mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:25:29 +08:00
568 lines
16 KiB
JavaScript
568 lines
16 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { createHash } = require('node:crypto');
|
|
const { test } = require('node:test');
|
|
|
|
const {
|
|
assertPluginPackageAutomationPublicationSuccessor,
|
|
createInitialPluginPackageAutomationPublication,
|
|
createNextPluginPackageAutomationPublication,
|
|
createPluginPackageAutomationLifecyclePublication,
|
|
InvalidPluginPackageAutomationPublicationError,
|
|
normalizePluginPackageAutomationPublication,
|
|
PluginPackageAutomationPublicationConflictError,
|
|
PluginPackageAutomationPublicationCoordinator,
|
|
PluginPackageAutomationPublicationRecoveryCoordinator,
|
|
PluginPackageAutomationPublicationUnavailableError,
|
|
pluginPackageAutomationDefinitionsFromRevision,
|
|
pluginPackageAutomationPublicationDigest,
|
|
} = require('../dist/plugin-package/pluginPackageAutomationPublication');
|
|
const {
|
|
pluginPackageTaskReconciliationFixture,
|
|
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
|
|
|
function digest(value) {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function automationFixture(namespace = 'automation-publication') {
|
|
return pluginPackageTaskReconciliationFixture(namespace, {
|
|
workflows: [
|
|
{
|
|
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
|
id: 'daily',
|
|
name: 'Daily workflow',
|
|
enabled: true,
|
|
steps: [
|
|
{
|
|
id: 'run',
|
|
task: 'alpha',
|
|
needs: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
prompts: [
|
|
{
|
|
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
|
id: 'greeting',
|
|
name: 'Greeting prompt',
|
|
template: 'Hello {{name}}',
|
|
parameters: [
|
|
{
|
|
name: 'name',
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
function automationCoordinatorAuthorities(
|
|
namespace = 'automation-publication-coordinator',
|
|
) {
|
|
const first = automationFixture(namespace);
|
|
let generation = first.revision.generation;
|
|
let head = null;
|
|
let observations = 0;
|
|
const revisions = new Map([
|
|
[first.revision.generation.generationDigest, first.revision],
|
|
]);
|
|
const publications = new Map();
|
|
const generationSource = {
|
|
async findActiveResourceGeneration() {
|
|
observations += 1;
|
|
return generation;
|
|
},
|
|
};
|
|
const materializedRepository = {
|
|
async find(generationDigest) {
|
|
return revisions.get(generationDigest) ?? null;
|
|
},
|
|
};
|
|
const repository = {
|
|
async findCurrent() {
|
|
return head;
|
|
},
|
|
async findByDigest(publicationDigest) {
|
|
return publications.get(publicationDigest) ?? null;
|
|
},
|
|
async publish(publication) {
|
|
const existing = publications.get(publication.publicationDigest);
|
|
if (existing) return { status: 'existing', publication: existing };
|
|
publications.set(publication.publicationDigest, publication);
|
|
head = publication;
|
|
return { status: 'created', publication };
|
|
},
|
|
};
|
|
const coordinator = new PluginPackageAutomationPublicationCoordinator({
|
|
generationSource,
|
|
materializedRepository,
|
|
repository,
|
|
taskSpecSemanticRegistry: first.registry,
|
|
now: () => 2_000 + observations,
|
|
});
|
|
return {
|
|
first,
|
|
coordinator,
|
|
generationSource,
|
|
materializedRepository,
|
|
repository,
|
|
revisions,
|
|
get generation() {
|
|
return generation;
|
|
},
|
|
set generation(value) {
|
|
generation = value;
|
|
},
|
|
get head() {
|
|
return head;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('creates one deterministic publication for Workflow and Prompt definitions', () => {
|
|
const value = automationFixture();
|
|
const definitions = pluginPackageAutomationDefinitionsFromRevision(
|
|
value.revision,
|
|
value.registry,
|
|
);
|
|
assert.deepEqual(
|
|
definitions.workflows.map(({ id }) => id),
|
|
['daily'],
|
|
);
|
|
assert.deepEqual(
|
|
definitions.prompts.map(({ id }) => id),
|
|
['greeting'],
|
|
);
|
|
|
|
const publication = createInitialPluginPackageAutomationPublication(
|
|
value.revision,
|
|
value.registry,
|
|
1_000,
|
|
);
|
|
assert.equal(publication.state, 'active');
|
|
assert.equal(publication.version, 1);
|
|
assert.equal(publication.previousPublicationDigest, null);
|
|
assert.equal(publication.lifecycleEventDigest, null);
|
|
assert.equal(
|
|
publication.target.materializedRevisionDigest,
|
|
value.revision.revisionDigest,
|
|
);
|
|
assert.equal(
|
|
publication.publicationDigest,
|
|
pluginPackageAutomationPublicationDigest(publication),
|
|
);
|
|
assert.deepEqual(
|
|
normalizePluginPackageAutomationPublication(publication),
|
|
publication,
|
|
);
|
|
assert.equal(
|
|
createInitialPluginPackageAutomationPublication(
|
|
value.revision,
|
|
value.registry,
|
|
1_000,
|
|
).publicationDigest,
|
|
publication.publicationDigest,
|
|
);
|
|
});
|
|
|
|
test('withdraws and restores the same immutable definitions through a digest chain', () => {
|
|
const value = automationFixture('automation-lifecycle');
|
|
const initial = createInitialPluginPackageAutomationPublication(
|
|
value.revision,
|
|
value.registry,
|
|
1_000,
|
|
);
|
|
const disabledEventDigest = digest('disabled');
|
|
const withdrawn = createPluginPackageAutomationLifecyclePublication({
|
|
previous: initial,
|
|
state: 'withdrawn',
|
|
lifecycleEventDigest: disabledEventDigest,
|
|
publishedAtMs: 1_001,
|
|
});
|
|
assert.equal(withdrawn.state, 'withdrawn');
|
|
assert.equal(withdrawn.version, 2);
|
|
assert.equal(
|
|
withdrawn.previousPublicationDigest,
|
|
initial.publicationDigest,
|
|
);
|
|
assert.equal(withdrawn.lifecycleEventDigest, disabledEventDigest);
|
|
assert.deepEqual(withdrawn.definitions, initial.definitions);
|
|
|
|
const enabledEventDigest = digest('enabled');
|
|
const restored = createPluginPackageAutomationLifecyclePublication({
|
|
previous: withdrawn,
|
|
state: 'active',
|
|
lifecycleEventDigest: enabledEventDigest,
|
|
publishedAtMs: 1_002,
|
|
});
|
|
assert.equal(restored.state, 'active');
|
|
assert.equal(restored.version, 3);
|
|
assert.equal(
|
|
restored.previousPublicationDigest,
|
|
withdrawn.publicationDigest,
|
|
);
|
|
assert.equal(restored.lifecycleEventDigest, enabledEventDigest);
|
|
assert.deepEqual(restored.definitions, initial.definitions);
|
|
});
|
|
|
|
test('replaces one active publication with the next materialized generation', () => {
|
|
const first = automationFixture('automation-upgrade');
|
|
const initial = createInitialPluginPackageAutomationPublication(
|
|
first.revision,
|
|
first.registry,
|
|
1_000,
|
|
);
|
|
const second = pluginPackageTaskReconciliationFixture(first.namespace, {
|
|
previous: first,
|
|
tasks: [
|
|
['alpha', 'alpha-v2'],
|
|
['beta', 'beta'],
|
|
],
|
|
workflows: [
|
|
{
|
|
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
|
id: 'hourly',
|
|
name: 'Hourly workflow',
|
|
enabled: true,
|
|
steps: [{ id: 'run', task: 'alpha', needs: [] }],
|
|
},
|
|
],
|
|
prompts: [
|
|
{
|
|
schema: 'qinglong/plugin-package-prompt-resource@v1',
|
|
id: 'summary',
|
|
name: 'Summary prompt',
|
|
template: 'Summarize {{topic}}',
|
|
parameters: [{ name: 'topic', required: true }],
|
|
},
|
|
],
|
|
});
|
|
const next = createNextPluginPackageAutomationPublication(
|
|
second.revision,
|
|
second.registry,
|
|
initial,
|
|
1_100,
|
|
);
|
|
assert.equal(next.version, 2);
|
|
assert.equal(next.state, 'active');
|
|
assert.equal(next.lifecycleEventDigest, null);
|
|
assert.equal(next.previousPublicationDigest, initial.publicationDigest);
|
|
assert.equal(next.target.generation, 2);
|
|
assert.deepEqual(
|
|
next.definitions.workflows.map(({ id }) => id),
|
|
['hourly'],
|
|
);
|
|
assert.doesNotThrow(() =>
|
|
assertPluginPackageAutomationPublicationSuccessor(initial, next),
|
|
);
|
|
});
|
|
|
|
test('rejects tampering and invalid lifecycle transitions', () => {
|
|
const value = automationFixture('automation-invalid');
|
|
const initial = createInitialPluginPackageAutomationPublication(
|
|
value.revision,
|
|
value.registry,
|
|
1_000,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
normalizePluginPackageAutomationPublication({
|
|
...initial,
|
|
publishedAtMs: 1_001,
|
|
}),
|
|
/publicationDigest does not match publication/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
createPluginPackageAutomationLifecyclePublication({
|
|
previous: initial,
|
|
state: 'active',
|
|
lifecycleEventDigest: digest('same-state'),
|
|
publishedAtMs: 1_001,
|
|
}),
|
|
/must toggle its state/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
createPluginPackageAutomationLifecyclePublication({
|
|
previous: initial,
|
|
state: 'withdrawn',
|
|
lifecycleEventDigest: digest('time-travel'),
|
|
publishedAtMs: 999,
|
|
}),
|
|
/precedes the previous publication/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
assertPluginPackageAutomationPublicationSuccessor(initial, {
|
|
...initial,
|
|
version: 2,
|
|
previousPublicationDigest: initial.publicationDigest,
|
|
publicationDigest: pluginPackageAutomationPublicationDigest({
|
|
...initial,
|
|
version: 2,
|
|
previousPublicationDigest: initial.publicationDigest,
|
|
}),
|
|
}),
|
|
/generation successor is invalid/,
|
|
);
|
|
});
|
|
|
|
test('publishes an absent tombstone for a generation without automation', () => {
|
|
const value = pluginPackageTaskReconciliationFixture('automation-empty');
|
|
assert.equal(
|
|
pluginPackageAutomationDefinitionsFromRevision(
|
|
value.revision,
|
|
value.registry,
|
|
),
|
|
null,
|
|
);
|
|
const publication = createInitialPluginPackageAutomationPublication(
|
|
value.revision,
|
|
value.registry,
|
|
1_000,
|
|
);
|
|
assert.equal(publication.state, 'absent');
|
|
assert.equal(publication.version, 1);
|
|
assert.deepEqual(publication.definitions, {
|
|
workflows: [],
|
|
prompts: [],
|
|
});
|
|
assert.throws(
|
|
() =>
|
|
createPluginPackageAutomationLifecyclePublication({
|
|
previous: publication,
|
|
state: 'active',
|
|
lifecycleEventDigest: digest('invalid-absent-toggle'),
|
|
publishedAtMs: 1_001,
|
|
}),
|
|
/must toggle its state/,
|
|
);
|
|
});
|
|
|
|
test('chains active, absent and active across every Package generation', () => {
|
|
const first = automationFixture('automation-tombstone-chain');
|
|
const initial = createInitialPluginPackageAutomationPublication(
|
|
first.revision,
|
|
first.registry,
|
|
1_000,
|
|
);
|
|
const second = pluginPackageTaskReconciliationFixture(first.namespace, {
|
|
previous: first,
|
|
});
|
|
const absent = createNextPluginPackageAutomationPublication(
|
|
second.revision,
|
|
second.registry,
|
|
initial,
|
|
1_100,
|
|
);
|
|
assert.equal(absent.state, 'absent');
|
|
assert.equal(absent.target.generation, 2);
|
|
assert.deepEqual(absent.definitions, { workflows: [], prompts: [] });
|
|
|
|
const third = pluginPackageTaskReconciliationFixture(first.namespace, {
|
|
previous: second,
|
|
workflows: [
|
|
{
|
|
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
|
id: 'restored',
|
|
name: 'Restored workflow',
|
|
enabled: true,
|
|
steps: [{ id: 'run', task: 'alpha', needs: [] }],
|
|
},
|
|
],
|
|
});
|
|
const restored = createNextPluginPackageAutomationPublication(
|
|
third.revision,
|
|
third.registry,
|
|
absent,
|
|
1_200,
|
|
);
|
|
assert.equal(restored.state, 'active');
|
|
assert.equal(restored.target.generation, 3);
|
|
assert.equal(
|
|
restored.previousPublicationDigest,
|
|
absent.publicationDigest,
|
|
);
|
|
assert.deepEqual(
|
|
restored.definitions.workflows.map(({ id }) => id),
|
|
['restored'],
|
|
);
|
|
});
|
|
|
|
test('coordinator publishes each materialized generation and preserves lifecycle state on replay', async () => {
|
|
const value = automationCoordinatorAuthorities();
|
|
const initial = await value.coordinator.publishActive(
|
|
value.first.projectId,
|
|
value.first.packageName,
|
|
);
|
|
assert.equal(initial.status, 'current');
|
|
assert.equal(initial.publication, 'created');
|
|
assert.equal(initial.record.state, 'active');
|
|
|
|
const withdrawn = createPluginPackageAutomationLifecyclePublication({
|
|
previous: initial.record,
|
|
state: 'withdrawn',
|
|
lifecycleEventDigest: digest('coordinator-disabled'),
|
|
publishedAtMs: 2_100,
|
|
});
|
|
await value.repository.publish(withdrawn);
|
|
const replay = await value.coordinator.publishActive(
|
|
value.first.projectId,
|
|
value.first.packageName,
|
|
);
|
|
assert.equal(replay.status, 'current');
|
|
assert.equal(replay.publication, 'existing');
|
|
assert.equal(replay.record.state, 'withdrawn');
|
|
|
|
const empty = pluginPackageTaskReconciliationFixture(value.first.namespace, {
|
|
previous: value.first,
|
|
});
|
|
value.revisions.set(
|
|
empty.revision.generation.generationDigest,
|
|
empty.revision,
|
|
);
|
|
value.generation = empty.revision.generation;
|
|
const tombstone = await value.coordinator.publishActive(
|
|
value.first.projectId,
|
|
value.first.packageName,
|
|
);
|
|
assert.equal(tombstone.status, 'current');
|
|
assert.equal(tombstone.record.state, 'absent');
|
|
assert.equal(tombstone.record.version, 3);
|
|
assert.deepEqual(tombstone.record.definitions, {
|
|
workflows: [],
|
|
prompts: [],
|
|
});
|
|
});
|
|
|
|
test('coordinator treats a final generation switch as superseded', async () => {
|
|
const value = automationCoordinatorAuthorities(
|
|
'automation-publication-superseded',
|
|
);
|
|
let observations = 0;
|
|
const source = {
|
|
async findActiveResourceGeneration() {
|
|
observations += 1;
|
|
return observations === 1 ? value.generation : null;
|
|
},
|
|
};
|
|
const coordinator = new PluginPackageAutomationPublicationCoordinator({
|
|
generationSource: source,
|
|
materializedRepository: value.materializedRepository,
|
|
repository: value.repository,
|
|
taskSpecSemanticRegistry: value.first.registry,
|
|
now: () => 3_000,
|
|
});
|
|
assert.deepEqual(
|
|
await coordinator.publishActive(
|
|
value.first.projectId,
|
|
value.first.packageName,
|
|
),
|
|
{
|
|
status: 'superseded',
|
|
generationDigest:
|
|
value.first.revision.generation.generationDigest,
|
|
},
|
|
);
|
|
});
|
|
|
|
test('bounded automation recovery converges and classifies manual and retry failures', async () => {
|
|
const value = automationCoordinatorAuthorities(
|
|
'automation-publication-recovery',
|
|
);
|
|
let pending = [
|
|
{
|
|
projectId: value.first.projectId,
|
|
packageName: value.first.packageName,
|
|
},
|
|
];
|
|
const source = {
|
|
async listPendingPage({ limit }) {
|
|
return {
|
|
candidates: pending.slice(0, limit),
|
|
truncated: false,
|
|
};
|
|
},
|
|
};
|
|
const publisher = Object.create(
|
|
PluginPackageAutomationPublicationCoordinator.prototype,
|
|
);
|
|
publisher.publishActive = async (projectId, packageName) => {
|
|
const result = await value.coordinator.publishActive(
|
|
projectId,
|
|
packageName,
|
|
);
|
|
if (result.status === 'current') pending = [];
|
|
return result;
|
|
};
|
|
const recovery =
|
|
new PluginPackageAutomationPublicationRecoveryCoordinator({
|
|
source,
|
|
publisher,
|
|
});
|
|
assert.deepEqual(
|
|
await recovery.recover({ pageSize: 1, maxPages: 1 }),
|
|
{
|
|
pages: 1,
|
|
scanned: 1,
|
|
settled: 1,
|
|
retry: 0,
|
|
manualRequired: 0,
|
|
superseded: 0,
|
|
remaining: false,
|
|
safeToAdmit: true,
|
|
},
|
|
);
|
|
|
|
const failures = Object.create(
|
|
PluginPackageAutomationPublicationCoordinator.prototype,
|
|
);
|
|
failures.publishActive = async (_projectId, packageName) => {
|
|
if (packageName === 'manual') {
|
|
throw new PluginPackageAutomationPublicationConflictError('conflict');
|
|
}
|
|
throw new PluginPackageAutomationPublicationUnavailableError();
|
|
};
|
|
const failedRecovery =
|
|
new PluginPackageAutomationPublicationRecoveryCoordinator({
|
|
source: {
|
|
async listPendingPage({ limit }) {
|
|
const candidates = [
|
|
{ projectId: value.first.projectId, packageName: 'manual' },
|
|
{ projectId: value.first.projectId, packageName: 'retry' },
|
|
].slice(0, limit);
|
|
const truncated = limit < 2;
|
|
const last = candidates.at(-1);
|
|
return {
|
|
candidates,
|
|
truncated,
|
|
...(truncated
|
|
? {
|
|
next: {
|
|
projectId: last.projectId,
|
|
packageName: last.packageName,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
},
|
|
},
|
|
publisher: failures,
|
|
});
|
|
const result = await failedRecovery.recover({
|
|
pageSize: 2,
|
|
maxPages: 1,
|
|
});
|
|
assert.equal(result.manualRequired, 1);
|
|
assert.equal(result.retry, 1);
|
|
assert.equal(result.remaining, true);
|
|
assert.equal(result.safeToAdmit, false);
|
|
await assert.rejects(
|
|
() => failedRecovery.recover({ pageSize: 0 }),
|
|
InvalidPluginPackageAutomationPublicationError,
|
|
);
|
|
});
|