mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
325 lines
9.6 KiB
JavaScript
325 lines
9.6 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { test } = require('node:test');
|
|
|
|
const {
|
|
createInitialPluginPackageAutomationPublication,
|
|
} = require('../dist/plugin-package/pluginPackageAutomationPublication');
|
|
const {
|
|
createPluginPackageWorkflowAdmissionBundle,
|
|
createPluginPackageWorkflowExecutionPlan,
|
|
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
|
const {
|
|
InvalidPluginPackageWorkflowFrontierError,
|
|
resolvePluginPackageWorkflowFrontier,
|
|
} = require('@qinglong/runtime-core/plugin-package-workflow-frontier');
|
|
const {
|
|
transitionStepRunMutation,
|
|
} = require('../dist/run/stepRun');
|
|
const {
|
|
pluginPackageTaskReconciliationFixture,
|
|
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
|
|
|
function fixture(namespace = 'workflow-frontier', steps) {
|
|
const value = pluginPackageTaskReconciliationFixture(namespace, {
|
|
workflows: [
|
|
{
|
|
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
|
id: 'daily',
|
|
name: 'Daily workflow',
|
|
enabled: true,
|
|
steps:
|
|
steps ??
|
|
[
|
|
{ id: 'collect', task: 'alpha', needs: [] },
|
|
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const publication = createInitialPluginPackageAutomationPublication(
|
|
value.revision,
|
|
value.registry,
|
|
2_000,
|
|
);
|
|
const workflowSteps = steps ?? [
|
|
{ id: 'collect', task: 'alpha', needs: [] },
|
|
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
|
];
|
|
const plan = createPluginPackageWorkflowExecutionPlan({
|
|
planId: `${namespace}-plan`,
|
|
runId: `${namespace}-run`,
|
|
workflowId: 'daily',
|
|
stepRunIds: Object.fromEntries(
|
|
workflowSteps.map(({ id }) => [id, `${namespace}-${id}`]),
|
|
),
|
|
publication,
|
|
revision: value.revision,
|
|
taskSpecSemanticRegistry: value.registry,
|
|
plannedAtMs: 3_000,
|
|
});
|
|
return createPluginPackageWorkflowAdmissionBundle(plan);
|
|
}
|
|
|
|
function transition(stepRun, to, runVersion, runEventSequence, atMs) {
|
|
return transitionStepRunMutation(
|
|
stepRun,
|
|
{
|
|
expectedVersion: stepRun.version,
|
|
expectedDigest: stepRun.stepRunDigest,
|
|
mutationId: `test-${stepRun.stepKey}-${to}-${stepRun.version}`,
|
|
to,
|
|
atMs,
|
|
...(to === 'failed' ? { resultCode: 'task_failed' } : {}),
|
|
...(to === 'succeeded'
|
|
? { outputRef: `artifact:${stepRun.stepKey}` }
|
|
: {}),
|
|
},
|
|
{
|
|
expectedRunVersion: runVersion,
|
|
expectedRunEventSequence: runEventSequence,
|
|
eventId: `event-${stepRun.stepKey}-${to}-${stepRun.version}`,
|
|
dedupeKey: `event-${stepRun.stepKey}-${to}-${stepRun.version}`,
|
|
actor: { type: 'executor' },
|
|
},
|
|
).stepRun;
|
|
}
|
|
|
|
function admittedStepRuns(bundle) {
|
|
return bundle.stepMutations.map(({ stepRun }) => stepRun);
|
|
}
|
|
|
|
test('promotes a dependent pending Task after every need succeeds', () => {
|
|
const bundle = fixture();
|
|
const [collect, summarize] = admittedStepRuns(bundle);
|
|
const running = transition(
|
|
collect,
|
|
'running',
|
|
bundle.run.version,
|
|
bundle.run.eventSequence,
|
|
4_000,
|
|
);
|
|
const succeeded = transition(
|
|
running,
|
|
'succeeded',
|
|
bundle.run.version + 1,
|
|
bundle.run.eventSequence + 1,
|
|
5_000,
|
|
);
|
|
const resolution = resolvePluginPackageWorkflowFrontier({
|
|
plan: bundle.plan,
|
|
run: {
|
|
...bundle.run,
|
|
version: bundle.run.version + 2,
|
|
eventSequence: bundle.run.eventSequence + 2,
|
|
},
|
|
stepRuns: [succeeded, summarize],
|
|
observedAtMs: 6_000,
|
|
});
|
|
|
|
assert.equal(resolution.stepMutations.length, 1);
|
|
assert.equal(resolution.stepMutations[0].previousStatus, 'pending');
|
|
assert.equal(resolution.stepMutations[0].stepRun.status, 'ready');
|
|
assert.equal(resolution.stepMutations[0].event.id.length <= 36, true);
|
|
assert.equal(resolution.stepMutations[0].event.actorType, 'reconciler');
|
|
assert.deepEqual(resolution.readyStepRunIds, [summarize.id]);
|
|
assert.equal(resolution.terminalStatus, null);
|
|
});
|
|
|
|
test('propagates a required dependency failure through the whole DAG in one pass', () => {
|
|
const steps = [
|
|
{ id: 'collect', task: 'alpha', needs: [] },
|
|
{ id: 'prepare', task: 'beta', needs: ['collect'] },
|
|
{ id: 'publish', task: 'alpha', needs: ['prepare'] },
|
|
];
|
|
const bundle = fixture('workflow-frontier-failure', steps);
|
|
const [collect, prepare, publish] = admittedStepRuns(bundle);
|
|
const running = transition(
|
|
collect,
|
|
'running',
|
|
bundle.run.version,
|
|
bundle.run.eventSequence,
|
|
4_000,
|
|
);
|
|
const failed = transition(
|
|
running,
|
|
'failed',
|
|
bundle.run.version + 1,
|
|
bundle.run.eventSequence + 1,
|
|
5_000,
|
|
);
|
|
const resolution = resolvePluginPackageWorkflowFrontier({
|
|
plan: bundle.plan,
|
|
run: {
|
|
...bundle.run,
|
|
version: bundle.run.version + 2,
|
|
eventSequence: bundle.run.eventSequence + 2,
|
|
},
|
|
stepRuns: [failed, prepare, publish],
|
|
observedAtMs: 6_000,
|
|
});
|
|
|
|
assert.deepEqual(
|
|
resolution.stepMutations.map(({ stepRun }) => [
|
|
stepRun.stepKey,
|
|
stepRun.status,
|
|
stepRun.resultCode,
|
|
]),
|
|
[
|
|
['prepare', 'skipped', 'dependency_not_succeeded'],
|
|
['publish', 'skipped', 'dependency_not_succeeded'],
|
|
],
|
|
);
|
|
assert.equal(
|
|
resolution.stepMutations[1].expectedRunVersion,
|
|
resolution.stepMutations[0].expectedRunVersion + 1,
|
|
);
|
|
assert.deepEqual(resolution.readyStepRunIds, []);
|
|
assert.equal(resolution.terminalStatus, 'failed');
|
|
assert.equal(resolution.terminalTransition.status, 'failed');
|
|
assert.equal(
|
|
resolution.terminalTransition.errorCode,
|
|
'workflow_step_failed',
|
|
);
|
|
assert.equal(
|
|
resolution.terminalTransition.expectedRunVersion,
|
|
bundle.run.version + 4,
|
|
);
|
|
assert.equal(resolution.terminalTransition.event.id.length <= 36, true);
|
|
assert.equal(resolution.terminalTransition.event.type, 'workflow.failed');
|
|
});
|
|
|
|
test('keeps dependents pending while a required predecessor is executable', () => {
|
|
const bundle = fixture('workflow-frontier-waiting');
|
|
const resolution = resolvePluginPackageWorkflowFrontier({
|
|
plan: bundle.plan,
|
|
run: bundle.run,
|
|
stepRuns: admittedStepRuns(bundle),
|
|
observedAtMs: 4_000,
|
|
});
|
|
|
|
assert.deepEqual(resolution.stepMutations, []);
|
|
assert.deepEqual(resolution.readyStepRunIds, [
|
|
bundle.plan.steps.find(({ stepKey }) => stepKey === 'collect').stepRunId,
|
|
]);
|
|
assert.equal(resolution.terminalStatus, null);
|
|
});
|
|
|
|
test('returns succeeded only after every required StepRun succeeds', () => {
|
|
const bundle = fixture('workflow-frontier-terminal');
|
|
const [collect, summarize] = admittedStepRuns(bundle);
|
|
const collectRunning = transition(
|
|
collect,
|
|
'running',
|
|
bundle.run.version,
|
|
bundle.run.eventSequence,
|
|
4_000,
|
|
);
|
|
const collectSucceeded = transition(
|
|
collectRunning,
|
|
'succeeded',
|
|
bundle.run.version + 1,
|
|
bundle.run.eventSequence + 1,
|
|
5_000,
|
|
);
|
|
const summarizeReady = transition(
|
|
summarize,
|
|
'ready',
|
|
bundle.run.version + 2,
|
|
bundle.run.eventSequence + 2,
|
|
6_000,
|
|
);
|
|
const summarizeRunning = transition(
|
|
summarizeReady,
|
|
'running',
|
|
bundle.run.version + 3,
|
|
bundle.run.eventSequence + 3,
|
|
7_000,
|
|
);
|
|
const summarizeSucceeded = transition(
|
|
summarizeRunning,
|
|
'succeeded',
|
|
bundle.run.version + 4,
|
|
bundle.run.eventSequence + 4,
|
|
8_000,
|
|
);
|
|
const resolution = resolvePluginPackageWorkflowFrontier({
|
|
plan: bundle.plan,
|
|
run: {
|
|
...bundle.run,
|
|
version: bundle.run.version + 5,
|
|
eventSequence: bundle.run.eventSequence + 5,
|
|
},
|
|
stepRuns: [collectSucceeded, summarizeSucceeded],
|
|
observedAtMs: 9_000,
|
|
});
|
|
|
|
assert.deepEqual(resolution.stepMutations, []);
|
|
assert.deepEqual(resolution.readyStepRunIds, []);
|
|
assert.equal(resolution.terminalStatus, 'succeeded');
|
|
assert.equal(resolution.terminalTransition.status, 'succeeded');
|
|
assert.equal(resolution.terminalTransition.errorCode, null);
|
|
assert.equal(
|
|
resolution.terminalTransition.event.sequence,
|
|
bundle.run.eventSequence + 6,
|
|
);
|
|
});
|
|
|
|
test('fails closed on incomplete or definition-drifted durable StepRuns', () => {
|
|
const bundle = fixture('workflow-frontier-corrupt');
|
|
const stepRuns = admittedStepRuns(bundle);
|
|
assert.throws(
|
|
() =>
|
|
resolvePluginPackageWorkflowFrontier({
|
|
plan: bundle.plan,
|
|
run: bundle.run,
|
|
stepRuns: stepRuns.slice(0, 1),
|
|
observedAtMs: 4_000,
|
|
}),
|
|
InvalidPluginPackageWorkflowFrontierError,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
resolvePluginPackageWorkflowFrontier({
|
|
plan: bundle.plan,
|
|
run: bundle.run,
|
|
stepRuns: [
|
|
{
|
|
...stepRuns[0],
|
|
definitionDigest: 'f'.repeat(64),
|
|
},
|
|
stepRuns[1],
|
|
],
|
|
observedAtMs: 4_000,
|
|
}),
|
|
InvalidPluginPackageWorkflowFrontierError,
|
|
);
|
|
});
|
|
|
|
test('does not advance a Workflow after aggregate cancellation is requested', () => {
|
|
const bundle = fixture('workflow-frontier-cancelled');
|
|
assert.throws(
|
|
() =>
|
|
resolvePluginPackageWorkflowFrontier({
|
|
plan: bundle.plan,
|
|
run: {
|
|
...bundle.run,
|
|
cancelRequestedAtMs: 3_500,
|
|
cancelReason: 'user',
|
|
},
|
|
stepRuns: admittedStepRuns(bundle),
|
|
observedAtMs: 4_000,
|
|
}),
|
|
InvalidPluginPackageWorkflowFrontierError,
|
|
);
|
|
});
|
|
|
|
test('publishes frontier planning only through its explicit runtime-core subpath', () => {
|
|
const subpath = require('@qinglong/runtime-core/plugin-package-workflow-frontier');
|
|
const root = require('../dist');
|
|
assert.equal(
|
|
subpath.resolvePluginPackageWorkflowFrontier,
|
|
resolvePluginPackageWorkflowFrontier,
|
|
);
|
|
assert.equal(root.resolvePluginPackageWorkflowFrontier, undefined);
|
|
});
|