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
@@ -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(