mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
414 lines
12 KiB
JavaScript
414 lines
12 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const { test } = require('node:test');
|
|
|
|
const {
|
|
StepRunFenceConflictError,
|
|
StepRunMutationConflictError,
|
|
StepRunStateConflictError,
|
|
createStepRunMutation,
|
|
transitionStepRunMutation,
|
|
} = require('@qinglong/runtime-core/step-run');
|
|
const {
|
|
LocalSqliteOperationAuthority,
|
|
} = require('../dist/authority/operationAuthority');
|
|
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
|
const {
|
|
LocalSqliteStepRunRepository,
|
|
} = require('../dist/run/stepRunRepository');
|
|
|
|
const DEFINITION_DIGEST = 'a'.repeat(64);
|
|
|
|
async function harness() {
|
|
const client = new DatabaseSync(':memory:');
|
|
client.exec('PRAGMA foreign_keys = ON');
|
|
await migrateLocalSqliteDatabase(client);
|
|
const authority = new LocalSqliteOperationAuthority(client);
|
|
return {
|
|
client,
|
|
authority,
|
|
repository: new LocalSqliteStepRunRepository(authority),
|
|
close: () => authority.close(),
|
|
};
|
|
}
|
|
|
|
function insertRun(client, id, version = 0, eventSequence = 0) {
|
|
client
|
|
.prepare(
|
|
`INSERT INTO "Runs" (
|
|
id, project_id, task_id, task_revision, trigger_type,
|
|
execution_origin, execution_owner, status, version,
|
|
event_sequence, priority, created_at_ms
|
|
) VALUES (?, 'project-001', 'task-001', 'revision-001', 'manual',
|
|
'manual', 'runtime', 'running', ?, ?, 0, 1)`,
|
|
)
|
|
.run(id, version, eventSequence);
|
|
}
|
|
|
|
function createMutation(options = {}) {
|
|
const {
|
|
id = 'step-run-001',
|
|
runId = 'run-001',
|
|
stepKey = 'workflow.fetch',
|
|
mutationId = 'step-create-001',
|
|
eventId = 'event-step-001',
|
|
dedupeKey = `step-create:${id}`,
|
|
expectedRunVersion = 0,
|
|
expectedRunEventSequence = 0,
|
|
parentStepRunId,
|
|
initialStatus = 'pending',
|
|
createdAtMs = 1_000,
|
|
} = options;
|
|
return createStepRunMutation(
|
|
{
|
|
id,
|
|
runId,
|
|
...(parentStepRunId === undefined ? {} : { parentStepRunId }),
|
|
stepKey,
|
|
kind: 'tool',
|
|
definitionRef: 'tool:demo.compare@1.0.0',
|
|
definitionDigest: DEFINITION_DIGEST,
|
|
required: true,
|
|
initialStatus,
|
|
inputRef: `artifact:${id}:input`,
|
|
mutationId,
|
|
createdAtMs,
|
|
},
|
|
{
|
|
expectedRunVersion,
|
|
expectedRunEventSequence,
|
|
eventId,
|
|
dedupeKey,
|
|
actor: { type: 'agent', id: 'agent-001' },
|
|
},
|
|
);
|
|
}
|
|
|
|
function transitionMutation(current, to, runVersion, eventSequence, options = {}) {
|
|
const mutationId =
|
|
options.mutationId ?? `step-${to}-${current.version + 1}`;
|
|
return transitionStepRunMutation(
|
|
current,
|
|
{
|
|
expectedVersion: current.version,
|
|
expectedDigest: current.stepRunDigest,
|
|
mutationId,
|
|
to,
|
|
atMs: options.atMs ?? current.updatedAtMs + 100,
|
|
...(options.approvalRequestId === undefined
|
|
? {}
|
|
: { approvalRequestId: options.approvalRequestId }),
|
|
...(options.outputRef === undefined
|
|
? {}
|
|
: { outputRef: options.outputRef }),
|
|
...(options.resultCode === undefined
|
|
? {}
|
|
: { resultCode: options.resultCode }),
|
|
...(options.errorSummary === undefined
|
|
? {}
|
|
: { errorSummary: options.errorSummary }),
|
|
},
|
|
{
|
|
expectedRunVersion: runVersion,
|
|
expectedRunEventSequence: eventSequence,
|
|
eventId: options.eventId ?? `event-${to}-${current.version + 1}`,
|
|
dedupeKey:
|
|
options.dedupeKey ?? `step-${to}:${current.id}:${current.version + 1}`,
|
|
actor: { type: 'agent', id: 'agent-001' },
|
|
},
|
|
);
|
|
}
|
|
|
|
test('atomically creates, transitions and replays historical StepRun mutations', async (t) => {
|
|
const value = await harness();
|
|
t.after(() => value.close());
|
|
insertRun(value.client, 'run-001');
|
|
|
|
const created = createMutation();
|
|
assert.deepEqual(await value.repository.apply(created), {
|
|
status: 'applied',
|
|
stepRun: created.stepRun,
|
|
runVersion: 1,
|
|
runEventSequence: 1,
|
|
});
|
|
assert.deepEqual(await value.repository.findById(created.stepRun.id), created.stepRun);
|
|
assert.deepEqual(
|
|
await value.repository.findByRunAndStepKey('run-001', 'workflow.fetch'),
|
|
created.stepRun,
|
|
);
|
|
assert.deepEqual(await value.repository.apply(created), {
|
|
status: 'existing',
|
|
stepRun: created.stepRun,
|
|
runVersion: 1,
|
|
runEventSequence: 1,
|
|
});
|
|
|
|
const ready = transitionMutation(created.stepRun, 'ready', 1, 1);
|
|
assert.deepEqual(await value.repository.apply(ready), {
|
|
status: 'applied',
|
|
stepRun: ready.stepRun,
|
|
runVersion: 2,
|
|
runEventSequence: 2,
|
|
});
|
|
assert.deepEqual(await value.repository.findById(created.stepRun.id), ready.stepRun);
|
|
|
|
assert.deepEqual(await value.repository.apply(created), {
|
|
status: 'existing',
|
|
stepRun: created.stepRun,
|
|
runVersion: 1,
|
|
runEventSequence: 1,
|
|
});
|
|
assert.deepEqual(
|
|
{
|
|
...value.client
|
|
.prepare(
|
|
`SELECT version, event_sequence AS "eventSequence"
|
|
FROM "Runs" WHERE id = 'run-001'`,
|
|
)
|
|
.get(),
|
|
},
|
|
{ version: 2, eventSequence: 2 },
|
|
);
|
|
assert.deepEqual(
|
|
{
|
|
...value.client
|
|
.prepare(
|
|
`SELECT COUNT(*) AS events,
|
|
COUNT(DISTINCT dedupe_key) AS dedupeKeys
|
|
FROM "RunEvents" WHERE run_id = 'run-001'`,
|
|
)
|
|
.get(),
|
|
},
|
|
{ events: 2, dedupeKeys: 2 },
|
|
);
|
|
assert.equal(
|
|
value.client
|
|
.prepare('SELECT COUNT(*) AS count FROM "StepRunMutations"')
|
|
.get().count,
|
|
2,
|
|
);
|
|
});
|
|
|
|
test('rolls the whole aggregate back on a stale Run fence', async (t) => {
|
|
const value = await harness();
|
|
t.after(() => value.close());
|
|
insertRun(value.client, 'run-001', 3, 4);
|
|
|
|
const stale = createMutation({
|
|
expectedRunVersion: 2,
|
|
expectedRunEventSequence: 4,
|
|
});
|
|
await assert.rejects(
|
|
value.repository.apply(stale),
|
|
StepRunFenceConflictError,
|
|
);
|
|
assert.deepEqual(
|
|
{
|
|
...value.client
|
|
.prepare(
|
|
`SELECT
|
|
(SELECT COUNT(*) FROM "StepRuns") AS stepRuns,
|
|
(SELECT COUNT(*) FROM "RunEvents") AS events,
|
|
(SELECT COUNT(*) FROM "StepRunMutations") AS mutations,
|
|
(SELECT version FROM "Runs" WHERE id = 'run-001') AS runVersion`,
|
|
)
|
|
.get(),
|
|
},
|
|
{ stepRuns: 0, events: 0, mutations: 0, runVersion: 3 },
|
|
);
|
|
});
|
|
|
|
test('rejects StepRun mutation after the Run aggregate is terminal', async (t) => {
|
|
const value = await harness();
|
|
t.after(() => value.close());
|
|
insertRun(value.client, 'run-001');
|
|
value.client
|
|
.prepare(`UPDATE "Runs" SET status = 'succeeded' WHERE id = 'run-001'`)
|
|
.run();
|
|
await assert.rejects(
|
|
value.repository.apply(createMutation()),
|
|
StepRunStateConflictError,
|
|
);
|
|
assert.equal(
|
|
value.client
|
|
.prepare('SELECT COUNT(*) AS count FROM "StepRuns"')
|
|
.get().count,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('rejects step-key collisions, missing parents and mutation identity reuse', async (t) => {
|
|
const value = await harness();
|
|
t.after(() => value.close());
|
|
insertRun(value.client, 'run-001');
|
|
const first = createMutation();
|
|
await value.repository.apply(first);
|
|
|
|
const collision = createMutation({
|
|
id: 'step-run-002',
|
|
stepKey: first.stepRun.stepKey,
|
|
mutationId: 'step-create-002',
|
|
eventId: 'event-step-002',
|
|
expectedRunVersion: 1,
|
|
expectedRunEventSequence: 1,
|
|
});
|
|
await assert.rejects(
|
|
value.repository.apply(collision),
|
|
StepRunStateConflictError,
|
|
);
|
|
|
|
const missingParent = createMutation({
|
|
id: 'step-run-003',
|
|
stepKey: 'workflow.child',
|
|
parentStepRunId: 'step-run-missing',
|
|
mutationId: 'step-create-003',
|
|
eventId: 'event-step-003',
|
|
expectedRunVersion: 1,
|
|
expectedRunEventSequence: 1,
|
|
});
|
|
await assert.rejects(
|
|
value.repository.apply(missingParent),
|
|
StepRunStateConflictError,
|
|
);
|
|
|
|
const reused = createMutation({
|
|
id: 'step-run-reused',
|
|
stepKey: 'workflow.reused',
|
|
mutationId: first.mutationId,
|
|
eventId: 'event-step-reused',
|
|
dedupeKey: 'step-create:step-run-reused',
|
|
expectedRunVersion: 1,
|
|
expectedRunEventSequence: 1,
|
|
});
|
|
await assert.rejects(
|
|
value.repository.apply(reused),
|
|
StepRunMutationConflictError,
|
|
);
|
|
assert.equal(
|
|
value.client
|
|
.prepare('SELECT COUNT(*) AS count FROM "StepRuns"')
|
|
.get().count,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test('lists StepRuns with stable keyset pagination', async (t) => {
|
|
const value = await harness();
|
|
t.after(() => value.close());
|
|
insertRun(value.client, 'run-001');
|
|
let runVersion = 0;
|
|
let eventSequence = 0;
|
|
for (const [index, stepKey] of ['workflow.c', 'workflow.a', 'workflow.b'].entries()) {
|
|
const mutation = createMutation({
|
|
id: `step-run-00${index + 1}`,
|
|
stepKey,
|
|
mutationId: `step-create-00${index + 1}`,
|
|
eventId: `event-step-00${index + 1}`,
|
|
dedupeKey: `step-create:00${index + 1}`,
|
|
expectedRunVersion: runVersion,
|
|
expectedRunEventSequence: eventSequence,
|
|
createdAtMs: 1_000 + index,
|
|
});
|
|
await value.repository.apply(mutation);
|
|
runVersion += 1;
|
|
eventSequence += 1;
|
|
}
|
|
|
|
const first = await value.repository.listByRun({
|
|
runId: 'run-001',
|
|
limit: 2,
|
|
});
|
|
assert.deepEqual(first.stepRuns.map((item) => item.stepKey), [
|
|
'workflow.a',
|
|
'workflow.b',
|
|
]);
|
|
assert.equal(first.truncated, true);
|
|
assert.deepEqual(first.next, {
|
|
stepKey: 'workflow.b',
|
|
id: 'step-run-003',
|
|
});
|
|
const second = await value.repository.listByRun({
|
|
runId: 'run-001',
|
|
limit: 2,
|
|
after: first.next,
|
|
});
|
|
assert.deepEqual(second.stepRuns.map((item) => item.stepKey), [
|
|
'workflow.c',
|
|
]);
|
|
assert.equal(second.truncated, false);
|
|
assert.equal(second.next, undefined);
|
|
});
|
|
|
|
test('reviewed guards bind Attempt and Event StepRun references to the same Run', async (t) => {
|
|
const value = await harness();
|
|
t.after(() => value.close());
|
|
insertRun(value.client, 'run-001');
|
|
insertRun(value.client, 'run-002');
|
|
const created = createMutation();
|
|
await value.repository.apply(created);
|
|
|
|
assert.throws(
|
|
() =>
|
|
value.client
|
|
.prepare(
|
|
`INSERT INTO "RunAttempts" (
|
|
id, run_id, step_run_id, attempt, status, executor_type,
|
|
callback_sequence, created_at_ms
|
|
) VALUES (
|
|
'attempt-cross-run', 'run-002', 'step-run-001', 1, 'claimed',
|
|
'local_process', 0, 1
|
|
)`,
|
|
)
|
|
.run(),
|
|
/StepRun reference mismatch/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
value.client
|
|
.prepare(
|
|
`INSERT INTO "RunEvents" (
|
|
id, run_id, sequence, type, dedupe_key, actor_type, step_run_id,
|
|
payload, created_at_ms
|
|
) VALUES (
|
|
'event-cross-run', 'run-002', 1, 'step.test',
|
|
'event-cross-run', 'system', 'step-run-001', '{}', 1
|
|
)`,
|
|
)
|
|
.run(),
|
|
/StepRun reference mismatch/,
|
|
);
|
|
});
|
|
|
|
test('binds every mutable StepRun mirror column to its digested JSON record', async (t) => {
|
|
const value = await harness();
|
|
t.after(() => value.close());
|
|
insertRun(value.client, 'run-001');
|
|
const created = createMutation();
|
|
await value.repository.apply(created);
|
|
|
|
assert.throws(
|
|
() =>
|
|
value.client
|
|
.prepare(
|
|
`UPDATE "StepRuns"
|
|
SET definition_ref = 'tool:tampered@1.0.0'
|
|
WHERE id = 'step-run-001'`,
|
|
)
|
|
.run(),
|
|
/CHECK constraint failed/,
|
|
);
|
|
assert.deepEqual(
|
|
await value.repository.findById('step-run-001'),
|
|
created.stepRun,
|
|
);
|
|
});
|
|
|
|
test('publishes the repository only through the explicit step-run subpath', () => {
|
|
const entrypoint = require('@qinglong/local-sqlite/step-run');
|
|
assert.equal(
|
|
entrypoint.LocalSqliteStepRunRepository,
|
|
LocalSqliteStepRunRepository,
|
|
);
|
|
assert.equal(require('../dist').LocalSqliteStepRunRepository, undefined);
|
|
});
|