Files
qinglong/packages/ql3-local-sqlite/test/stepRunRepository.test.cjs
T

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);
});