mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
TaskDefinitionConflictError,
|
||||
TaskDefinitionUnavailableError,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const {
|
||||
UnsupportedTaskSpecError,
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
createTaskSpecSemanticRegistry,
|
||||
} = require('@qinglong/runtime-core/task-spec-semantic');
|
||||
const {
|
||||
compileLocalCommandTaskDefinition,
|
||||
} = require('@qinglong/runtime-core/task-definition-execution-compiler');
|
||||
const {
|
||||
createLocalTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/local-dispatch');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-task-def-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'qinglong3.sqlite');
|
||||
}
|
||||
|
||||
function command(index, overrides = {}) {
|
||||
const suffix = String(index).padStart(12, '0');
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: `task-${String(index).padStart(5, '0')}`,
|
||||
expectedRevision: null,
|
||||
mutationId: `019f7200-0000-7000-8000-${suffix}`,
|
||||
name: `Task ${index}`,
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: [String(index)] },
|
||||
},
|
||||
},
|
||||
labels: { source: 'contract-test' },
|
||||
enabled: true,
|
||||
occurredAtMs: 100 + index,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('creates, versions and resolves immutable TaskDefinitions', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => runtime.close());
|
||||
|
||||
const created = await runtime.taskDefinitions.appendTaskDefinitionRevision(
|
||||
command(1),
|
||||
);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(created.definition.revision, 1);
|
||||
const createdTaskRevision = `qltd:v1:1:${created.definition.contentDigest}`;
|
||||
const executionRevision =
|
||||
await runtime.localDispatch.resolveLocalTaskExecutionRevision({
|
||||
projectId: created.definition.projectId,
|
||||
taskId: created.definition.taskId,
|
||||
taskRevision: createdTaskRevision,
|
||||
});
|
||||
assert.ok(executionRevision);
|
||||
assert.match(executionRevision.contentDigest, /^[a-f0-9]{64}$/);
|
||||
assert.equal(
|
||||
(
|
||||
await runtime.localDispatch.resolveLocalExecutionContextRecipe(
|
||||
executionRevision.contextRef,
|
||||
)
|
||||
).contextRef,
|
||||
executionRevision.contextRef,
|
||||
);
|
||||
assert.equal(
|
||||
(await runtime.taskDefinitions.appendTaskDefinitionRevision(command(1)))
|
||||
.status,
|
||||
'existing',
|
||||
);
|
||||
|
||||
const updatedCommand = command(1, {
|
||||
expectedRevision: 1,
|
||||
mutationId: '019f7200-0000-7000-8000-000000010001',
|
||||
name: 'Task 1 updated',
|
||||
enabled: false,
|
||||
occurredAtMs: 200,
|
||||
});
|
||||
const updated = await runtime.taskDefinitions.appendTaskDefinitionRevision(
|
||||
updatedCommand,
|
||||
);
|
||||
assert.equal(updated.status, 'updated');
|
||||
assert.equal(updated.definition.revision, 2);
|
||||
assert.equal(updated.definition.createdAtMs, 101);
|
||||
assert.equal(updated.definition.updatedAtMs, 200);
|
||||
assert.equal(
|
||||
await runtime.localDispatch.resolveLocalTaskExecutionRevision({
|
||||
projectId: updated.definition.projectId,
|
||||
taskId: updated.definition.taskId,
|
||||
taskRevision: `qltd:v1:2:${updated.definition.contentDigest}`,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await runtime.taskDefinitions.findCurrentTaskDefinition(
|
||||
'default',
|
||||
'task-00001',
|
||||
)
|
||||
).name,
|
||||
'Task 1 updated',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await runtime.taskDefinitions.findTaskDefinitionRevision(
|
||||
'default',
|
||||
'task-00001',
|
||||
1,
|
||||
)
|
||||
).name,
|
||||
'Task 1',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unknown semantics before mutation and accepts explicit composition', async (t) => {
|
||||
const builtInPath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath: builtInPath, profile: 'edge' });
|
||||
const builtIn = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath: builtInPath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => builtIn.close());
|
||||
const customCommand = command(1, {
|
||||
kind: 'tool',
|
||||
spec: { schema: 'example/tool@v1', config: { entrypoint: 'probe' } },
|
||||
});
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
builtIn.taskDefinitions.appendTaskDefinitionRevision(customCommand),
|
||||
UnsupportedTaskSpecError,
|
||||
);
|
||||
assert.equal(
|
||||
await builtIn.taskDefinitions.findCurrentTaskDefinition(
|
||||
customCommand.projectId,
|
||||
customCommand.taskId,
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
const customPath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath: customPath, profile: 'edge' });
|
||||
const registry = createTaskSpecSemanticRegistry([
|
||||
{
|
||||
schema: 'example/tool@v1',
|
||||
kind: 'tool',
|
||||
normalizeConfig(config) {
|
||||
return Object.freeze({
|
||||
entrypoint: String(config.entrypoint).toUpperCase(),
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
const custom = await openLocalSqliteRuntimeDatabase(
|
||||
{ databasePath: customPath, profile: 'edge' },
|
||||
{ taskSpecSemanticRegistry: registry },
|
||||
);
|
||||
t.after(() => custom.close());
|
||||
const created = await custom.taskDefinitions.appendTaskDefinitionRevision(
|
||||
customCommand,
|
||||
);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(created.definition.spec.config.entrypoint, 'PROBE');
|
||||
|
||||
await custom.close();
|
||||
const historicalReader = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath: customPath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => historicalReader.close());
|
||||
assert.equal(
|
||||
(
|
||||
await historicalReader.taskDefinitions.findCurrentTaskDefinition(
|
||||
customCommand.projectId,
|
||||
customCommand.taskId,
|
||||
)
|
||||
).spec.config.entrypoint,
|
||||
'PROBE',
|
||||
);
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
historicalReader.taskDefinitions.appendTaskDefinitionRevision(
|
||||
customCommand,
|
||||
),
|
||||
UnsupportedTaskSpecError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes TaskDefinition and local execution facts in one transaction', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => runtime.close());
|
||||
|
||||
const input = command(20);
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const canonicalInput = {
|
||||
...input,
|
||||
spec: registry.normalize({
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
kind: input.kind,
|
||||
spec: input.spec,
|
||||
}),
|
||||
};
|
||||
const definition = createTaskDefinitionRecord(
|
||||
canonicalInput,
|
||||
input.occurredAtMs,
|
||||
);
|
||||
const plan = compileLocalCommandTaskDefinition(definition, registry);
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalExecutionContextRecipe(
|
||||
plan.contextRecipe,
|
||||
),
|
||||
'inserted',
|
||||
);
|
||||
const conflictingRevision = createLocalTaskExecutionRevision({
|
||||
...plan.executionRevision,
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['conflict'] },
|
||||
});
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalTaskExecutionRevision(
|
||||
conflictingRevision,
|
||||
),
|
||||
'inserted',
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
runtime.taskDefinitions.appendTaskDefinitionRevision(input),
|
||||
TaskDefinitionConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.taskDefinitions.findCurrentTaskDefinition(
|
||||
input.projectId,
|
||||
input.taskId,
|
||||
),
|
||||
null,
|
||||
);
|
||||
const client = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3TaskDefinitionRevisions"
|
||||
WHERE "project_id" = ? AND "task_id" = ?`,
|
||||
)
|
||||
.get(input.projectId, input.taskId).count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('exact TaskDefinition replay fails closed when a published execution fact is missing', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => runtime.close());
|
||||
const input = command(21);
|
||||
const created = await runtime.taskDefinitions.appendTaskDefinitionRevision(
|
||||
input,
|
||||
);
|
||||
const taskRevision = `qltd:v1:1:${created.definition.contentDigest}`;
|
||||
const client = new DatabaseSync(databasePath);
|
||||
try {
|
||||
client
|
||||
.prepare(
|
||||
`DELETE FROM "QingLong3LocalTaskExecutionRevisions"
|
||||
WHERE "project_id" = ? AND "task_id" = ? AND "task_revision" = ?`,
|
||||
)
|
||||
.run(input.projectId, input.taskId, taskRevision);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
runtime.taskDefinitions.appendTaskDefinitionRevision(input),
|
||||
TaskDefinitionUnavailableError,
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.localDispatch.resolveLocalTaskExecutionRevision({
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
taskRevision,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('fences stale revisions, mutation drift and archived Projects', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => runtime.close());
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(command(1));
|
||||
|
||||
await assert.rejects(
|
||||
runtime.taskDefinitions.appendTaskDefinitionRevision(
|
||||
command(1, { name: 'drifted replay' }),
|
||||
),
|
||||
TaskDefinitionConflictError,
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.taskDefinitions.appendTaskDefinitionRevision(
|
||||
command(1, {
|
||||
expectedRevision: 2,
|
||||
mutationId: '019f7200-0000-7000-8000-000000010002',
|
||||
}),
|
||||
),
|
||||
TaskDefinitionConflictError,
|
||||
);
|
||||
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec(
|
||||
`UPDATE "QingLong3Projects" SET "status" = 'archived', "version" = 2,
|
||||
"updated_at_ms" = 300 WHERE "id" = 'default'`,
|
||||
);
|
||||
client.close();
|
||||
await assert.rejects(
|
||||
runtime.taskDefinitions.appendTaskDefinitionRevision(command(2)),
|
||||
TaskDefinitionConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('paginates current definitions and allows only one concurrent revision', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'standalone' });
|
||||
const first = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'standalone',
|
||||
});
|
||||
const second = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'standalone',
|
||||
});
|
||||
t.after(() => Promise.all([first.close(), second.close()]));
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
await first.taskDefinitions.appendTaskDefinitionRevision(command(index));
|
||||
}
|
||||
const page = await first.taskDefinitions.listTaskDefinitions({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
page.definitions.map(({ taskId }) => taskId),
|
||||
['task-00001', 'task-00002'],
|
||||
);
|
||||
assert.equal(page.truncated, true);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await first.taskDefinitions.listTaskDefinitions({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
after: page.next,
|
||||
})
|
||||
).definitions.map(({ taskId }) => taskId),
|
||||
['task-00003'],
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
first.taskDefinitions.appendTaskDefinitionRevision(
|
||||
command(1, {
|
||||
expectedRevision: 1,
|
||||
mutationId: '019f7200-0000-7000-8000-000000020001',
|
||||
name: 'winner-a',
|
||||
occurredAtMs: 500,
|
||||
}),
|
||||
),
|
||||
second.taskDefinitions.appendTaskDefinitionRevision(
|
||||
command(1, {
|
||||
expectedRevision: 1,
|
||||
mutationId: '019f7200-0000-7000-8000-000000020002',
|
||||
name: 'winner-b',
|
||||
occurredAtMs: 500,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
assert.equal(
|
||||
results.filter(({ status }) => status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(results.filter(({ status }) => status === 'rejected').length, 1);
|
||||
assert.ok(
|
||||
results.find(({ status }) => status === 'rejected').reason instanceof
|
||||
TaskDefinitionConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when a durable revision digest is corrupt', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => runtime.close());
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(command(1));
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec(
|
||||
`UPDATE "QingLong3TaskDefinitionRevisions"
|
||||
SET "content_digest" = '${'0'.repeat(64)}'`,
|
||||
);
|
||||
client.close();
|
||||
await assert.rejects(
|
||||
runtime.taskDefinitions.findCurrentTaskDefinition('default', 'task-00001'),
|
||||
TaskDefinitionUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when a durable execution revision digest is corrupt', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => runtime.close());
|
||||
const input = command(22);
|
||||
const created = await runtime.taskDefinitions.appendTaskDefinitionRevision(
|
||||
input,
|
||||
);
|
||||
const taskRevision = `qltd:v1:1:${created.definition.contentDigest}`;
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3LocalTaskExecutionRevisions"
|
||||
SET "content_digest" = ?
|
||||
WHERE "project_id" = ? AND "task_id" = ? AND "task_revision" = ?`,
|
||||
)
|
||||
.run('0'.repeat(64), input.projectId, input.taskId, taskRevision);
|
||||
client.close();
|
||||
await assert.rejects(
|
||||
runtime.localDispatch.resolveLocalTaskExecutionRevision({
|
||||
projectId: input.projectId,
|
||||
taskId: input.taskId,
|
||||
taskRevision,
|
||||
}),
|
||||
/digest does not match/,
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.taskDefinitions.appendTaskDefinitionRevision(input),
|
||||
TaskDefinitionUnavailableError,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user