feat(ql3): qualify plugin upgrades before activation

This commit is contained in:
whyour
2026-08-14 13:01:26 +08:00
parent 3ca9901055
commit 056eebef94
12 changed files with 691 additions and 64 deletions
@@ -40,6 +40,10 @@ export type PluginPackageActivationPrerequisiteObservation =
| Readonly<{
status: 'deferred';
reason: 'secret_binding_transition_required';
}>
| Readonly<{
status: 'rejected';
reason: 'activation_fact_conflict';
}>;
export interface PluginPackageActivationPrerequisite {
@@ -49,6 +53,37 @@ export interface PluginPackageActivationPrerequisite {
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>>;
}
export function sequencePluginPackageActivationPrerequisites(
prerequisites: readonly PluginPackageActivationPrerequisite[],
): PluginPackageActivationPrerequisite {
if (
!Array.isArray(prerequisites) ||
prerequisites.length < 1 ||
prerequisites.length > 8 ||
prerequisites.some(
(prerequisite) =>
!prerequisite || typeof prerequisite.inspect !== 'function',
)
) {
throw new InvalidPluginPackageInstallError(
'activation prerequisite sequence is invalid',
);
}
const sequence = Object.freeze([...prerequisites]);
return Object.freeze({
async inspect(
record: Readonly<PluginPackageInstallRecord>,
lock: Readonly<PluginPackageLock>,
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>> {
for (const prerequisite of sequence) {
const observation = await prerequisite.inspect(record, lock);
if (observation.status !== 'ready') return observation;
}
return Object.freeze({ status: 'ready' as const });
},
});
}
export interface InstallPluginPackageOptions {
readonly lock: PluginPackageLock;
readonly proposalDigest: string;
@@ -224,6 +259,19 @@ export class PluginPackageInstallationCoordinator {
lock,
);
if (prerequisite?.status === 'deferred') return record;
if (prerequisite?.status === 'rejected') {
const failed = transitionPluginPackageInstall(lock, record, {
type: 'failed',
mutationId: options.activationFailedMutationId,
occurredAtMs: options.activationObservedAtMs,
reason: prerequisite.reason,
});
return (
await this.#repository.commit(
pluginPackageInstallCommit(record, failed),
)
).record;
}
return this.#activation.activate({
...identity,
activationStartedMutationId: options.activationStartedMutationId,
@@ -311,6 +311,25 @@ export class PluginPackageRecoveryCoordinator {
lock,
);
if (prerequisite?.status === 'deferred') return record;
if (prerequisite?.status === 'rejected') {
const failed = transitionPluginPackageInstall(lock, record, {
type: 'failed',
mutationId: mutationId(
'activation-preparation-failed',
record,
occurredAtMs,
),
occurredAtMs,
reason: prerequisite.reason,
});
return normalizePluginPackageInstallRecord(
(
await this.#repository.commit(
pluginPackageInstallCommit(record, failed),
)
).record,
);
}
return this.#activation.activate({
...identity,
activationStartedMutationId: mutationId(
@@ -10,12 +10,21 @@ import {
type PluginPackageContentEntryDescriptor,
} from './pluginPackageBundle';
import {
PluginPackageInstallUnavailableError,
assertPluginPackageInstallMatchesLock,
normalizePluginPackageInstallRecord,
normalizePluginPackageLock,
pluginPackageManifestDigest,
serializePluginPackageManifest,
type PluginPackageInstallRecord,
type PluginPackageLock,
} from './installation/pluginPackageInstall';
import type {
PluginPackageActivationPrerequisite,
PluginPackageActivationPrerequisiteObservation,
} from './installation/pluginPackageInstallation';
import {
createPluginPackageResourceGenerationFromReferences,
normalizePluginPackageResourceGeneration,
pluginPackageResourceReferencesFromContents,
type PluginPackageResourceGeneration,
@@ -185,6 +194,17 @@ export interface MaterializeActivePluginPackageResourcesOptions {
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
}
export interface MaterializePluginPackageResourceGenerationOptions {
readonly generation: Readonly<PluginPackageResourceGeneration>;
readonly lock: Readonly<PluginPackageLock>;
readonly byteSource: PluginPackageResourceByteSource;
readonly secretBindingSource?: Pick<
PluginPackageSecretBindingRepository,
'find'
>;
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
}
export interface PluginPackageMaterializedRevisionRepository {
find(
generationDigest: string,
@@ -197,6 +217,115 @@ export interface PluginPackageMaterializedRevisionRepository {
>;
}
export class PluginPackageResourceActivationPrerequisite
implements PluginPackageActivationPrerequisite
{
constructor(
private readonly options: {
readonly byteSource: PluginPackageResourceByteSource;
readonly materializedRepository: PluginPackageMaterializedRevisionRepository;
readonly secretBindingSource?: Pick<
PluginPackageSecretBindingRepository,
'find'
>;
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
},
) {
const authorities = dataRecord(options, 'activation prerequisite options');
exactKeys(
authorities,
['byteSource', 'materializedRepository', 'taskSpecSemanticRegistry'],
['secretBindingSource'],
'activation prerequisite options',
);
if (
!options.byteSource ||
typeof options.byteSource.open !== 'function' ||
!options.materializedRepository ||
typeof options.materializedRepository.find !== 'function' ||
typeof options.materializedRepository.publish !== 'function' ||
(options.secretBindingSource !== undefined &&
(!options.secretBindingSource ||
typeof options.secretBindingSource.find !== 'function')) ||
!(options.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)
) {
invalid('activation prerequisite authority is invalid');
}
}
async inspect(
recordValue: Readonly<PluginPackageInstallRecord>,
lockValue: Readonly<PluginPackageLock>,
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>> {
try {
const record = normalizePluginPackageInstallRecord(recordValue);
const lock = normalizePluginPackageLock(lockValue);
assertPluginPackageInstallMatchesLock(lock, record);
if (record.state !== 'staged') {
invalid('activation prerequisite requires a staged install');
}
// Generation one may require the post-activation B1 binding ceremony.
// There is no healthy previous pointer to preserve in that flow.
if (record.previousActiveLockDigest === null) {
return Object.freeze({ status: 'ready' as const });
}
const generation = createPluginPackageResourceGenerationFromReferences({
installationId: record.installationId,
projectId: record.projectId,
packageName: record.packageName,
lockDigest: lock.lockDigest,
generation: lock.targetGeneration,
previousActiveLockDigest: record.previousActiveLockDigest,
contentDigest: lock.source.contentDigest,
resources: lock.resources,
});
let revision = await this.options.materializedRepository.find(
generation.generationDigest,
);
if (revision === null) {
const candidate = await materializePluginPackageResourceGeneration({
generation,
lock,
byteSource: this.options.byteSource,
...(this.options.secretBindingSource === undefined
? {}
: { secretBindingSource: this.options.secretBindingSource }),
taskSpecSemanticRegistry: this.options.taskSpecSemanticRegistry,
});
revision = (
await this.options.materializedRepository.publish(candidate)
).revision;
}
const durable = normalizePluginPackageMaterializedRevision(
revision,
this.options.taskSpecSemanticRegistry,
);
if (
durable.generation.generationDigest !== generation.generationDigest ||
durable.generation.installationId !== record.installationId ||
durable.generation.lockDigest !== lock.lockDigest
) {
throw new PluginPackageResourceMaterializationConflictError(
'durable candidate revision does not match the staged generation',
);
}
return Object.freeze({ status: 'ready' as const });
} catch (error) {
if (
error instanceof InvalidPluginPackageResourceMaterializationError ||
error instanceof PluginPackageResourceMaterializationConflictError
) {
return Object.freeze({
status: 'rejected' as const,
reason: 'activation_fact_conflict' as const,
});
}
if (error instanceof PluginPackageInstallUnavailableError) throw error;
throw new PluginPackageInstallUnavailableError();
}
}
}
export interface PluginPackageTaskDefinitionDraft {
readonly projectId: string;
readonly taskId: string;
@@ -1282,32 +1411,39 @@ function materializationSources(
}
}
export async function materializeActivePluginPackageResources(
value: MaterializeActivePluginPackageResourcesOptions,
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
materializationSources(value);
function generationMaterializationSources(
value: MaterializePluginPackageResourceGenerationOptions,
): void {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!value.byteSource ||
typeof value.byteSource.open !== 'function' ||
(value.secretBindingSource !== undefined &&
(!value.secretBindingSource ||
typeof value.secretBindingSource.find !== 'function')) ||
!(value.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)
) {
invalid('generation materialization sources are invalid');
}
}
/**
* Qualifies one immutable candidate generation without consulting or moving
* the active pointer. Callers may durably publish the returned revision before
* activation so deterministic Package errors cannot replace a healthy head.
*/
export async function materializePluginPackageResourceGeneration(
value: MaterializePluginPackageResourceGenerationOptions,
): Promise<Readonly<PluginPackageMaterializedRevision>> {
generationMaterializationSources(value);
try {
const first = await value.generationSource.findActiveResourceGeneration(
value.projectId,
value.packageName,
const generation = normalizePluginPackageResourceGeneration(
value.generation,
);
if (first === null) return null;
const generation = normalizePluginPackageResourceGeneration(first);
if (
generation.projectId !== value.projectId ||
generation.packageName !== value.packageName
) {
throw new PluginPackageResourceMaterializationConflictError(
'generation source returned another Package identity',
);
}
const lockValue = await value.lockSource.findLock(generation.lockDigest);
if (lockValue === null) {
throw new PluginPackageResourceMaterializationConflictError(
'active generation lock is missing',
);
}
const lock = normalizePluginPackageLock(lockValue);
const lock = normalizePluginPackageLock(value.lock);
assertGenerationMatchesLock(generation, lock);
const reader = await value.byteSource.open(generation);
if (
!reader ||
@@ -1350,22 +1486,70 @@ export async function materializeActivePluginPackageResources(
});
}
}
const activeManifest = normalizeManifestBytes(manifestBytes, lock);
const activeSecretBindingValue =
activeManifest.spec.permissions.secrets.length === 0
const manifest = normalizeManifestBytes(manifestBytes, lock);
const bindingValue =
manifest.spec.permissions.secrets.length === 0
? undefined
: await value.secretBindingSource?.find(generation.generationDigest);
const activeSecretBinding = activeSecretBindingValue ?? undefined;
const revision = materializePluginPackageResources({
return materializePluginPackageResources({
generation,
lock,
manifestBytes,
...(activeSecretBinding === undefined
...(bindingValue === undefined || bindingValue === null
? {}
: { secretBinding: activeSecretBinding }),
: { secretBinding: bindingValue }),
resources: Object.freeze(resources),
taskSpecSemanticRegistry: value.taskSpecSemanticRegistry,
});
} catch (error) {
if (
error instanceof InvalidPluginPackageResourceMaterializationError ||
error instanceof PluginPackageResourceMaterializationConflictError ||
error instanceof PluginPackageResourceMaterializationUnavailableError
) {
throw error;
}
throw new PluginPackageResourceMaterializationUnavailableError({
cause: error instanceof Error ? error : undefined,
});
}
}
export async function materializeActivePluginPackageResources(
value: MaterializeActivePluginPackageResourcesOptions,
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
materializationSources(value);
try {
const first = await value.generationSource.findActiveResourceGeneration(
value.projectId,
value.packageName,
);
if (first === null) return null;
const generation = normalizePluginPackageResourceGeneration(first);
if (
generation.projectId !== value.projectId ||
generation.packageName !== value.packageName
) {
throw new PluginPackageResourceMaterializationConflictError(
'generation source returned another Package identity',
);
}
const lockValue = await value.lockSource.findLock(generation.lockDigest);
if (lockValue === null) {
throw new PluginPackageResourceMaterializationConflictError(
'active generation lock is missing',
);
}
const lock = normalizePluginPackageLock(lockValue);
const revision = await materializePluginPackageResourceGeneration({
generation,
lock,
byteSource: value.byteSource,
...(value.secretBindingSource === undefined
? {}
: { secretBindingSource: value.secretBindingSource }),
taskSpecSemanticRegistry: value.taskSpecSemanticRegistry,
});
const secondValue =
await value.generationSource.findActiveResourceGeneration(
value.projectId,
@@ -20,13 +20,18 @@ const {
const {
PluginPackageRecoveryCoordinator,
} = require('../dist/plugin-package/installation/pluginPackageRecovery');
const {
sequencePluginPackageActivationPrerequisites,
} = require('../dist/plugin-package/installation/pluginPackageInstallation');
const ARTIFACT_DIGEST = 'a'.repeat(64);
const CONTENT_DIGEST = 'b'.repeat(64);
const PREVIOUS_LOCK_DIGEST = 'c'.repeat(64);
function fixture(
packageName = 'example-monitor',
installationId = 'install-001',
options = {},
) {
const manifest = {
apiVersion: PLUGIN_PACKAGE_API_VERSION,
@@ -65,13 +70,24 @@ function fixture(
availableMemoryBytes: 128 * 1024 * 1024,
availableDiskBytes: 256 * 1024 * 1024,
};
const plan = planPluginPackageInstall(manifest, environment);
const previousManifest = options.upgrade
? {
...manifest,
metadata: { ...manifest.metadata, version: '1.1.0' },
}
: undefined;
const plan = planPluginPackageInstall(
manifest,
environment,
previousManifest,
);
const action = {
lockId: `lock-${packageName}`,
projectId: 'default',
manifest,
plan,
environment,
...(previousManifest === undefined ? {} : { previousManifest }),
source: {
kind: 'offline',
locator: `offline:sha256:${ARTIFACT_DIGEST}`,
@@ -81,7 +97,8 @@ function fixture(
},
architecture: 'arm64',
deploymentProfile: 'edge',
targetGeneration: 1,
targetGeneration: options.upgrade ? 2 : 1,
...(options.upgrade ? { previousLockDigest: PREVIOUS_LOCK_DIGEST } : {}),
};
const lock = createPluginPackageLock({
...action,
@@ -116,8 +133,8 @@ function stageEvidence(lock) {
};
}
function stagedFixture(packageName, installationId) {
const value = fixture(packageName, installationId);
function stagedFixture(packageName, installationId, options) {
const value = fixture(packageName, installationId, options);
const staged = transitionPluginPackageInstall(value.lock, value.queued, {
type: 'stage_completed',
mutationId: `mutation-stage-${value.lock.packageName}`,
@@ -353,6 +370,80 @@ test('stages but defers activation until the exact prerequisite is ready', async
});
});
test('orders activation prerequisites and stops before later authorities', async () => {
const value = stagedFixture();
const calls = [];
let first = {
status: 'deferred',
reason: 'secret_binding_transition_required',
};
const sequence = sequencePluginPackageActivationPrerequisites([
{
async inspect() {
calls.push('secret');
return first;
},
},
{
async inspect() {
calls.push('candidate');
return {
status: 'rejected',
reason: 'activation_fact_conflict',
};
},
},
]);
assert.deepEqual(await sequence.inspect(value.staged, value.lock), first);
assert.deepEqual(calls, ['secret']);
first = { status: 'ready' };
assert.deepEqual(await sequence.inspect(value.staged, value.lock), {
status: 'rejected',
reason: 'activation_fact_conflict',
});
assert.deepEqual(calls, ['secret', 'secret', 'candidate']);
});
test('rejects an invalid upgrade before publication and retains the previous active lock', async () => {
const value = stagedFixture('example-monitor', 'install-upgrade', {
upgrade: true,
});
const repository = new MemoryRepository([{ ...value, record: value.staged }]);
const calls = { stage: 0, publish: 0, inspect: 0 };
const coordinator = new PluginPackageRecoveryCoordinator({
repository,
stageProvider: {
async stage() {
calls.stage += 1;
throw new Error('stage must not run');
},
},
publisher: publisherFor(repository, calls),
activationPrerequisite: {
async inspect() {
return {
status: 'rejected',
reason: 'activation_fact_conflict',
};
},
},
now: () => 250,
});
const page = await coordinator.recoverPage({ limit: 1 });
const durable = await repository.find('default', 'example-monitor');
assert.equal(page.items[0].status, 'settled');
assert.equal(durable.state, 'failed');
assert.equal(durable.previousActiveLockDigest, PREVIOUS_LOCK_DIGEST);
assert.equal(durable.activeLockDigest, PREVIOUS_LOCK_DIGEST);
assert.equal(durable.failure.reason, 'activation_fact_conflict');
assert.equal(durable.failure.failedFrom, 'staged');
assert.deepEqual(calls, { stage: 0, publish: 0, inspect: 0 });
});
test('inspects an activating install without republishing it', async () => {
const value = activatingFixture();
const repository = new MemoryRepository([
@@ -12,9 +12,11 @@ const {
} = require('../dist/plugin-package/pluginPackage');
const {
createPluginPackageLock,
createPluginPackageInstall,
pluginPackageInstallActionDigest,
pluginPackageInstallPlanDigest,
serializePluginPackageManifest,
transitionPluginPackageInstall,
} = require('../dist/plugin-package/installation/pluginPackageInstall');
const {
pluginPackageContentTreeDigest,
@@ -30,6 +32,7 @@ const {
InvalidPluginPackageResourceMaterializationError,
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA,
PluginPackageResourceActivationPrerequisite,
PluginPackageResourceMaterializationConflictError,
materializeActivePluginPackageResources,
materializePluginPackageResources,
@@ -40,6 +43,7 @@ const {
const ARTIFACT_DIGEST = 'a'.repeat(64);
const OCI_MANIFEST_DIGEST = 'f'.repeat(64);
const PREVIOUS_LOCK_DIGEST = 'c'.repeat(64);
function resourceValues(overrides = {}) {
return {
@@ -208,13 +212,24 @@ function fixture(options = {}) {
.sort((left, right) => left.path.localeCompare(right.path));
const contentDigest = pluginPackageContentTreeDigest(descriptors);
const installEnvironment = environment();
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
const previousManifest = options.upgrade
? {
...packageManifest,
metadata: { ...packageManifest.metadata, version: '0.9.0' },
}
: undefined;
const plan = planPluginPackageInstall(
packageManifest,
installEnvironment,
previousManifest,
);
const actionInput = {
lockId: 'lock-001',
projectId: 'project-001',
manifest: packageManifest,
plan,
environment: installEnvironment,
...(previousManifest === undefined ? {} : { previousManifest }),
source: {
kind: 'oci',
locator:
@@ -226,7 +241,10 @@ function fixture(options = {}) {
},
architecture: 'arm64',
deploymentProfile: 'edge',
targetGeneration: 1,
targetGeneration: options.upgrade ? 2 : 1,
...(options.upgrade
? { previousLockDigest: PREVIOUS_LOCK_DIGEST }
: {}),
};
const lock = createPluginPackageLock({
...actionInput,
@@ -249,7 +267,9 @@ function fixture(options = {}) {
packageName: lock.packageName,
lockDigest: lock.lockDigest,
generation: lock.targetGeneration,
previousActiveLockDigest: null,
previousActiveLockDigest: options.upgrade
? PREVIOUS_LOCK_DIGEST
: null,
contentDigest,
resources: lock.resources,
});
@@ -268,6 +288,24 @@ function fixture(options = {}) {
};
}
function stagedRecord(value) {
const queued = createPluginPackageInstall(value.lock, {
installationId: value.generation.installationId,
mutationId: 'candidate-created',
occurredAtMs: 201,
});
return transitionPluginPackageInstall(value.lock, queued, {
type: 'stage_completed',
mutationId: 'candidate-staged',
occurredAtMs: 202,
stageRef: `stage:${value.lock.lockDigest}`,
artifactDigest: value.lock.source.artifactDigest,
manifestDigest: value.lock.manifestDigest,
contentDigest: value.lock.source.contentDigest,
evidenceDigest: 'e'.repeat(64),
});
}
test('materializes exact Task, Workflow, Prompt and Tool JSON into one immutable revision', () => {
const value = fixture();
const revision = materializePluginPackageResources({
@@ -707,6 +745,134 @@ test('reads active bytes sequentially with explicit bounds and rejects a generat
);
});
test('qualifies and durably publishes a staged candidate before activation', async () => {
const value = fixture({ upgrade: true });
const record = stagedRecord(value);
let durable = null;
let publications = 0;
const prerequisite = new PluginPackageResourceActivationPrerequisite({
byteSource: {
async open(generation) {
assert.equal(
generation.generationDigest,
value.generation.generationDigest,
);
return {
async read(path) {
return path === 'package.json'
? value.manifestBytes
: value.resourceBytes[path];
},
async close() {},
};
},
},
materializedRepository: {
async find(generationDigest) {
return durable?.generation.generationDigest === generationDigest
? durable
: null;
},
async publish(revision) {
publications += 1;
durable = revision;
return { status: 'created', revision };
},
},
taskSpecSemanticRegistry: value.registry,
});
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
status: 'ready',
});
assert.equal(publications, 1);
assert.equal(
durable.generation.generationDigest,
value.generation.generationDigest,
);
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
status: 'ready',
});
assert.equal(publications, 1);
});
test('rejects a semantically invalid staged candidate without publishing it', async () => {
const value = fixture({
upgrade: true,
resourceValues: {
'workflows/daily.json': {
schema: 'qinglong/plugin-package-workflow-resource@v1',
id: 'daily',
name: 'Daily report',
enabled: true,
steps: [{ id: 'missing', task: 'missing', needs: [] }],
},
},
});
const record = stagedRecord(value);
let publications = 0;
const prerequisite = new PluginPackageResourceActivationPrerequisite({
byteSource: {
async open() {
return {
async read(path) {
return path === 'package.json'
? value.manifestBytes
: value.resourceBytes[path];
},
async close() {},
};
},
},
materializedRepository: {
async find() {
return null;
},
async publish(revision) {
publications += 1;
return { status: 'created', revision };
},
},
taskSpecSemanticRegistry: value.registry,
});
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
status: 'rejected',
reason: 'activation_fact_conflict',
});
assert.equal(publications, 0);
});
test('leaves generation-one qualification to the post-activation B1 binding ceremony', async () => {
const value = fixture();
const record = stagedRecord(value);
let authorityCalls = 0;
const prerequisite = new PluginPackageResourceActivationPrerequisite({
byteSource: {
async open() {
authorityCalls += 1;
throw new Error('generation one must not read candidate bytes');
},
},
materializedRepository: {
async find() {
authorityCalls += 1;
return null;
},
async publish(revision) {
authorityCalls += 1;
return { status: 'created', revision };
},
},
taskSpecSemanticRegistry: value.registry,
});
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
status: 'ready',
});
assert.equal(authorityCalls, 0);
});
test('publishes materialization only through the explicit runtime-core subpath', () => {
assert.equal(require('../dist').materializePluginPackageResources, undefined);
assert.equal(