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,266 @@
|
||||
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 { migrateLocalSqlitePath } = require('../dist/migration/migration');
|
||||
const {
|
||||
LocalLegacyAdoptionAuthorizationFenceConflictError,
|
||||
LocalLegacyAdoptionConflictError,
|
||||
openLocalSqliteAdoptionDatabase,
|
||||
} = require('@qinglong/local-sqlite/adoption');
|
||||
|
||||
const MUTATION_ID = '12345678-1234-4123-8123-123456789abc';
|
||||
const DECISION_ID = '019f7200-0000-7000-8000-000000000001';
|
||||
const SUBJECT = Object.freeze({ type: 'user', id: 'local-owner' });
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-adoption-publisher-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'qinglong3.sqlite');
|
||||
}
|
||||
|
||||
async function preparedDatabase(t) {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES ('default', 'user', 'local-owner', 1, 'active', 'owner',
|
||||
'test-owner-binding', 'user', 'local-owner', 1)`,
|
||||
)
|
||||
.run();
|
||||
client.close();
|
||||
return databasePath;
|
||||
}
|
||||
|
||||
function candidate(index, taskId = `legacy-cron:${index}`) {
|
||||
return Object.freeze({
|
||||
rowOrdinal: index,
|
||||
sourceDigest: String(index).padStart(64, '0'),
|
||||
task: Object.freeze({
|
||||
taskId,
|
||||
name: `Legacy Task ${index}`,
|
||||
kind: 'command',
|
||||
spec: Object.freeze({
|
||||
schema: 'qinglong/command@v1',
|
||||
config: Object.freeze({
|
||||
command: Object.freeze({
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: Object.freeze([String(index)]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
labels: Object.freeze({ source: 'legacy-adoption' }),
|
||||
enabled: true,
|
||||
}),
|
||||
triggers: Object.freeze([
|
||||
Object.freeze({
|
||||
triggerId: `${taskId}:cron:1`,
|
||||
spec: Object.freeze({
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: Object.freeze({
|
||||
expression: `${index} 0 * * *`,
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
}),
|
||||
}),
|
||||
enabled: true,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
function command(candidates, overrides = {}) {
|
||||
return {
|
||||
mutationId: MUTATION_ID,
|
||||
decisionId: DECISION_ID,
|
||||
projectId: 'default',
|
||||
profile: 'edge',
|
||||
planDigest: DIGEST,
|
||||
inventoryDigest: 'b'.repeat(64),
|
||||
decisionDigest: 'c'.repeat(64),
|
||||
receiptDigest: 'd'.repeat(64),
|
||||
authorizationFileDigest: 'e'.repeat(64),
|
||||
rowCount: candidates.length,
|
||||
skippedCount: 0,
|
||||
subject: SUBJECT,
|
||||
fence: Object.freeze({ projectVersion: 1, bindingVersion: 1 }),
|
||||
audit: Object.freeze({
|
||||
eventId: MUTATION_ID,
|
||||
requestId: 'legacy-adoption-test',
|
||||
operationId: 'task.adopt',
|
||||
projectId: 'default',
|
||||
subject: SUBJECT,
|
||||
authenticationId: 'local-console:adoption-review',
|
||||
outcome: 'allowed',
|
||||
reasons: Object.freeze(['project_role_allowed']),
|
||||
fence: Object.freeze({ projectVersion: 1, bindingVersion: 1 }),
|
||||
occurredAtMs: 100,
|
||||
}),
|
||||
candidates,
|
||||
confirmExternalAuthority() {},
|
||||
createdAtMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('publishes tasks, execution facts, triggers, audit and ledger atomically', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const adoption = await openLocalSqliteAdoptionDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
const input = command([candidate(1), candidate(2)]);
|
||||
const inserted = await adoption.publisher.publish(input);
|
||||
assert.equal(inserted.status, 'inserted');
|
||||
assert.equal(inserted.adoption.adoptedTaskCount, 2);
|
||||
assert.equal(inserted.adoption.adoptedTriggerCount, 2);
|
||||
assert.match(inserted.adoption.publicationDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal((await adoption.publisher.publish(input)).status, 'existing');
|
||||
await adoption.close();
|
||||
|
||||
const client = new DatabaseSync(databasePath, { readOnly: true });
|
||||
assert.equal(
|
||||
client
|
||||
.prepare('SELECT COUNT(*) AS count FROM "QingLong3TaskDefinitions"')
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
client.prepare('SELECT COUNT(*) AS count FROM "QingLong3Triggers"').get()
|
||||
.count,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalTriggerSchedules" WHERE "next_fire_at_ms" IS NULL',
|
||||
)
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalTaskExecutionRevisions"',
|
||||
)
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT "operation_id" AS operationId, "outcome" AS outcome
|
||||
FROM "QingLong3SecurityAuditEvents" WHERE "event_id" = ?`,
|
||||
)
|
||||
.get(MUTATION_ID),
|
||||
},
|
||||
{ operationId: 'task.adopt', outcome: 'allowed' },
|
||||
);
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('rolls the complete publication back on a later candidate conflict', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const adoption = await openLocalSqliteAdoptionDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
await assert.rejects(
|
||||
adoption.publisher.publish(
|
||||
command([candidate(1, 'duplicate-task'), candidate(2, 'duplicate-task')]),
|
||||
),
|
||||
LocalLegacyAdoptionConflictError,
|
||||
);
|
||||
await adoption.close();
|
||||
|
||||
const client = new DatabaseSync(databasePath, { readOnly: true });
|
||||
for (const table of [
|
||||
'QingLong3TaskDefinitions',
|
||||
'QingLong3Triggers',
|
||||
'QingLong3LocalTriggerSchedules',
|
||||
'QingLong3LegacyAdoptions',
|
||||
'QingLong3SecurityAuditEvents',
|
||||
]) {
|
||||
assert.equal(
|
||||
client.prepare(`SELECT COUNT(*) AS count FROM "${table}"`).get().count,
|
||||
0,
|
||||
table,
|
||||
);
|
||||
}
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('awaits the final external authority check and rolls back on rejection', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const adoption = await openLocalSqliteAdoptionDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
let checked = false;
|
||||
const authorityFailure = new Error('external authority changed');
|
||||
await assert.rejects(
|
||||
adoption.publisher.publish(
|
||||
command([candidate(1)], {
|
||||
async confirmExternalAuthority() {
|
||||
await Promise.resolve();
|
||||
checked = true;
|
||||
throw authorityFailure;
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
name: 'LocalLegacyAdoptionUnavailableError',
|
||||
code: 'LOCAL_LEGACY_ADOPTION_UNAVAILABLE',
|
||||
cause: authorityFailure,
|
||||
},
|
||||
);
|
||||
assert.equal(checked, true);
|
||||
await adoption.close();
|
||||
|
||||
const client = new DatabaseSync(databasePath, { readOnly: true });
|
||||
for (const table of [
|
||||
'QingLong3TaskDefinitions',
|
||||
'QingLong3Triggers',
|
||||
'QingLong3LocalTriggerSchedules',
|
||||
'QingLong3LegacyAdoptions',
|
||||
'QingLong3SecurityAuditEvents',
|
||||
]) {
|
||||
assert.equal(
|
||||
client.prepare(`SELECT COUNT(*) AS count FROM "${table}"`).get().count,
|
||||
0,
|
||||
table,
|
||||
);
|
||||
}
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('rejects stale authorization fences before any adoption mutation', async (t) => {
|
||||
const databasePath = await preparedDatabase(t);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec(
|
||||
`UPDATE "QingLong3Projects" SET "version" = 2, "updated_at_ms" = 2
|
||||
WHERE "id" = 'default'`,
|
||||
);
|
||||
client.close();
|
||||
const adoption = await openLocalSqliteAdoptionDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
await assert.rejects(
|
||||
adoption.publisher.publish(command([candidate(1)])),
|
||||
LocalLegacyAdoptionAuthorizationFenceConflictError,
|
||||
);
|
||||
await adoption.close();
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ApprovalMutationConflictError,
|
||||
ApprovalPolicyFenceConflictError,
|
||||
ApprovalUnavailableError,
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
LocalSqliteApprovalRequestRepository,
|
||||
} = require('@qinglong/local-sqlite/approved-action');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
|
||||
const DIGEST_A = 'a'.repeat(64);
|
||||
const DIGEST_B = 'b'.repeat(64);
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'approved-dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function action(overrides = {}) {
|
||||
return {
|
||||
permission: 'package.manage',
|
||||
actionType: 'plugin_package.install',
|
||||
actionRef: 'proposal:pkg-demo-v1',
|
||||
actionDigest: DIGEST_A,
|
||||
previewDigest: DIGEST_B,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function request(id = 'approval-1') {
|
||||
return createApprovalRequest({
|
||||
id,
|
||||
projectId: 'default',
|
||||
action: action(),
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(eventId, operationId, subject, authenticationId, outcome, atMs) {
|
||||
return {
|
||||
eventId,
|
||||
requestId: 'request-http-1',
|
||||
operationId,
|
||||
projectId: 'default',
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons: [outcome === 'approval_required' ? 'package_review' : 'role_grant'],
|
||||
fence: FENCE,
|
||||
occurredAtMs: atMs,
|
||||
};
|
||||
}
|
||||
|
||||
function createCommand(overrides = {}) {
|
||||
return {
|
||||
request: request(),
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000001',
|
||||
'approval.request',
|
||||
REQUESTER,
|
||||
'auth-requester-1',
|
||||
'approval_required',
|
||||
1_000,
|
||||
),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decideCommand(overrides = {}) {
|
||||
return {
|
||||
requestId: 'approval-1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-step-up-1',
|
||||
authenticatedAtMs: 1_500,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs: 2_000,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000002',
|
||||
'approval.decide',
|
||||
REQUESTER,
|
||||
'auth-step-up-1',
|
||||
'allowed',
|
||||
2_000,
|
||||
),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeCommand(overrides = {}) {
|
||||
return {
|
||||
requestId: 'approval-1',
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-1',
|
||||
dispatchId: 'dispatch-1',
|
||||
action: action(),
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs: 3_000,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000003',
|
||||
'approval.consume',
|
||||
SYSTEM,
|
||||
'auth-dispatcher-1',
|
||||
'allowed',
|
||||
3_000,
|
||||
),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
t.after(() => client.close());
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',1,'active','owner',
|
||||
'grant-owner-1','user','usr_owner',0)`,
|
||||
)
|
||||
.run();
|
||||
return {
|
||||
client,
|
||||
repository: new LocalSqliteApprovalRequestRepository(client),
|
||||
};
|
||||
}
|
||||
|
||||
test('persists request, strong decision, dispatch and audit with exact replay', async (t) => {
|
||||
const { client, repository } = await fixture(t);
|
||||
assert.equal((await repository.create(createCommand())).status, 'created');
|
||||
assert.equal((await repository.create(createCommand())).status, 'existing');
|
||||
|
||||
const decided = await repository.decide(decideCommand());
|
||||
assert.equal(decided.status, 'decided');
|
||||
assert.equal(decided.request.state, 'approved');
|
||||
assert.equal((await repository.decide(decideCommand())).status, 'existing');
|
||||
|
||||
const consumed = await repository.consume(consumeCommand());
|
||||
assert.equal(consumed.status, 'consumed');
|
||||
assert.equal(consumed.request.state, 'consumed');
|
||||
assert.equal(consumed.dispatch.approvedBy.id, 'usr_owner');
|
||||
assert.equal(
|
||||
(await repository.consume(consumeCommand())).status,
|
||||
'existing',
|
||||
);
|
||||
assert.deepEqual(
|
||||
await repository.findDispatchById('dispatch-1'),
|
||||
consumed.dispatch,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" LIKE 'approval.%'`,
|
||||
)
|
||||
.get().count,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects replay drift and rolls request plus audit back together', async (t) => {
|
||||
const { client, repository } = await fixture(t);
|
||||
await repository.create(createCommand());
|
||||
await assert.rejects(
|
||||
repository.create(
|
||||
createCommand({
|
||||
audit: {
|
||||
...createCommand().audit,
|
||||
reasons: ['changed'],
|
||||
},
|
||||
}),
|
||||
),
|
||||
ApprovalMutationConflictError,
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.decide(
|
||||
decideCommand({
|
||||
audit: {
|
||||
...decideCommand().audit,
|
||||
operationId: 'approval.consume',
|
||||
},
|
||||
}),
|
||||
),
|
||||
ApprovalMutationConflictError,
|
||||
);
|
||||
assert.equal((await repository.findById('approval-1')).state, 'pending');
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'approval.decide'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('fences a role change before decision without partial audit', async (t) => {
|
||||
const { client, repository } = await fixture(t);
|
||||
await repository.create(createCommand());
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',2,'active','owner',
|
||||
'grant-owner-2','user','usr_owner',1500)`,
|
||||
)
|
||||
.run();
|
||||
await assert.rejects(
|
||||
repository.decide(decideCommand()),
|
||||
ApprovalPolicyFenceConflictError,
|
||||
);
|
||||
assert.equal((await repository.findById('approval-1')).state, 'pending');
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = '10000000-0000-4000-8000-000000000002'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when stored canonical request or dispatch JSON drifts', async (t) => {
|
||||
const { client, repository } = await fixture(t);
|
||||
await repository.create(createCommand());
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3ApprovalRequests"
|
||||
SET "request_json" = json_set("request_json", '$.risk', 'low')
|
||||
WHERE "request_id" = 'approval-1'`,
|
||||
)
|
||||
.run();
|
||||
await assert.rejects(
|
||||
repository.findById('approval-1'),
|
||||
ApprovalUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('runs an optional authentication guard inside every mutation transaction', async (t) => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
t.after(() => client.close());
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',1,'active','owner',
|
||||
'10000000-0000-4000-8000-000000000099','user','usr_owner',1)`
|
||||
)
|
||||
.run();
|
||||
let admitted = false;
|
||||
let guardCalls = 0;
|
||||
const repository = new LocalSqliteApprovalRequestRepository(client, () => {
|
||||
guardCalls += 1;
|
||||
if (!admitted) throw new Error('credential fence rejected');
|
||||
});
|
||||
await assert.rejects(
|
||||
repository.create(createCommand()),
|
||||
ApprovalUnavailableError,
|
||||
);
|
||||
assert.equal(guardCalls, 1);
|
||||
assert.equal(await repository.findById('approval-1'), null);
|
||||
admitted = true;
|
||||
await repository.create(createCommand());
|
||||
await repository.decide(decideCommand());
|
||||
await repository.consume(consumeCommand());
|
||||
assert.equal(guardCalls, 4);
|
||||
});
|
||||
|
||||
test('exports the authority only through the approved-action subpath', () => {
|
||||
const root = require('@qinglong/local-sqlite');
|
||||
const subpath = require('@qinglong/local-sqlite/approved-action');
|
||||
assert.equal(root.LocalSqliteApprovalRequestRepository, undefined);
|
||||
assert.equal(
|
||||
subpath.LocalSqliteApprovalRequestRepository,
|
||||
LocalSqliteApprovalRequestRepository,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ApprovalUnavailableError,
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createToolInvocationPreviewArtifact,
|
||||
} = require('@qinglong/runtime-core/tool-invocation-artifact');
|
||||
const {
|
||||
LocalSqliteApprovalRequestRepository,
|
||||
} = require('@qinglong/local-sqlite/approved-action');
|
||||
const {
|
||||
LocalSqliteApprovalRequestSource,
|
||||
} = require('@qinglong/local-sqlite/approval-discovery');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('@qinglong/local-sqlite/operation-authority');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
|
||||
function request(id, requestedAtMs) {
|
||||
return createApprovalRequest({
|
||||
id,
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: `tool:${id}`,
|
||||
actionDigest: 'a'.repeat(64),
|
||||
previewDigest: 'b'.repeat(64),
|
||||
},
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs,
|
||||
expiresAtMs: requestedAtMs + 60_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
}
|
||||
|
||||
function audit(id, atMs) {
|
||||
return {
|
||||
eventId: id,
|
||||
requestId: `command-${id}`,
|
||||
operationId: 'approval.request',
|
||||
projectId: 'default',
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-owner',
|
||||
outcome: 'approval_required',
|
||||
reasons: ['agent_action_requires_approval'],
|
||||
fence: FENCE,
|
||||
occurredAtMs: atMs,
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client.exec(`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',1,'active','owner','grant-owner',
|
||||
'user','usr_owner',0)`);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
t.after(() => authority.close());
|
||||
const writer = new LocalSqliteApprovalRequestRepository(authority);
|
||||
for (const [index, atMs] of [1_000, 2_000, 3_000].entries()) {
|
||||
const id = `approval-${index + 1}`;
|
||||
await writer.create({
|
||||
request: request(id, atMs),
|
||||
audit: audit(`10000000-0000-4000-8000-00000000000${index + 1}`, atMs),
|
||||
});
|
||||
}
|
||||
return {
|
||||
authority,
|
||||
client,
|
||||
source: new LocalSqliteApprovalRequestSource(authority),
|
||||
};
|
||||
}
|
||||
|
||||
test('lists one Project newest-first with a stable keyset cursor', async (t) => {
|
||||
const { source } = await fixture(t);
|
||||
const first = await source.listApprovalRequests({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
first.requests.map(({ id }) => id),
|
||||
['approval-3', 'approval-2'],
|
||||
);
|
||||
assert.equal(first.truncated, true);
|
||||
assert.deepEqual(first.next, {
|
||||
updatedAtMs: 2_000,
|
||||
requestId: 'approval-2',
|
||||
});
|
||||
const second = await source.listApprovalRequests({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
after: first.next,
|
||||
});
|
||||
assert.deepEqual(second.requests.map(({ id }) => id), ['approval-1']);
|
||||
assert.equal(second.truncated, false);
|
||||
assert.equal(second.next, undefined);
|
||||
});
|
||||
|
||||
test('rejects widened input and fails closed on row mirror drift', async (t) => {
|
||||
const { client, source } = await fixture(t);
|
||||
assert.throws(
|
||||
() => source.listApprovalRequests({ projectId: 'default', limit: 65 }),
|
||||
TypeError,
|
||||
);
|
||||
client.exec(`UPDATE "QingLong3ApprovalRequests"
|
||||
SET "updated_at_ms" = "updated_at_ms" + 1
|
||||
WHERE "request_id" = 'approval-3'`);
|
||||
await assert.rejects(
|
||||
source.listApprovalRequests({ projectId: 'default', limit: 2 }),
|
||||
ApprovalUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('reads one Project-scoped Approval with an exactly bound redacted preview', async (t) => {
|
||||
const { authority, client, source } = await fixture(t);
|
||||
const previewArtifact = createToolInvocationPreviewArtifact({
|
||||
artifactId: 'preview-approval',
|
||||
projectId: 'default',
|
||||
actionRef: 'tool:approval-preview',
|
||||
actionDigest: 'c'.repeat(64),
|
||||
redactionContractDigest: 'd'.repeat(64),
|
||||
sealedAtMs: 4_000,
|
||||
preview: {
|
||||
title: 'Run task',
|
||||
summary: 'Runs one selected task.',
|
||||
fields: [{ kind: 'redacted', label: 'Token', value: null }],
|
||||
warnings: ['external_effect'],
|
||||
},
|
||||
});
|
||||
const approval = createApprovalRequest({
|
||||
id: 'approval-preview',
|
||||
projectId: 'default',
|
||||
action: {
|
||||
permission: 'run.start',
|
||||
actionType: 'tool.invoke',
|
||||
actionRef: previewArtifact.actionRef,
|
||||
actionDigest: previewArtifact.actionDigest,
|
||||
previewDigest: previewArtifact.previewDigest,
|
||||
},
|
||||
risk: 'medium',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs: 4_000,
|
||||
expiresAtMs: 64_000,
|
||||
requestFence: FENCE,
|
||||
});
|
||||
await new LocalSqliteApprovalRequestRepository(authority).create({
|
||||
request: approval,
|
||||
audit: audit('10000000-0000-4000-8000-000000000009', 4_000),
|
||||
});
|
||||
client.prepare(`INSERT INTO "ToolInvocationPreviewArtifacts" (
|
||||
artifact_id, project_id, action_ref, action_digest, preview_digest,
|
||||
redaction_contract_digest, artifact_digest, byte_length, sealed_at_ms,
|
||||
artifact_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
previewArtifact.artifactId,
|
||||
previewArtifact.projectId,
|
||||
previewArtifact.actionRef,
|
||||
previewArtifact.actionDigest,
|
||||
previewArtifact.previewDigest,
|
||||
previewArtifact.redactionContractDigest,
|
||||
previewArtifact.artifactDigest,
|
||||
previewArtifact.byteLength,
|
||||
previewArtifact.sealedAtMs,
|
||||
JSON.stringify(previewArtifact),
|
||||
);
|
||||
const detail = await source.getApprovalRequestDetail({
|
||||
projectId: 'default',
|
||||
requestId: 'approval-preview',
|
||||
});
|
||||
assert.equal(detail.request.id, 'approval-preview');
|
||||
assert.equal(detail.preview.title, 'Run task');
|
||||
assert.equal(
|
||||
await source.getApprovalRequestDetail({
|
||||
projectId: 'other',
|
||||
requestId: 'approval-preview',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
client.exec(`UPDATE "ToolInvocationPreviewArtifacts"
|
||||
SET byte_length = byte_length + 1
|
||||
WHERE artifact_id = 'preview-approval'`);
|
||||
await assert.rejects(
|
||||
source.getApprovalRequestDetail({
|
||||
projectId: 'default',
|
||||
requestId: 'approval-preview',
|
||||
}),
|
||||
ApprovalUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('exports discovery separately from Approval mutation authority', () => {
|
||||
const root = require('@qinglong/local-sqlite');
|
||||
const mutation = require('@qinglong/local-sqlite/approved-action');
|
||||
const discovery = require('@qinglong/local-sqlite/approval-discovery');
|
||||
assert.equal(root.LocalSqliteApprovalRequestSource, undefined);
|
||||
assert.equal(mutation.LocalSqliteApprovalRequestSource, undefined);
|
||||
assert.equal(
|
||||
discovery.LocalSqliteApprovalRequestSource,
|
||||
LocalSqliteApprovalRequestSource,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
|
||||
const RUN_1 = '019f70d0-0000-7000-8000-000000000001';
|
||||
const RUN_2 = '019f70d0-0000-7000-8000-000000000002';
|
||||
const ATTEMPT_1 = '019f70d0-0000-7000-8000-000000000011';
|
||||
const ATTEMPT_2 = '019f70d0-0000-7000-8000-000000000012';
|
||||
|
||||
async function database(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-receipt-journal-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const opened = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => opened.close());
|
||||
return opened;
|
||||
}
|
||||
|
||||
function run(id) {
|
||||
return {
|
||||
id,
|
||||
projectId: 'default',
|
||||
taskId: `task-${id}`,
|
||||
taskRevision: `revision-${id}`,
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'running',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function attempt(id, runId, status = 'running', finishedAtMs) {
|
||||
return {
|
||||
id,
|
||||
runId,
|
||||
attempt: 1,
|
||||
status,
|
||||
executorType: 'local_process',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 1,
|
||||
...(finishedAtMs === undefined ? {} : { finishedAtMs }),
|
||||
};
|
||||
}
|
||||
|
||||
test('registers one exact pre-spawn receipt barrier idempotently', async (t) => {
|
||||
const opened = await database(t);
|
||||
await opened.runRepository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run(RUN_1));
|
||||
await transaction.insertAttempt(attempt(ATTEMPT_1, RUN_1));
|
||||
});
|
||||
|
||||
const command = { runId: RUN_1, attemptId: ATTEMPT_1, registeredAtMs: 10 };
|
||||
await opened.completionReceipts.register(command);
|
||||
await opened.completionReceipts.register(command);
|
||||
await assert.rejects(
|
||||
opened.completionReceipts.register({ ...command, registeredAtMs: 11 }),
|
||||
/registration conflicts/,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
await opened.completionReceipts.listCandidates({ observedAtMs: 20 }),
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
...command,
|
||||
state: 'pending',
|
||||
updatedAtMs: 10,
|
||||
attemptStatus: 'running',
|
||||
executorType: 'local_process',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
nextCursor: { updatedAtMs: 10, attemptId: ATTEMPT_1 },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('pages deterministically and exposes quarantines only when purge is due', async (t) => {
|
||||
const opened = await database(t);
|
||||
await opened.runRepository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run(RUN_1));
|
||||
await transaction.insertAttempt(attempt(ATTEMPT_1, RUN_1, 'failed', 5));
|
||||
await transaction.insertRun(run(RUN_2));
|
||||
await transaction.insertAttempt(attempt(ATTEMPT_2, RUN_2));
|
||||
});
|
||||
await opened.completionReceipts.register({
|
||||
runId: RUN_1,
|
||||
attemptId: ATTEMPT_1,
|
||||
registeredAtMs: 10,
|
||||
});
|
||||
await opened.completionReceipts.register({
|
||||
runId: RUN_2,
|
||||
attemptId: ATTEMPT_2,
|
||||
registeredAtMs: 11,
|
||||
});
|
||||
await opened.completionReceipts.markQuarantined({
|
||||
attemptId: ATTEMPT_1,
|
||||
quarantineRef: `.quarantine/${ATTEMPT_1.slice(0, 2)}/${ATTEMPT_1}.json`,
|
||||
updatedAtMs: 20,
|
||||
purgeAfterMs: 30,
|
||||
});
|
||||
|
||||
const beforePurge = await opened.completionReceipts.listCandidates({
|
||||
observedAtMs: 29,
|
||||
limit: 1,
|
||||
});
|
||||
assert.deepEqual(
|
||||
beforePurge.candidates.map(({ attemptId }) => attemptId),
|
||||
[ATTEMPT_2],
|
||||
);
|
||||
assert.equal(beforePurge.truncated, false);
|
||||
|
||||
const due = await opened.completionReceipts.listCandidates({
|
||||
observedAtMs: 30,
|
||||
limit: 1,
|
||||
});
|
||||
assert.deepEqual(
|
||||
due.candidates.map(({ attemptId }) => attemptId),
|
||||
[ATTEMPT_2],
|
||||
);
|
||||
assert.equal(due.truncated, true);
|
||||
const next = await opened.completionReceipts.listCandidates({
|
||||
observedAtMs: 30,
|
||||
limit: 1,
|
||||
cursor: due.nextCursor,
|
||||
});
|
||||
assert.equal(next.candidates[0].attemptId, ATTEMPT_1);
|
||||
assert.equal(next.candidates[0].state, 'quarantined');
|
||||
assert.equal(await opened.completionReceipts.resolve(ATTEMPT_1), true);
|
||||
assert.equal(await opened.completionReceipts.resolve(ATTEMPT_1), false);
|
||||
});
|
||||
|
||||
test('rejects a receipt registration that is not bound to a local Attempt', async (t) => {
|
||||
const opened = await database(t);
|
||||
await assert.rejects(
|
||||
opened.completionReceipts.register({
|
||||
runId: RUN_1,
|
||||
attemptId: ATTEMPT_1,
|
||||
registeredAtMs: 1,
|
||||
}),
|
||||
/does not match a local Attempt/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,744 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
runMigrationStream,
|
||||
} = require('@qinglong/runtime-core/migration-stream');
|
||||
const {
|
||||
createLocalExecutionContextRecipe,
|
||||
createLocalTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/local-dispatch');
|
||||
const {
|
||||
createTaskDefinitionRecord,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const {
|
||||
createBuiltInTaskSpecSemanticRegistry,
|
||||
} = require('@qinglong/runtime-core/task-spec-semantic');
|
||||
const {
|
||||
compileLocalCommandTaskDefinition,
|
||||
} = require('@qinglong/runtime-core/task-definition-execution-compiler');
|
||||
const {
|
||||
LocalSqliteConfigurationError,
|
||||
LocalSqliteReadinessError,
|
||||
auditLocalSqlitePath,
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
const {
|
||||
localSqliteMigrationDefinition,
|
||||
} = require('../dist/migration/migration');
|
||||
const {
|
||||
LocalSqliteMigrationStreamStore,
|
||||
} = require('../dist/migration/migrationStreamStore');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-sqlite-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return {
|
||||
directory,
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
};
|
||||
}
|
||||
|
||||
test('creates a reviewed edge database and opens runtime only after readiness', async (t) => {
|
||||
const { databasePath } = fixture(t);
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
|
||||
const migrated = await migrateLocalSqlitePath(options);
|
||||
assert.deepEqual(migrated.readiness.migrationIds, [
|
||||
'0001-run-core',
|
||||
'0002-capability',
|
||||
'0003-completion-receipt-journal',
|
||||
'0004-capability-v2',
|
||||
'0005-local-dispatch-plan',
|
||||
'0006-capability-v3',
|
||||
'0007-local-secret-envelopes',
|
||||
'0008-capability-v4',
|
||||
'0009-local-project-policy-audit',
|
||||
'0010-capability-v5',
|
||||
'0011-local-identity-credential',
|
||||
'0012-capability-v6',
|
||||
'0013-local-owner-bootstrap',
|
||||
'0014-capability-v7',
|
||||
'0015-local-owner-delivery-acknowledgements',
|
||||
'0016-capability-v8',
|
||||
'0017-api-credential-pepper-bindings',
|
||||
'0018-capability-v9',
|
||||
'0019-local-owner-pepper-catalog',
|
||||
'0020-capability-v10',
|
||||
'0021-local-owner-credential-recovery',
|
||||
'0022-capability-v11',
|
||||
'0023-local-owner-pepper-material-gc',
|
||||
'0024-capability-v12',
|
||||
'0025-local-owner-delivery-acknowledgement-gc',
|
||||
'0026-capability-v13',
|
||||
'0027-task-definitions',
|
||||
'0028-capability-v14',
|
||||
'0029-local-execution-revision-digest',
|
||||
'0030-capability-v15',
|
||||
'0031-trigger-definitions',
|
||||
'0032-capability-v16',
|
||||
'0033-legacy-adoption-ledger',
|
||||
'0034-capability-v17',
|
||||
'0035-local-scheduler',
|
||||
'0036-capability-v18',
|
||||
'0037-plugin-package-installs',
|
||||
'0038-capability-v19',
|
||||
'0039-approved-actions',
|
||||
'0040-capability-v20',
|
||||
'0041-plugin-package-admission-receipts',
|
||||
'0042-capability-v21',
|
||||
'0043-approved-action-executions-and-package-proposals',
|
||||
'0044-capability-v22',
|
||||
'0045-plugin-package-materialized-revisions',
|
||||
'0046-capability-v23',
|
||||
'0047-plugin-package-task-reconciliations',
|
||||
'0048-capability-v24',
|
||||
'0049-project-tool-definition-snapshots',
|
||||
'0050-capability-v25',
|
||||
'0051-step-runs',
|
||||
'0052-capability-v26',
|
||||
'0053-tool-execution-evidence',
|
||||
'0054-capability-v27',
|
||||
'0055-tool-execution-start-barriers',
|
||||
'0056-capability-v28',
|
||||
'0057-tool-invocation-artifacts',
|
||||
'0058-capability-v29',
|
||||
'0059-tool-execution-artifact-bindings',
|
||||
'0060-capability-v30',
|
||||
'0061-tool-execution-completions',
|
||||
'0062-capability-v31',
|
||||
'0063-tool-execution-failure-completions',
|
||||
'0064-capability-v32',
|
||||
'0065-tool-result-key-catalog',
|
||||
'0066-capability-v33',
|
||||
'0067-tool-result-rekey-overlays',
|
||||
'0068-capability-v34',
|
||||
'0069-plugin-package-quarantine',
|
||||
'0070-capability-v35',
|
||||
'0071-local-identity-credential-administration',
|
||||
'0072-capability-v36',
|
||||
'0073-local-project-administration',
|
||||
'0074-capability-v37',
|
||||
'0075-security-audit-compactions',
|
||||
'0076-capability-v38',
|
||||
'0077-plugin-package-lifecycle',
|
||||
'0078-capability-v39',
|
||||
'0079-plugin-package-automation-publications',
|
||||
'0080-capability-v40',
|
||||
'0081-plugin-package-workflow-admissions',
|
||||
'0082-capability-v41',
|
||||
'0083-plugin-package-workflow-task-attempt-admissions',
|
||||
'0084-capability-v42',
|
||||
'0085-plugin-package-workflow-run-list-index',
|
||||
'0086-capability-v43',
|
||||
]);
|
||||
assert.equal(migrated.readiness.contractName, 'local-control-core');
|
||||
assert.equal(migrated.readiness.contractVersion, 43);
|
||||
assert.equal(migrated.readiness.journalMode, 'delete');
|
||||
assert.equal(fs.statSync(databasePath).mode & 0o777, 0o600);
|
||||
|
||||
assert.deepEqual(await auditLocalSqlitePath(options), migrated.readiness);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(options);
|
||||
assert.equal(runtime.profile, 'edge');
|
||||
assert.deepEqual(runtime.readiness, migrated.readiness);
|
||||
assert.deepEqual(Object.keys(runtime.localDispatch).sort(), [
|
||||
'appendLocalExecutionContextRecipe',
|
||||
'appendLocalTaskExecutionRevision',
|
||||
'listLocalDispatchCandidates',
|
||||
'resolveLocalExecutionContextRecipe',
|
||||
'resolveLocalTaskExecutionRevision',
|
||||
]);
|
||||
assert.deepEqual(Object.keys(runtime.executionControl).sort(), [
|
||||
'listLocalActiveExecutions',
|
||||
'listLocalExecutionControlCandidates',
|
||||
]);
|
||||
assert.deepEqual(Object.keys(runtime.startupRecovery), ['inspectCandidates']);
|
||||
assert.deepEqual(Object.keys(runtime.completionReceipts).sort(), [
|
||||
'listCandidates',
|
||||
'markQuarantined',
|
||||
'register',
|
||||
'resolve',
|
||||
]);
|
||||
for (const capability of [
|
||||
runtime.localDispatch,
|
||||
runtime.executionControl,
|
||||
runtime.startupRecovery,
|
||||
runtime.completionReceipts,
|
||||
]) {
|
||||
assert.equal(Object.isFrozen(capability), true);
|
||||
assert.notEqual(capability, runtime.runRepository);
|
||||
}
|
||||
assert.equal(runtime.runRepository.register, undefined);
|
||||
assert.equal(runtime.runRepository.listLocalDispatchCandidates, undefined);
|
||||
assert.equal(
|
||||
typeof runtime.localSecrets.resolveLocalSecretEnvelopes,
|
||||
'function',
|
||||
);
|
||||
assert.equal(typeof runtime.projectPolicy.resolve, 'function');
|
||||
assert.equal(
|
||||
typeof runtime.localSecretAdministration
|
||||
.appendAuthorizedLocalSecretEnvelope,
|
||||
'function',
|
||||
);
|
||||
assert.equal(typeof runtime.securityAudit.record, 'function');
|
||||
assert.equal(typeof runtime.apiCredentials.resolve, 'function');
|
||||
assert.equal(
|
||||
typeof runtime.taskDefinitions.findCurrentTaskDefinition,
|
||||
'function',
|
||||
);
|
||||
assert.equal(typeof runtime.triggers.findCurrentTrigger, 'function');
|
||||
assert.equal(
|
||||
typeof (await runtime.projectToolDefinitionSnapshots()).findCurrent,
|
||||
'function',
|
||||
);
|
||||
assert.equal(
|
||||
typeof (await runtime.pluginPackageAutomationPublications())
|
||||
.listPendingPage,
|
||||
'function',
|
||||
);
|
||||
await Promise.all([runtime.close(), runtime.close()]);
|
||||
for (const operation of [
|
||||
runtime.runRepository.findRunById('closed-run'),
|
||||
runtime.localDispatch.listLocalDispatchCandidates({ limit: 1 }),
|
||||
runtime.executionControl.listLocalExecutionControlCandidates({
|
||||
observedAtMs: 1,
|
||||
limit: 1,
|
||||
}),
|
||||
runtime.startupRecovery.inspectCandidates({ limit: 1 }),
|
||||
runtime.completionReceipts.listCandidates({
|
||||
observedAtMs: 1,
|
||||
limit: 1,
|
||||
}),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
operation,
|
||||
(error) => error?.name === 'RunRepositoryOperationError',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('standalone opts into bounded WAL while edge keeps rollback journal', async (t) => {
|
||||
const { databasePath } = fixture(t);
|
||||
const options = { databasePath, profile: 'standalone' };
|
||||
const migrated = await migrateLocalSqlitePath(options);
|
||||
assert.equal(migrated.readiness.journalMode, 'wal');
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(options);
|
||||
assert.equal(runtime.readiness.journalMode, 'wal');
|
||||
await runtime.close();
|
||||
});
|
||||
|
||||
test('loads one shared trusted Tool storage bundle only when requested', async (t) => {
|
||||
const { databasePath } = fixture(t);
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(options);
|
||||
|
||||
const first = await runtime.trustedToolStorage();
|
||||
const second = await runtime.trustedToolStorage();
|
||||
assert.equal(first, second);
|
||||
assert.equal(Object.isFrozen(first), true);
|
||||
assert.equal(typeof first.invocationArtifacts.findInput, 'function');
|
||||
assert.equal(typeof first.stepRuns.findById, 'function');
|
||||
assert.equal(typeof first.startBarriers.findByStartId, 'function');
|
||||
assert.equal(typeof first.completions.findByStartId, 'function');
|
||||
assert.equal(typeof first.failureCompletions.findByStartId, 'function');
|
||||
assert.equal(typeof first.resultKeyCatalog.findCurrent, 'function');
|
||||
assert.equal(first.resultKeyCatalog.append, undefined);
|
||||
assert.equal(typeof first.resultRekeys.findHeadByArtifactId, 'function');
|
||||
assert.equal(first.resultRekeys.append, undefined);
|
||||
assert.equal(typeof first.toolDefinitionSnapshots.findCurrent, 'function');
|
||||
assert.equal(
|
||||
first.toolDefinitionSnapshots,
|
||||
await runtime.projectToolDefinitionSnapshots(),
|
||||
);
|
||||
|
||||
await runtime.close();
|
||||
});
|
||||
|
||||
test('backfills legacy credential provenance and recovery-required catalog state', async () => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
try {
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
const legacyStream = {
|
||||
...localSqliteMigrationDefinition,
|
||||
migrations: localSqliteMigrationDefinition.migrations.slice(0, 16),
|
||||
};
|
||||
await runMigrationStream({
|
||||
stream: legacyStream,
|
||||
store: new LocalSqliteMigrationStreamStore(client),
|
||||
});
|
||||
client.exec(`
|
||||
INSERT INTO "QingLong3IdentitySubjects" (
|
||||
"subject_type", "subject_id", "status", "version",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES ('user', 'legacy-user', 'active', 1, 1, 1);
|
||||
INSERT INTO "QingLong3ApiCredentials" (
|
||||
"credential_id", "version", "state", "subject_type", "subject_id",
|
||||
"secret_digest", "created_at_ms", "not_before_at_ms", "expires_at_ms"
|
||||
) VALUES (
|
||||
'legacy-credential', 1, 'active', 'user', 'legacy-user',
|
||||
'${'a'.repeat(64)}', 1, 1, 2
|
||||
);
|
||||
`);
|
||||
await localSqliteMigrationDefinition.migrations[16].up({ client });
|
||||
client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
await localSqliteMigrationDefinition.migrations[18].up({ client });
|
||||
client.exec('COMMIT');
|
||||
} catch (error) {
|
||||
if (client.isTransaction) client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT credential_id, credential_version, pepper_key_id
|
||||
FROM "QingLong3ApiCredentialPepperBindings"`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{
|
||||
credential_id: 'legacy-credential',
|
||||
credential_version: 1,
|
||||
pepper_key_id: 'legacy-v1',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT pepper_key_id, state, version, material_digest
|
||||
FROM "QingLong3LocalOwnerPepperKeys"`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{
|
||||
pepper_key_id: 'legacy-v1',
|
||||
state: 'recovery_required',
|
||||
version: 1,
|
||||
material_digest: null,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('backfills v14 execution revisions with a verified independent digest', async () => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
try {
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await runMigrationStream({
|
||||
stream: {
|
||||
...localSqliteMigrationDefinition,
|
||||
migrations: localSqliteMigrationDefinition.migrations.slice(0, 28),
|
||||
},
|
||||
store: new LocalSqliteMigrationStreamStore(client),
|
||||
});
|
||||
const recipe = createLocalExecutionContextRecipe({
|
||||
environment: [{ name: 'VALUE', kind: 'public', value: 'legacy' }],
|
||||
createdAtMs: 10,
|
||||
});
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalExecutionContextRecipes" (
|
||||
"context_ref", "environment_json", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
recipe.contextRef,
|
||||
JSON.stringify(recipe.environment),
|
||||
recipe.contentDigest,
|
||||
recipe.createdAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalTaskExecutionRevisions" (
|
||||
"project_id", "task_id", "task_revision", "executor_type",
|
||||
"command_json", "working_directory", "timeout_ms",
|
||||
"context_ref", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
'default',
|
||||
'legacy-task',
|
||||
'legacy-revision',
|
||||
'local_process',
|
||||
JSON.stringify({ kind: 'argv', file: '/bin/echo', args: ['legacy'] }),
|
||||
'/tmp',
|
||||
1000,
|
||||
recipe.contextRef,
|
||||
11,
|
||||
);
|
||||
const registry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const taskCommand = {
|
||||
projectId: 'default',
|
||||
taskId: 'task-definition-backfill',
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7200-0000-7000-8000-000000000029',
|
||||
name: 'Backfilled TaskDefinition',
|
||||
kind: 'command',
|
||||
spec: registry.normalize({
|
||||
projectId: 'default',
|
||||
taskId: 'task-definition-backfill',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: ['definition'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
labels: {},
|
||||
enabled: true,
|
||||
occurredAtMs: 12,
|
||||
};
|
||||
const taskDefinition = createTaskDefinitionRecord(taskCommand, 12);
|
||||
const taskPlan = compileLocalCommandTaskDefinition(
|
||||
taskDefinition,
|
||||
registry,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitions" (
|
||||
"project_id", "task_id", "current_revision",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskDefinition.projectId,
|
||||
taskDefinition.taskId,
|
||||
taskDefinition.revision,
|
||||
taskDefinition.createdAtMs,
|
||||
taskDefinition.updatedAtMs,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3TaskDefinitionRevisions" (
|
||||
"project_id", "task_id", "revision", "mutation_id",
|
||||
"name", "description", "kind", "spec_json", "labels_json",
|
||||
"enabled", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
taskDefinition.projectId,
|
||||
taskDefinition.taskId,
|
||||
taskDefinition.revision,
|
||||
taskDefinition.mutationId,
|
||||
taskDefinition.name,
|
||||
null,
|
||||
taskDefinition.kind,
|
||||
JSON.stringify(taskDefinition.spec),
|
||||
JSON.stringify(taskDefinition.labels),
|
||||
1,
|
||||
taskDefinition.contentDigest,
|
||||
taskDefinition.updatedAtMs,
|
||||
);
|
||||
|
||||
await runMigrationStream({
|
||||
stream: localSqliteMigrationDefinition,
|
||||
store: new LocalSqliteMigrationStreamStore(client),
|
||||
});
|
||||
|
||||
const expected = createLocalTaskExecutionRevision({
|
||||
projectId: 'default',
|
||||
taskId: 'legacy-task',
|
||||
taskRevision: 'legacy-revision',
|
||||
executorType: 'local_process',
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['legacy'] },
|
||||
workingDirectory: '/tmp',
|
||||
timeoutMs: 1000,
|
||||
contextRef: recipe.contextRef,
|
||||
createdAtMs: 11,
|
||||
});
|
||||
const stored = client
|
||||
.prepare(
|
||||
`SELECT "content_digest" AS "contentDigest", "command_json" AS "commandJson"
|
||||
FROM "QingLong3LocalTaskExecutionRevisions"`,
|
||||
)
|
||||
.get();
|
||||
assert.equal(stored.contentDigest, expected.contentDigest);
|
||||
assert.equal(stored.commandJson, JSON.stringify(expected.command));
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT "content_digest" AS "contentDigest",
|
||||
"context_ref" AS "contextRef"
|
||||
FROM "QingLong3LocalTaskExecutionRevisions"
|
||||
WHERE "project_id" = ? AND "task_id" = ?
|
||||
AND "task_revision" = ?`,
|
||||
)
|
||||
.get(
|
||||
taskDefinition.projectId,
|
||||
taskDefinition.taskId,
|
||||
taskPlan.executionRevision.taskRevision,
|
||||
),
|
||||
},
|
||||
{
|
||||
contentDigest: taskPlan.executionRevision.contentDigest,
|
||||
contextRef: taskPlan.contextRecipe.contextRef,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT contract_version, migration_id
|
||||
FROM "QingLong3SchemaCapabilities"
|
||||
WHERE contract_name = 'local-control-core'`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{
|
||||
contract_version: 43,
|
||||
migration_id: '0085-plugin-package-workflow-run-list-index',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('rolls the digest migration back when a legacy revision is not canonical', async () => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
try {
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await runMigrationStream({
|
||||
stream: {
|
||||
...localSqliteMigrationDefinition,
|
||||
migrations: localSqliteMigrationDefinition.migrations.slice(0, 28),
|
||||
},
|
||||
store: new LocalSqliteMigrationStreamStore(client),
|
||||
});
|
||||
const recipe = createLocalExecutionContextRecipe({
|
||||
environment: [],
|
||||
createdAtMs: 1,
|
||||
});
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalExecutionContextRecipes" (
|
||||
"context_ref", "environment_json", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(recipe.contextRef, '[]', recipe.contentDigest, 1);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalTaskExecutionRevisions" (
|
||||
"project_id", "task_id", "task_revision", "executor_type",
|
||||
"command_json", "working_directory", "timeout_ms",
|
||||
"context_ref", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
'default',
|
||||
'corrupt-task',
|
||||
'corrupt-revision',
|
||||
'local_process',
|
||||
'{}',
|
||||
recipe.contextRef,
|
||||
1,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
runMigrationStream({
|
||||
stream: localSqliteMigrationDefinition,
|
||||
store: new LocalSqliteMigrationStreamStore(client),
|
||||
}),
|
||||
/command kind is invalid/,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM pragma_table_info(
|
||||
'QingLong3LocalTaskExecutionRevisions'
|
||||
) WHERE name = 'content_digest'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(`SELECT COUNT(*) AS count FROM "QingLong3SchemaMigrations"`)
|
||||
.get().count,
|
||||
28,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime entrypoint keeps migration, compiler and Plugin Package adapter lazy', () => {
|
||||
const script = `
|
||||
require('@qinglong/local-sqlite/runtime');
|
||||
const loaded = Object.keys(require.cache)
|
||||
.filter((entry) =>
|
||||
/[\\/]migrations[\\/]|[\\/]migration\\.js$|taskDefinitionExecutionCompiler\\.js$|pluginPackageInstallRepository\\.js$|approvedActionExecutionRepository\\.js$|pluginPackageProposalRepository\\.js$/.test(entry),
|
||||
);
|
||||
process.stdout.write(JSON.stringify(loaded));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), []);
|
||||
});
|
||||
|
||||
test('readiness inspection subpath excludes DDL and mutable repositories', () => {
|
||||
const script = `
|
||||
const inspection = require(${JSON.stringify(
|
||||
path.resolve(__dirname, '../dist/readiness/readinessInspection.js'),
|
||||
)});
|
||||
const loaded = Object.keys(require.cache)
|
||||
.filter((entry) =>
|
||||
/[\\/]migrations[\\/]|[\\/]migration\\.js$|runRepository\\.js$|pluginPackageInstallRepository\\.js$/.test(entry),
|
||||
);
|
||||
process.stdout.write(JSON.stringify({
|
||||
inspection: typeof inspection.inspectLocalSqliteReadinessPath,
|
||||
loaded,
|
||||
}));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
inspection: 'function',
|
||||
loaded: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('Approved Action execution and Package proposal authorities require explicit subpaths', () => {
|
||||
const root = require('@qinglong/local-sqlite');
|
||||
const execution = require('@qinglong/local-sqlite/approved-action-execution');
|
||||
const proposal = require('@qinglong/local-sqlite/plugin-package-proposal');
|
||||
assert.equal(root.LocalSqliteApprovedActionExecutionRepository, undefined);
|
||||
assert.equal(
|
||||
root.LocalSqlitePluginPackageInstallProposalRepository,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
typeof execution.LocalSqliteApprovedActionExecutionRepository,
|
||||
'function',
|
||||
);
|
||||
assert.equal(
|
||||
typeof proposal.LocalSqlitePluginPackageInstallProposalRepository,
|
||||
'function',
|
||||
);
|
||||
});
|
||||
|
||||
test('runtime bootstrap never auto-migrates an unprepared database', async (t) => {
|
||||
const { databasePath } = fixture(t);
|
||||
new DatabaseSync(databasePath).close();
|
||||
|
||||
await assert.rejects(
|
||||
openLocalSqliteRuntimeDatabase({ databasePath, profile: 'standalone' }),
|
||||
LocalSqliteReadinessError,
|
||||
);
|
||||
const client = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT 1 FROM sqlite_schema
|
||||
WHERE type = 'table' AND name = 'QingLong3SchemaMigrations'`,
|
||||
)
|
||||
.get(),
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for schema drift and migration checksum drift', async (t) => {
|
||||
const { databasePath } = fixture(t);
|
||||
const options = { databasePath, profile: 'standalone' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec('DROP INDEX ql3_local_events_run_sequence_uidx');
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3SchemaMigrations"
|
||||
SET checksum = ? WHERE migration_id = '0001-run-core'`,
|
||||
)
|
||||
.run('0'.repeat(64));
|
||||
client.close();
|
||||
|
||||
await assert.rejects(
|
||||
auditLocalSqlitePath(options),
|
||||
(error) =>
|
||||
error instanceof LocalSqliteReadinessError &&
|
||||
/audit failed/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('excludes reviewed optional feature tables while preserving unknown table drift evidence', async (t) => {
|
||||
const { databasePath } = fixture(t);
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 76);
|
||||
client.exec(
|
||||
'CREATE TABLE "ModelInvocationFeatureHead" (feature_id TEXT PRIMARY KEY)',
|
||||
);
|
||||
client.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 76);
|
||||
|
||||
const unknownClient = new DatabaseSync(databasePath);
|
||||
unknownClient.exec('CREATE TABLE "UserExtensionData" (id TEXT PRIMARY KEY)');
|
||||
unknownClient.close();
|
||||
|
||||
assert.equal((await auditLocalSqlitePath(options)).tableCount, 77);
|
||||
|
||||
const triggerClient = new DatabaseSync(databasePath);
|
||||
triggerClient.exec(`
|
||||
CREATE TRIGGER unreviewed_run_trigger AFTER INSERT ON "Runs"
|
||||
BEGIN
|
||||
SELECT 1;
|
||||
END
|
||||
`);
|
||||
triggerClient.close();
|
||||
await assert.rejects(
|
||||
auditLocalSqlitePath(options),
|
||||
/reviewed trigger contract is incompatible/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects wrong Profiles, relative paths and symlink targets before opening', async (t) => {
|
||||
const { directory, databasePath } = fixture(t);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
migrateLocalSqlitePath({
|
||||
databasePath,
|
||||
profile: 'cluster-control',
|
||||
}),
|
||||
LocalSqliteConfigurationError,
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
migrateLocalSqlitePath({ databasePath: 'relative.db', profile: 'edge' }),
|
||||
LocalSqliteConfigurationError,
|
||||
);
|
||||
|
||||
const target = path.join(directory, 'target.sqlite');
|
||||
new DatabaseSync(target).close();
|
||||
const link = path.join(directory, 'linked.sqlite');
|
||||
fs.symlinkSync(target, link);
|
||||
await assert.rejects(
|
||||
migrateLocalSqlitePath({ databasePath: link, profile: 'edge' }),
|
||||
LocalSqliteConfigurationError,
|
||||
);
|
||||
});
|
||||
+667
@@ -0,0 +1,667 @@
|
||||
const fs = require('node:fs');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
const {
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createPluginPackageLifecycleEvent,
|
||||
pluginPackageLifecycleActionDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-lifecycle');
|
||||
const {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionSnapshotContribution,
|
||||
} = require('@qinglong/runtime-core/project-tool-definition-snapshot');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqliteApprovalRequestRepository,
|
||||
} = require('../../dist/approved-action/approvalRequestRepository');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
EDGE_PLUGIN_PACKAGE_LIFECYCLE_ACTIVE_SOURCE_LIMIT,
|
||||
STANDALONE_PLUGIN_PACKAGE_LIFECYCLE_ACTIVE_SOURCE_LIMIT,
|
||||
LocalSqlitePluginPackageLifecycleRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageLifecycleRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageTaskReconciliationRepository');
|
||||
const {
|
||||
LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
} = require('../../dist/tool-execution/projectToolDefinitionSnapshotRepository');
|
||||
const { migrateLocalSqlitePath } = require('../../dist/migration/migration');
|
||||
const { auditLocalSqliteReadiness } = require('../../dist/readiness/readiness');
|
||||
|
||||
const OWNER = Object.freeze({ type: 'user', id: 'owner-001' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'lifecycle-dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
const CRASH_POINTS = Object.freeze({
|
||||
after_task_revision: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3TaskDefinitionRevisions"',
|
||||
durable: false,
|
||||
}),
|
||||
after_tool_snapshot: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3ProjectToolDefinitionSnapshots"',
|
||||
durable: false,
|
||||
}),
|
||||
after_lifecycle_event: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3PluginPackageLifecycleEvents"',
|
||||
durable: false,
|
||||
}),
|
||||
after_lifecycle_receipt: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3PluginPackageLifecycleReceipts"',
|
||||
durable: false,
|
||||
}),
|
||||
after_lifecycle_task: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3PluginPackageLifecycleTasks"',
|
||||
durable: false,
|
||||
}),
|
||||
after_lifecycle_head: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3PluginPackageLifecycleHeads"',
|
||||
durable: false,
|
||||
}),
|
||||
before_commit: Object.freeze({
|
||||
timing: 'beforeExec',
|
||||
sql: 'COMMIT',
|
||||
durable: false,
|
||||
}),
|
||||
after_commit: Object.freeze({
|
||||
timing: 'afterExec',
|
||||
sql: 'COMMIT',
|
||||
durable: true,
|
||||
}),
|
||||
});
|
||||
|
||||
function fixture(profile) {
|
||||
return pluginPackageTaskReconciliationFixture(
|
||||
`lifecycle-crash-${profile}`,
|
||||
{ profile },
|
||||
);
|
||||
}
|
||||
|
||||
function client(databasePath) {
|
||||
const database = new DatabaseSync(databasePath);
|
||||
database.exec('PRAGMA foreign_keys = ON');
|
||||
return database;
|
||||
}
|
||||
|
||||
function activeSourceLimit(profile) {
|
||||
return profile === 'edge'
|
||||
? EDGE_PLUGIN_PACKAGE_LIFECYCLE_ACTIVE_SOURCE_LIMIT
|
||||
: STANDALONE_PLUGIN_PACKAGE_LIFECYCLE_ACTIVE_SOURCE_LIMIT;
|
||||
}
|
||||
|
||||
function repositories(authority, value, profile) {
|
||||
return {
|
||||
approval: new LocalSqliteApprovalRequestRepository(authority),
|
||||
install: new LocalSqlitePluginPackageInstallRepository(authority),
|
||||
lifecycle: new LocalSqlitePluginPackageLifecycleRepository(authority, {
|
||||
registry: value.registry,
|
||||
activeSourceLimit: activeSourceLimit(profile),
|
||||
}),
|
||||
materialized:
|
||||
new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
authority,
|
||||
value.registry,
|
||||
),
|
||||
reconciliation:
|
||||
new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
authority,
|
||||
value.registry,
|
||||
),
|
||||
snapshots: new LocalSqliteProjectToolDefinitionSnapshotRepository(
|
||||
authority,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
projectId,
|
||||
occurredAtMs,
|
||||
) {
|
||||
return {
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons: [outcome === 'approval_required' ? 'package_review' : 'role_grant'],
|
||||
fence: FENCE,
|
||||
occurredAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function auditId(sequence, offset) {
|
||||
return `91000000-0000-4000-8000-${String(sequence * 10 + offset).padStart(
|
||||
12,
|
||||
'0',
|
||||
)}`;
|
||||
}
|
||||
|
||||
function approvalSequence(action) {
|
||||
return action === 'enable' ? 2 : 1;
|
||||
}
|
||||
|
||||
async function approveLifecycleImpact(
|
||||
approval,
|
||||
value,
|
||||
impact,
|
||||
sequence,
|
||||
) {
|
||||
const requestId = `lifecycle-crash-approval-${sequence}`;
|
||||
const dispatchId = `lifecycle-crash-dispatch-${sequence}`;
|
||||
const requestedAtMs = 10_000 * sequence + 1;
|
||||
const decidedAtMs = requestedAtMs + 1;
|
||||
const consumedAtMs = requestedAtMs + 2;
|
||||
const expiresAtMs = requestedAtMs + 1_000;
|
||||
const action = {
|
||||
permission: 'package.manage',
|
||||
actionType: `plugin_package.lifecycle.${impact.action}`,
|
||||
actionRef: `lifecycle:${impact.impactDigest}`,
|
||||
actionDigest: pluginPackageLifecycleActionDigest(impact),
|
||||
previewDigest: impact.impactDigest,
|
||||
};
|
||||
await approval.create({
|
||||
request: createApprovalRequest({
|
||||
id: requestId,
|
||||
projectId: value.projectId,
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: OWNER,
|
||||
requestedAtMs,
|
||||
expiresAtMs,
|
||||
requestFence: FENCE,
|
||||
}),
|
||||
audit: audit(
|
||||
auditId(sequence, 1),
|
||||
`lifecycle-crash-http-${sequence}`,
|
||||
'approval.request',
|
||||
OWNER,
|
||||
`auth-request-${sequence}`,
|
||||
'approval_required',
|
||||
value.projectId,
|
||||
requestedAtMs,
|
||||
),
|
||||
});
|
||||
await approval.decide({
|
||||
requestId,
|
||||
expectedVersion: 1,
|
||||
decisionId: `lifecycle-crash-decision-${sequence}`,
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: OWNER,
|
||||
authenticationId: `auth-approve-${sequence}`,
|
||||
authenticatedAtMs: decidedAtMs - 1,
|
||||
expiresAtMs,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
auditId(sequence, 2),
|
||||
`lifecycle-crash-http-${sequence}`,
|
||||
'approval.decide',
|
||||
OWNER,
|
||||
`auth-approve-${sequence}`,
|
||||
'allowed',
|
||||
value.projectId,
|
||||
decidedAtMs,
|
||||
),
|
||||
});
|
||||
return approval.consume({
|
||||
requestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId: `lifecycle-crash-consume-${sequence}`,
|
||||
dispatchId,
|
||||
action,
|
||||
requestedBy: OWNER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
auditId(sequence, 3),
|
||||
`lifecycle-crash-dispatch-cycle-${sequence}`,
|
||||
'approval.consume',
|
||||
SYSTEM,
|
||||
`auth-dispatch-${sequence}`,
|
||||
'allowed',
|
||||
value.projectId,
|
||||
consumedAtMs,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function lifecycleEvent(impact, action) {
|
||||
const sequence = approvalSequence(action);
|
||||
return createPluginPackageLifecycleEvent({
|
||||
dispatchId: `lifecycle-crash-dispatch-${sequence}`,
|
||||
impact,
|
||||
requestedBy: OWNER,
|
||||
approvedBy: OWNER,
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs: 10_000 * sequence + 4,
|
||||
});
|
||||
}
|
||||
|
||||
async function publishActivePackage(repositoriesValue, value) {
|
||||
await activateInstall(repositoriesValue.install, value);
|
||||
await repositoriesValue.materialized.publish(value.revision);
|
||||
await repositoriesValue.reconciliation.reconcile(value.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return value.revision.generation;
|
||||
},
|
||||
});
|
||||
await repositoriesValue.snapshots.publish(
|
||||
createProjectToolDefinitionSnapshot({
|
||||
projectId: value.projectId,
|
||||
contributions: [
|
||||
projectToolDefinitionSnapshotContribution(
|
||||
value.revision,
|
||||
value.registry,
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function setupScenario({ action, databasePath, profile }) {
|
||||
await migrateLocalSqlitePath({ databasePath, profile });
|
||||
const value = fixture(profile);
|
||||
const database = client(databasePath);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(value.projectId, value.projectId, value.projectId);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
project_id, subject_type, subject_id, version, state, role,
|
||||
mutation_id, changed_by_type, changed_by_id, created_at_ms
|
||||
) VALUES (?, 'user', ?, 1, 'active', 'owner', ?, 'user', ?, 1)`,
|
||||
)
|
||||
.run(
|
||||
value.projectId,
|
||||
OWNER.id,
|
||||
`grant-lifecycle-crash-${profile}`,
|
||||
OWNER.id,
|
||||
);
|
||||
const authority = new LocalSqliteOperationAuthority(database);
|
||||
try {
|
||||
const repository = repositories(authority, value, profile);
|
||||
await publishActivePackage(repository, value);
|
||||
if (action === 'enable') {
|
||||
const disableImpact = await repository.lifecycle.plan(
|
||||
'disable',
|
||||
value.projectId,
|
||||
value.packageName,
|
||||
);
|
||||
await approveLifecycleImpact(
|
||||
repository.approval,
|
||||
value,
|
||||
disableImpact,
|
||||
1,
|
||||
);
|
||||
await repository.lifecycle.transition(
|
||||
lifecycleEvent(disableImpact, 'disable'),
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
const impact = await repository.lifecycle.plan(
|
||||
action,
|
||||
value.projectId,
|
||||
value.packageName,
|
||||
);
|
||||
await approveLifecycleImpact(
|
||||
repository.approval,
|
||||
value,
|
||||
impact,
|
||||
approvalSequence(action),
|
||||
);
|
||||
return lifecycleEvent(impact, action);
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
function writeCrashMarker(markerPath, pointName, action) {
|
||||
const descriptor = fs.openSync(markerPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeSync(
|
||||
descriptor,
|
||||
JSON.stringify({
|
||||
schema: 'qinglong/sqlite-plugin-package-lifecycle-crash-marker@v1',
|
||||
action,
|
||||
point: pointName,
|
||||
pid: process.pid,
|
||||
}),
|
||||
);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function crashClient(database, pointName, markerPath, action) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
if (!point) throw new Error(`unknown crash point ${pointName}`);
|
||||
let triggered = false;
|
||||
const crash = () => {
|
||||
if (triggered) return;
|
||||
triggered = true;
|
||||
writeCrashMarker(markerPath, pointName, action);
|
||||
process.kill(process.pid, 'SIGKILL');
|
||||
throw new Error(`SIGKILL did not terminate ${action}/${pointName}`);
|
||||
};
|
||||
const matches = (timing, sql) =>
|
||||
!triggered && point.timing === timing && sql.trim().includes(point.sql);
|
||||
return new Proxy(database, {
|
||||
get(target, property) {
|
||||
if (property === 'exec') {
|
||||
return (sql) => {
|
||||
if (matches('beforeExec', sql)) crash();
|
||||
const result = target.exec(sql);
|
||||
if (matches('afterExec', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
if (property === 'prepare') {
|
||||
return (sql) => {
|
||||
const statement = target.prepare(sql);
|
||||
return new Proxy(statement, {
|
||||
get(statementTarget, statementProperty) {
|
||||
const value = Reflect.get(
|
||||
statementTarget,
|
||||
statementProperty,
|
||||
statementTarget,
|
||||
);
|
||||
if (statementProperty === 'run') {
|
||||
return (...values) => {
|
||||
const result = value.apply(statementTarget, values);
|
||||
if (matches('afterRun', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return typeof value === 'function'
|
||||
? value.bind(statementTarget)
|
||||
: value;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runCrashScenario({
|
||||
action,
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
}) {
|
||||
const value = fixture(profile);
|
||||
const planningAuthority = new LocalSqliteOperationAuthority(
|
||||
client(databasePath),
|
||||
);
|
||||
const impact = await repositories(
|
||||
planningAuthority,
|
||||
value,
|
||||
profile,
|
||||
).lifecycle.plan(action, value.projectId, value.packageName);
|
||||
await planningAuthority.close();
|
||||
const database = client(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(
|
||||
crashClient(database, pointName, markerPath, action),
|
||||
);
|
||||
const repository = repositories(authority, value, profile);
|
||||
await repository.lifecycle.transition(lifecycleEvent(impact, action), () => {});
|
||||
throw new Error(`crash point ${action}/${pointName} was not reached`);
|
||||
}
|
||||
|
||||
function eventFacts(database, eventDigest) {
|
||||
return {
|
||||
events: database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleEvents"
|
||||
WHERE event_digest = ?`,
|
||||
)
|
||||
.get(eventDigest).count,
|
||||
receipts: database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleReceipts"
|
||||
WHERE event_digest = ?`,
|
||||
)
|
||||
.get(eventDigest).count,
|
||||
tasks: database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleTasks"
|
||||
WHERE event_digest = ?`,
|
||||
)
|
||||
.get(eventDigest).count,
|
||||
heads: database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleHeads"
|
||||
WHERE event_digest = ?`,
|
||||
)
|
||||
.get(eventDigest).count,
|
||||
};
|
||||
}
|
||||
|
||||
function taskFacts(database, projectId) {
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT revision.enabled,
|
||||
head.current_revision AS "currentRevision"
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
WHERE head.project_id = ?
|
||||
ORDER BY head.task_id`,
|
||||
)
|
||||
.all(projectId);
|
||||
}
|
||||
|
||||
function assertTaskState(facts, revision, enabled, label) {
|
||||
if (
|
||||
facts.length !== 2 ||
|
||||
facts.some(
|
||||
(fact) =>
|
||||
fact.enabled !== enabled || fact.currentRevision !== revision,
|
||||
)
|
||||
) {
|
||||
throw new Error(`${label} Task state is incomplete`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyScenario({
|
||||
action,
|
||||
databasePath,
|
||||
event,
|
||||
pointName,
|
||||
profile,
|
||||
}) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
const value = fixture(profile);
|
||||
const database = client(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(database);
|
||||
try {
|
||||
const repository = repositories(authority, value, profile);
|
||||
const beforeRecovery = await repository.lifecycle.findByEventDigest(
|
||||
event.eventDigest,
|
||||
);
|
||||
if (point.durable !== (beforeRecovery !== null)) {
|
||||
throw new Error(
|
||||
`${profile}/${action}/${pointName} durability is inconsistent`,
|
||||
);
|
||||
}
|
||||
const beforeEventFacts = eventFacts(database, event.eventDigest);
|
||||
const expectedBeforeFacts = point.durable
|
||||
? { events: 1, receipts: 1, tasks: 2, heads: 1 }
|
||||
: { events: 0, receipts: 0, tasks: 0, heads: 0 };
|
||||
if (JSON.stringify(beforeEventFacts) !== JSON.stringify(expectedBeforeFacts)) {
|
||||
throw new Error(
|
||||
`${profile}/${action}/${pointName} left partial lifecycle facts`,
|
||||
);
|
||||
}
|
||||
const beforeRevision = action === 'disable' ? 1 : 2;
|
||||
const beforeEnabled = action === 'disable' ? 1 : 0;
|
||||
const finalRevision = action === 'disable' ? 2 : 3;
|
||||
const finalEnabled = action === 'disable' ? 0 : 1;
|
||||
assertTaskState(
|
||||
taskFacts(database, value.projectId),
|
||||
point.durable ? finalRevision : beforeRevision,
|
||||
point.durable ? finalEnabled : beforeEnabled,
|
||||
`${profile}/${action}/${pointName} pre-recovery`,
|
||||
);
|
||||
const beforeSnapshot = await repository.snapshots.findCurrent(
|
||||
value.projectId,
|
||||
);
|
||||
const expectedBeforeSourceCount = point.durable
|
||||
? action === 'enable'
|
||||
? 1
|
||||
: 0
|
||||
: action === 'enable'
|
||||
? 0
|
||||
: 1;
|
||||
if (beforeSnapshot.snapshot.sources.length !== expectedBeforeSourceCount) {
|
||||
throw new Error(
|
||||
`${profile}/${action}/${pointName} Tool snapshot is partial`,
|
||||
);
|
||||
}
|
||||
const recovered = await repository.lifecycle.transition(event, () => {});
|
||||
if (recovered.status !== (point.durable ? 'existing' : 'created')) {
|
||||
throw new Error(
|
||||
`${profile}/${action}/${pointName} replay status is inconsistent`,
|
||||
);
|
||||
}
|
||||
assertTaskState(
|
||||
taskFacts(database, value.projectId),
|
||||
finalRevision,
|
||||
finalEnabled,
|
||||
`${profile}/${action}/${pointName} recovered`,
|
||||
);
|
||||
const recoveredSnapshot = await repository.snapshots.findCurrent(
|
||||
value.projectId,
|
||||
);
|
||||
const finalSourceCount = action === 'enable' ? 1 : 0;
|
||||
if (recoveredSnapshot.snapshot.sources.length !== finalSourceCount) {
|
||||
throw new Error(
|
||||
`${profile}/${action}/${pointName} recovered Tool snapshot is invalid`,
|
||||
);
|
||||
}
|
||||
const recoveredFacts = eventFacts(database, event.eventDigest);
|
||||
if (
|
||||
JSON.stringify(recoveredFacts) !==
|
||||
JSON.stringify({ events: 1, receipts: 1, tasks: 2, heads: 1 })
|
||||
) {
|
||||
throw new Error(
|
||||
`${profile}/${action}/${pointName} replay is not exactly once`,
|
||||
);
|
||||
}
|
||||
const head = await repository.lifecycle.findHead(
|
||||
value.projectId,
|
||||
value.packageName,
|
||||
);
|
||||
const expectedDisposition = action === 'enable' ? 'active' : 'disabled';
|
||||
if (
|
||||
!head ||
|
||||
head.disposition !== expectedDisposition ||
|
||||
head.eventDigest !== event.eventDigest
|
||||
) {
|
||||
throw new Error(
|
||||
`${profile}/${action}/${pointName} lifecycle head is invalid`,
|
||||
);
|
||||
}
|
||||
await auditLocalSqliteReadiness(database);
|
||||
const integrity = database.prepare('PRAGMA integrity_check').get();
|
||||
const foreignKey = database
|
||||
.prepare('SELECT * FROM pragma_foreign_key_check LIMIT 1')
|
||||
.get();
|
||||
const journal = database.prepare('PRAGMA journal_mode').get();
|
||||
const synchronous = database.prepare('PRAGMA synchronous').get();
|
||||
return Object.freeze({
|
||||
profile,
|
||||
action,
|
||||
pointName,
|
||||
crashBeforeCommit: !point.durable,
|
||||
durableAfterCrash: point.durable,
|
||||
exactReplay: true,
|
||||
integrityCheck: Object.values(integrity)[0],
|
||||
foreignKeyCheck: foreignKey === undefined ? 'ok' : 'failed',
|
||||
journalMode: journal.journal_mode,
|
||||
synchronous: synchronous.synchronous,
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CRASH_POINTS,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const [
|
||||
,
|
||||
,
|
||||
command,
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
action,
|
||||
] = process.argv;
|
||||
if (command !== 'crash') {
|
||||
throw new Error('fixture command must be crash');
|
||||
}
|
||||
runCrashScenario({
|
||||
action,
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
}).catch((error) => {
|
||||
process.stderr.write(`${error.stack ?? error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
const fs = require('node:fs');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
const {
|
||||
createPluginPackageQuarantineEvent,
|
||||
} = require('@qinglong/runtime-core/plugin-package-quarantine');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageQuarantineRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageQuarantineRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageTaskReconciliationRepository');
|
||||
const { auditLocalSqliteReadiness } = require('../../dist/readiness/readiness');
|
||||
const { migrateLocalSqlitePath } = require('../../dist/migration/migration');
|
||||
|
||||
const DIGEST_D = 'd'.repeat(64);
|
||||
const DIGEST_E = 'e'.repeat(64);
|
||||
|
||||
const CRASH_POINTS = Object.freeze({
|
||||
after_task_disable: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3TaskDefinitionRevisions"',
|
||||
durable: false,
|
||||
}),
|
||||
after_quarantine_event: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3PluginPackageQuarantineEvents"',
|
||||
durable: false,
|
||||
}),
|
||||
after_withdrawal_receipt: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3PluginPackageWithdrawalReceipts"',
|
||||
durable: false,
|
||||
}),
|
||||
before_commit: Object.freeze({
|
||||
timing: 'beforeExec',
|
||||
sql: 'COMMIT',
|
||||
durable: false,
|
||||
}),
|
||||
after_commit: Object.freeze({
|
||||
timing: 'afterExec',
|
||||
sql: 'COMMIT',
|
||||
durable: true,
|
||||
}),
|
||||
});
|
||||
|
||||
function fixture(profile) {
|
||||
return pluginPackageTaskReconciliationFixture(`quarantine-crash-${profile}`, {
|
||||
profile,
|
||||
});
|
||||
}
|
||||
|
||||
function event(value) {
|
||||
const record = value.install.active;
|
||||
return createPluginPackageQuarantineEvent({
|
||||
mutationId: `quarantine-crash-${value.profile}`,
|
||||
revocationReceiptDigest: DIGEST_D,
|
||||
impactDigest: DIGEST_E,
|
||||
target: {
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
installationId: record.installationId,
|
||||
lockDigest: record.lockDigest,
|
||||
installState: record.state,
|
||||
installVersion: record.version,
|
||||
installRecordDigest: record.recordDigest,
|
||||
activeLockDigest: record.activeLockDigest,
|
||||
},
|
||||
proposer: { type: 'user', id: 'owner-a' },
|
||||
confirmer: { type: 'user', id: 'owner-b' },
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
occurredAtMs: record.updatedAtMs + 1,
|
||||
});
|
||||
}
|
||||
|
||||
function client(databasePath) {
|
||||
const database = new DatabaseSync(databasePath);
|
||||
database.exec('PRAGMA foreign_keys = ON');
|
||||
return database;
|
||||
}
|
||||
|
||||
async function setupScenario({ databasePath, profile }) {
|
||||
await migrateLocalSqlitePath({ databasePath, profile });
|
||||
const value = fixture(profile);
|
||||
const database = client(databasePath);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(value.projectId, value.projectId, value.projectId);
|
||||
const authority = new LocalSqliteOperationAuthority(database);
|
||||
try {
|
||||
const install = new LocalSqlitePluginPackageInstallRepository(authority);
|
||||
const materialized =
|
||||
new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
authority,
|
||||
value.registry,
|
||||
);
|
||||
const reconciliation =
|
||||
new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
authority,
|
||||
value.registry,
|
||||
);
|
||||
await activateInstall(install, value);
|
||||
await materialized.publish(value.revision);
|
||||
await reconciliation.reconcile(value.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return value.revision.generation;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
function writeCrashMarker(markerPath, pointName) {
|
||||
const descriptor = fs.openSync(markerPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeSync(
|
||||
descriptor,
|
||||
JSON.stringify({
|
||||
schema: 'qinglong/sqlite-plugin-package-quarantine-crash-marker@v1',
|
||||
point: pointName,
|
||||
pid: process.pid,
|
||||
}),
|
||||
);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function crashClient(database, pointName, markerPath) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
if (!point) throw new Error(`unknown crash point ${pointName}`);
|
||||
let triggered = false;
|
||||
const crash = () => {
|
||||
if (triggered) return;
|
||||
triggered = true;
|
||||
writeCrashMarker(markerPath, pointName);
|
||||
process.kill(process.pid, 'SIGKILL');
|
||||
throw new Error(`SIGKILL did not terminate ${pointName}`);
|
||||
};
|
||||
const matches = (timing, sql) =>
|
||||
!triggered && point.timing === timing && sql.trim().includes(point.sql);
|
||||
return new Proxy(database, {
|
||||
get(target, property) {
|
||||
if (property === 'exec') {
|
||||
return (sql) => {
|
||||
if (matches('beforeExec', sql)) crash();
|
||||
const result = target.exec(sql);
|
||||
if (matches('afterExec', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
if (property === 'prepare') {
|
||||
return (sql) => {
|
||||
const statement = target.prepare(sql);
|
||||
return new Proxy(statement, {
|
||||
get(statementTarget, statementProperty) {
|
||||
const value = Reflect.get(
|
||||
statementTarget,
|
||||
statementProperty,
|
||||
statementTarget,
|
||||
);
|
||||
if (statementProperty === 'run') {
|
||||
return (...values) => {
|
||||
const result = value.apply(statementTarget, values);
|
||||
if (matches('afterRun', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return typeof value === 'function'
|
||||
? value.bind(statementTarget)
|
||||
: value;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runCrashScenario({
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
}) {
|
||||
const database = client(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(
|
||||
crashClient(database, pointName, markerPath),
|
||||
);
|
||||
const value = fixture(profile);
|
||||
await new LocalSqlitePluginPackageQuarantineRepository(authority, {
|
||||
registry: value.registry,
|
||||
activeSourceLimit: profile === 'edge' ? 4 : 16,
|
||||
}).quarantine(event(value), () => {});
|
||||
throw new Error(`crash point ${pointName} was not reached`);
|
||||
}
|
||||
|
||||
async function verifyScenario({ databasePath, pointName, profile }) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
const value = fixture(profile);
|
||||
const quarantineEvent = event(value);
|
||||
const database = client(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(database);
|
||||
try {
|
||||
const repository = new LocalSqlitePluginPackageQuarantineRepository(
|
||||
authority,
|
||||
{
|
||||
registry: value.registry,
|
||||
activeSourceLimit: profile === 'edge' ? 4 : 16,
|
||||
},
|
||||
);
|
||||
const beforeRecovery = await repository.findByEventDigest(
|
||||
quarantineEvent.eventDigest,
|
||||
);
|
||||
if (point.durable !== (beforeRecovery !== null)) {
|
||||
throw new Error(`${profile}/${pointName} durability is inconsistent`);
|
||||
}
|
||||
const recovered = await repository.quarantine(quarantineEvent, () => {});
|
||||
if (recovered.status !== (point.durable ? 'existing' : 'created')) {
|
||||
throw new Error(`${profile}/${pointName} replay status is inconsistent`);
|
||||
}
|
||||
const taskFacts = database
|
||||
.prepare(
|
||||
`SELECT revision.enabled, head.current_revision AS "currentRevision"
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
WHERE head.project_id = ?
|
||||
ORDER BY head.task_id`,
|
||||
)
|
||||
.all(value.projectId);
|
||||
if (
|
||||
taskFacts.length !== 2 ||
|
||||
taskFacts.some((fact) => fact.enabled !== 0 || fact.currentRevision !== 2)
|
||||
) {
|
||||
throw new Error(`${profile}/${pointName} Task withdrawal is incomplete`);
|
||||
}
|
||||
await auditLocalSqliteReadiness(database);
|
||||
const integrity = database.prepare('PRAGMA integrity_check').get();
|
||||
const foreignKey = database
|
||||
.prepare('SELECT * FROM pragma_foreign_key_check LIMIT 1')
|
||||
.get();
|
||||
const journal = database.prepare('PRAGMA journal_mode').get();
|
||||
return Object.freeze({
|
||||
profile,
|
||||
pointName,
|
||||
crashBeforeCommit: !point.durable,
|
||||
durableAfterCrash: point.durable,
|
||||
integrityCheck: Object.values(integrity)[0],
|
||||
foreignKeyCheck: foreignKey === undefined ? 'ok' : 'failed',
|
||||
journalMode: journal.journal_mode,
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CRASH_POINTS,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const [, , action, databasePath, markerPath, pointName, profile] =
|
||||
process.argv;
|
||||
if (action !== 'crash') {
|
||||
throw new Error('fixture action must be crash');
|
||||
}
|
||||
runCrashScenario({
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
}).catch((error) => {
|
||||
process.stderr.write(`${error.stack ?? error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Vendored
+488
@@ -0,0 +1,488 @@
|
||||
const fs = require('node:fs');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { performance } = require('node:perf_hooks');
|
||||
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageAutomationPublicationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowAdmissionRepository,
|
||||
} = require('../../dist/plugin-package/workflow/pluginPackageWorkflowAdmissionRepository');
|
||||
const { auditLocalSqliteReadiness } = require('../../dist/readiness/readiness');
|
||||
const { migrateLocalSqlitePath } = require('../../dist/migration/migration');
|
||||
|
||||
const CRASH_POINTS = Object.freeze({
|
||||
after_run: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "Runs"',
|
||||
durable: false,
|
||||
}),
|
||||
after_admission_event: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "RunEvents"',
|
||||
durable: false,
|
||||
}),
|
||||
after_first_step_run: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "StepRuns"',
|
||||
durable: false,
|
||||
}),
|
||||
after_first_step_mutation: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "StepRunMutations"',
|
||||
durable: false,
|
||||
}),
|
||||
after_admission: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3PluginPackageWorkflowAdmissions"',
|
||||
durable: false,
|
||||
}),
|
||||
after_first_admission_step: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "QingLong3PluginPackageWorkflowAdmissionSteps"',
|
||||
durable: false,
|
||||
}),
|
||||
before_commit: Object.freeze({
|
||||
timing: 'beforeExec',
|
||||
sql: 'COMMIT',
|
||||
durable: false,
|
||||
}),
|
||||
after_commit: Object.freeze({
|
||||
timing: 'afterExec',
|
||||
sql: 'COMMIT',
|
||||
durable: true,
|
||||
}),
|
||||
});
|
||||
|
||||
function fixture(profile) {
|
||||
const value = pluginPackageTaskReconciliationFixture(
|
||||
`workflow-admission-crash-${profile}`,
|
||||
{
|
||||
profile,
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
return {
|
||||
...value,
|
||||
publication: createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
2_000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function executionPlan(value) {
|
||||
return createPluginPackageWorkflowExecutionPlan({
|
||||
planId: `workflow-admission-crash-plan-${value.profile}`,
|
||||
runId: `wfa-crash-run-${value.profile}`,
|
||||
workflowId: 'daily',
|
||||
stepRunIds: {
|
||||
collect: `workflow-admission-crash-collect-${value.profile}`,
|
||||
summarize: `workflow-admission-crash-summary-${value.profile}`,
|
||||
},
|
||||
publication: value.publication,
|
||||
revision: value.revision,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
plannedAtMs: 3_000,
|
||||
});
|
||||
}
|
||||
|
||||
function client(databasePath) {
|
||||
const database = new DatabaseSync(databasePath);
|
||||
database.exec('PRAGMA foreign_keys = ON');
|
||||
return database;
|
||||
}
|
||||
|
||||
async function setupScenario({ databasePath, profile }) {
|
||||
await migrateLocalSqlitePath({ databasePath, profile });
|
||||
const value = fixture(profile);
|
||||
const database = client(databasePath);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(value.projectId, value.projectId, value.projectId);
|
||||
const authority = new LocalSqliteOperationAuthority(database);
|
||||
try {
|
||||
await activateInstall(
|
||||
new LocalSqlitePluginPackageInstallRepository(authority),
|
||||
value,
|
||||
);
|
||||
await new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
authority,
|
||||
value.registry,
|
||||
).publish(value.revision);
|
||||
await new LocalSqlitePluginPackageAutomationPublicationRepository(
|
||||
authority,
|
||||
).publish(value.publication);
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
function writeCrashMarker(markerPath, pointName) {
|
||||
const descriptor = fs.openSync(markerPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeSync(
|
||||
descriptor,
|
||||
JSON.stringify({
|
||||
schema:
|
||||
'qinglong/sqlite-plugin-package-workflow-admission-crash-marker@v1',
|
||||
point: pointName,
|
||||
pid: process.pid,
|
||||
}),
|
||||
);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function crashClient(database, pointName, markerPath) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
if (!point) throw new Error(`unknown crash point ${pointName}`);
|
||||
let triggered = false;
|
||||
const crash = () => {
|
||||
if (triggered) return;
|
||||
triggered = true;
|
||||
writeCrashMarker(markerPath, pointName);
|
||||
process.kill(process.pid, 'SIGKILL');
|
||||
throw new Error(`SIGKILL did not terminate ${pointName}`);
|
||||
};
|
||||
const matches = (timing, sql) =>
|
||||
!triggered && point.timing === timing && sql.trim().includes(point.sql);
|
||||
return new Proxy(database, {
|
||||
get(target, property) {
|
||||
if (property === 'exec') {
|
||||
return (sql) => {
|
||||
if (matches('beforeExec', sql)) crash();
|
||||
const result = target.exec(sql);
|
||||
if (matches('afterExec', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
if (property === 'prepare') {
|
||||
return (sql) => {
|
||||
const statement = target.prepare(sql);
|
||||
return new Proxy(statement, {
|
||||
get(statementTarget, statementProperty) {
|
||||
const value = Reflect.get(
|
||||
statementTarget,
|
||||
statementProperty,
|
||||
statementTarget,
|
||||
);
|
||||
if (statementProperty === 'run') {
|
||||
return (...values) => {
|
||||
const result = value.apply(statementTarget, values);
|
||||
if (matches('afterRun', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return typeof value === 'function'
|
||||
? value.bind(statementTarget)
|
||||
: value;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runCrashScenario({
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
}) {
|
||||
const database = client(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(
|
||||
crashClient(database, pointName, markerPath),
|
||||
);
|
||||
const value = fixture(profile);
|
||||
await new LocalSqlitePluginPackageWorkflowAdmissionRepository(
|
||||
authority,
|
||||
).admit(executionPlan(value));
|
||||
throw new Error(`crash point ${pointName} was not reached`);
|
||||
}
|
||||
|
||||
async function verifyScenario({ databasePath, pointName, profile }) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
const value = fixture(profile);
|
||||
const plan = executionPlan(value);
|
||||
const database = client(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(database);
|
||||
try {
|
||||
const repository = new LocalSqlitePluginPackageWorkflowAdmissionRepository(
|
||||
authority,
|
||||
);
|
||||
const beforeRecovery = await repository.findByPlanId(plan.planId);
|
||||
if (point.durable !== (beforeRecovery !== null)) {
|
||||
throw new Error(`${profile}/${pointName} durability is inconsistent`);
|
||||
}
|
||||
const recovered = await repository.admit(plan);
|
||||
if (recovered.status !== (point.durable ? 'existing' : 'created')) {
|
||||
throw new Error(`${profile}/${pointName} replay status is inconsistent`);
|
||||
}
|
||||
const counts = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM "Runs") AS runs,
|
||||
(SELECT COUNT(*) FROM "StepRuns") AS steps,
|
||||
(SELECT COUNT(*) FROM "RunEvents") AS events,
|
||||
(SELECT COUNT(*) FROM "StepRunMutations") AS mutations,
|
||||
(SELECT COUNT(*)
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissions") AS admissions,
|
||||
(SELECT COUNT(*)
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissionSteps")
|
||||
AS admissionSteps`,
|
||||
)
|
||||
.get();
|
||||
if (
|
||||
counts.runs !== 1 ||
|
||||
counts.steps !== 2 ||
|
||||
counts.events !== 3 ||
|
||||
counts.mutations !== 2 ||
|
||||
counts.admissions !== 1 ||
|
||||
counts.admissionSteps !== 2
|
||||
) {
|
||||
throw new Error(`${profile}/${pointName} evidence is incomplete`);
|
||||
}
|
||||
await auditLocalSqliteReadiness(database);
|
||||
const integrity = database.prepare('PRAGMA integrity_check').get();
|
||||
const foreignKey = database
|
||||
.prepare('SELECT * FROM pragma_foreign_key_check LIMIT 1')
|
||||
.get();
|
||||
const journal = database.prepare('PRAGMA journal_mode').get();
|
||||
const synchronous = database.prepare('PRAGMA synchronous').get();
|
||||
return Object.freeze({
|
||||
profile,
|
||||
pointName,
|
||||
crashBeforeCommit: !point.durable,
|
||||
durableAfterCrash: point.durable,
|
||||
exactReplay:
|
||||
recovered.status === (point.durable ? 'existing' : 'created'),
|
||||
integrityCheck: Object.values(integrity)[0],
|
||||
foreignKeyCheck: foreignKey === undefined ? 'ok' : 'failed',
|
||||
journalMode: journal.journal_mode,
|
||||
synchronous: synchronous.synchronous,
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
function measuredClient(database, measurement) {
|
||||
let lockStartedAt;
|
||||
return new Proxy(database, {
|
||||
get(target, property) {
|
||||
if (property === 'exec') {
|
||||
return (sql) => {
|
||||
const normalized = sql.trim().toUpperCase();
|
||||
const result = target.exec(sql);
|
||||
if (normalized === 'BEGIN IMMEDIATE') {
|
||||
if (lockStartedAt !== undefined) {
|
||||
throw new Error('nested Workflow admission write lock');
|
||||
}
|
||||
measurement.beginImmediateCount += 1;
|
||||
lockStartedAt = performance.now();
|
||||
} else if (normalized === 'COMMIT') {
|
||||
if (lockStartedAt === undefined) {
|
||||
throw new Error(
|
||||
'Workflow admission committed without a write lock',
|
||||
);
|
||||
}
|
||||
measurement.commitCount += 1;
|
||||
measurement.lockDurationsMs.push(performance.now() - lockStartedAt);
|
||||
lockStartedAt = undefined;
|
||||
} else if (normalized === 'ROLLBACK') {
|
||||
measurement.rollbackCount += 1;
|
||||
lockStartedAt = undefined;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function measuredExecutionPlan(value, sequence) {
|
||||
const suffix = String(sequence).padStart(4, '0');
|
||||
return createPluginPackageWorkflowExecutionPlan({
|
||||
planId: `wfl-plan-${value.profile}-${suffix}`,
|
||||
runId: `wfl-run-${value.profile}-${suffix}`,
|
||||
workflowId: 'daily',
|
||||
stepRunIds: {
|
||||
collect: `wfl-collect-${value.profile}-${suffix}`,
|
||||
summarize: `wfl-summary-${value.profile}-${suffix}`,
|
||||
},
|
||||
publication: value.publication,
|
||||
revision: value.revision,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
plannedAtMs: 4_000 + sequence,
|
||||
});
|
||||
}
|
||||
|
||||
function rounded(value) {
|
||||
return Math.round(value * 1_000) / 1_000;
|
||||
}
|
||||
|
||||
function percentile(sortedValues, percentileValue) {
|
||||
const index = Math.min(
|
||||
sortedValues.length - 1,
|
||||
Math.ceil((percentileValue / 100) * sortedValues.length) - 1,
|
||||
);
|
||||
return sortedValues[Math.max(0, index)];
|
||||
}
|
||||
|
||||
async function measureWorkflowAdmissionTransactions({
|
||||
databasePath,
|
||||
profile,
|
||||
samples,
|
||||
}) {
|
||||
if (!Number.isSafeInteger(samples) || samples < 1 || samples > 1_000) {
|
||||
throw new RangeError('Workflow admission samples are out of range');
|
||||
}
|
||||
const value = fixture(profile);
|
||||
const database = client(databasePath);
|
||||
const measurement = {
|
||||
beginImmediateCount: 0,
|
||||
commitCount: 0,
|
||||
rollbackCount: 0,
|
||||
lockDurationsMs: [],
|
||||
};
|
||||
const authority = new LocalSqliteOperationAuthority(
|
||||
measuredClient(database, measurement),
|
||||
);
|
||||
try {
|
||||
const repository = new LocalSqlitePluginPackageWorkflowAdmissionRepository(
|
||||
authority,
|
||||
);
|
||||
for (let sequence = 1; sequence <= samples; sequence += 1) {
|
||||
const result = await repository.admit(
|
||||
measuredExecutionPlan(value, sequence),
|
||||
);
|
||||
if (result.status !== 'created') {
|
||||
throw new Error(
|
||||
`Workflow admission sample ${sequence} was not newly committed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const counts = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM "Runs") AS runs,
|
||||
(SELECT COUNT(*) FROM "StepRuns") AS steps,
|
||||
(SELECT COUNT(*)
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissions") AS admissions,
|
||||
(SELECT COUNT(*)
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissionSteps")
|
||||
AS admissionSteps`,
|
||||
)
|
||||
.get();
|
||||
if (
|
||||
counts.runs !== samples ||
|
||||
counts.steps !== samples * 2 ||
|
||||
counts.admissions !== samples ||
|
||||
counts.admissionSteps !== samples * 2
|
||||
) {
|
||||
throw new Error('Workflow admission measurement facts are incomplete');
|
||||
}
|
||||
await auditLocalSqliteReadiness(database);
|
||||
const integrity = database.prepare('PRAGMA integrity_check').get();
|
||||
const foreignKey = database
|
||||
.prepare('SELECT * FROM pragma_foreign_key_check LIMIT 1')
|
||||
.get();
|
||||
const journal = database.prepare('PRAGMA journal_mode').get();
|
||||
const synchronous = database.prepare('PRAGMA synchronous').get();
|
||||
const sorted = [...measurement.lockDurationsMs].sort(
|
||||
(left, right) => left - right,
|
||||
);
|
||||
return Object.freeze({
|
||||
profile,
|
||||
samples,
|
||||
beginImmediateCount: measurement.beginImmediateCount,
|
||||
commitCount: measurement.commitCount,
|
||||
rollbackCount: measurement.rollbackCount,
|
||||
oneWriteTransactionPerWorkflow:
|
||||
measurement.beginImmediateCount === samples &&
|
||||
measurement.commitCount === samples &&
|
||||
measurement.rollbackCount === 0,
|
||||
lockDurationMs: Object.freeze({
|
||||
p50: rounded(percentile(sorted, 50)),
|
||||
p95: rounded(percentile(sorted, 95)),
|
||||
p99: rounded(percentile(sorted, 99)),
|
||||
max: rounded(sorted.at(-1)),
|
||||
}),
|
||||
integrityCheck: Object.values(integrity)[0],
|
||||
foreignKeyCheck: foreignKey === undefined ? 'ok' : 'failed',
|
||||
journalMode: journal.journal_mode,
|
||||
synchronous: synchronous.synchronous,
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CRASH_POINTS,
|
||||
executionPlan,
|
||||
fixture,
|
||||
measureWorkflowAdmissionTransactions,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const [, , action, databasePath, markerPath, pointName, profile] =
|
||||
process.argv;
|
||||
if (action !== 'crash') {
|
||||
throw new Error('fixture action must be crash');
|
||||
}
|
||||
runCrashScenario({
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
}).catch((error) => {
|
||||
process.stderr.write(`${error.stack ?? error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Vendored
+407
@@ -0,0 +1,407 @@
|
||||
const fs = require('node:fs');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('../../dist/plugin-package/pluginPackageTaskReconciliationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowAdmissionRepository,
|
||||
} = require('../../dist/plugin-package/workflow/pluginPackageWorkflowAdmissionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
} = require('../../dist/plugin-package/workflow/pluginPackageWorkflowTaskAttemptAdmissionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository,
|
||||
} = require('../../dist/plugin-package/workflow/pluginPackageWorkflowCancellationConvergenceRepository');
|
||||
const { LocalSqliteRunRepository } = require('../../dist/run/runRepository');
|
||||
const {
|
||||
LocalSqliteWorkflowTaskExecutionRepository,
|
||||
} = require('../../dist/plugin-package/workflow/workflowTaskExecutionRepository');
|
||||
const { auditLocalSqliteReadiness } = require('../../dist/readiness/readiness');
|
||||
const {
|
||||
executionPlan,
|
||||
fixture,
|
||||
setupScenario: setupWorkflowAdmissionScenario,
|
||||
} = require('./pluginPackageWorkflowAdmissionCrashMatrixFixture.cjs');
|
||||
|
||||
const CRASH_POINTS = Object.freeze({
|
||||
after_conclusive_stop_before_begin: Object.freeze({
|
||||
timing: 'beforeExec',
|
||||
sql: 'BEGIN IMMEDIATE',
|
||||
durable: false,
|
||||
}),
|
||||
after_attempt_terminal: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'UPDATE "RunAttempts"',
|
||||
durable: false,
|
||||
}),
|
||||
after_run_cas: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'UPDATE "Runs"',
|
||||
durable: false,
|
||||
}),
|
||||
after_step_terminal: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'UPDATE "StepRuns"',
|
||||
durable: false,
|
||||
}),
|
||||
after_attempt_event: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "RunEvents"',
|
||||
durable: false,
|
||||
}),
|
||||
after_step_mutation: Object.freeze({
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "StepRunMutations"',
|
||||
durable: false,
|
||||
}),
|
||||
before_commit: Object.freeze({
|
||||
timing: 'beforeExec',
|
||||
sql: 'COMMIT',
|
||||
durable: false,
|
||||
}),
|
||||
after_commit: Object.freeze({
|
||||
timing: 'afterExec',
|
||||
sql: 'COMMIT',
|
||||
durable: true,
|
||||
}),
|
||||
});
|
||||
|
||||
function client(databasePath) {
|
||||
const database = new DatabaseSync(databasePath);
|
||||
database.exec('PRAGMA foreign_keys = ON');
|
||||
return database;
|
||||
}
|
||||
|
||||
function writeCrashMarker(markerPath, pointName) {
|
||||
const descriptor = fs.openSync(markerPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeSync(
|
||||
descriptor,
|
||||
JSON.stringify({
|
||||
schema:
|
||||
'qinglong/sqlite-plugin-package-workflow-task-control-crash-marker@v1',
|
||||
point: pointName,
|
||||
conclusiveStopObserved: true,
|
||||
pid: process.pid,
|
||||
}),
|
||||
);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function crashClient(database, pointName, markerPath) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
if (!point) throw new Error(`unknown crash point ${pointName}`);
|
||||
let triggered = false;
|
||||
const crash = () => {
|
||||
if (triggered) return;
|
||||
triggered = true;
|
||||
writeCrashMarker(markerPath, pointName);
|
||||
process.kill(process.pid, 'SIGKILL');
|
||||
throw new Error(`SIGKILL did not terminate ${pointName}`);
|
||||
};
|
||||
const matches = (timing, sql) =>
|
||||
!triggered && point.timing === timing && sql.trim().includes(point.sql);
|
||||
return new Proxy(database, {
|
||||
get(target, property) {
|
||||
if (property === 'exec') {
|
||||
return (sql) => {
|
||||
if (matches('beforeExec', sql)) crash();
|
||||
const result = target.exec(sql);
|
||||
if (matches('afterExec', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
if (property === 'prepare') {
|
||||
return (sql) => {
|
||||
const statement = target.prepare(sql);
|
||||
return new Proxy(statement, {
|
||||
get(statementTarget, statementProperty) {
|
||||
const value = Reflect.get(
|
||||
statementTarget,
|
||||
statementProperty,
|
||||
statementTarget,
|
||||
);
|
||||
if (statementProperty === 'run') {
|
||||
return (...values) => {
|
||||
const result = value.apply(statementTarget, values);
|
||||
if (matches('afterRun', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return typeof value === 'function'
|
||||
? value.bind(statementTarget)
|
||||
: value;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function command(run, attempt, pointName, profile) {
|
||||
const identity = createHash('sha256')
|
||||
.update(profile)
|
||||
.update('\0')
|
||||
.update(pointName)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
return {
|
||||
run,
|
||||
attempt,
|
||||
reason: 'user',
|
||||
terminalStatus: 'cancelled',
|
||||
errorCode: 'EXECUTION_CANCELLED',
|
||||
errorSummary: 'Execution was cancelled',
|
||||
finishedAtMs: Math.max(
|
||||
Date.now(),
|
||||
run.updatedAtMs ?? 0,
|
||||
attempt.startedAtMs ?? 0,
|
||||
),
|
||||
attemptEventId: `wfc-a-${identity}`,
|
||||
stepMutationId: `wfc-s-${identity}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupScenario({ databasePath, profile }) {
|
||||
await setupWorkflowAdmissionScenario({ databasePath, profile });
|
||||
const value = fixture(profile);
|
||||
const plan = executionPlan(value);
|
||||
const database = client(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(database);
|
||||
try {
|
||||
await new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
authority,
|
||||
value.registry,
|
||||
).reconcile(value.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return value.revision.generation;
|
||||
},
|
||||
});
|
||||
await new LocalSqlitePluginPackageWorkflowAdmissionRepository(
|
||||
authority,
|
||||
).admit(plan);
|
||||
const collect = plan.steps.find(({ stepKey }) => stepKey === 'collect');
|
||||
if (!collect) throw new Error('Workflow collect Step is missing');
|
||||
const admitted =
|
||||
await new LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository(
|
||||
authority,
|
||||
).admit(plan.runId, collect.stepRunId);
|
||||
const runs = new LocalSqliteRunRepository(database);
|
||||
const execution = new LocalSqliteWorkflowTaskExecutionRepository(authority);
|
||||
const callbackTokenHash = 'c'.repeat(64);
|
||||
const startingAtMs = admitted.receipt.admittedAtMs + 1;
|
||||
const runningAtMs = startingAtMs + 1;
|
||||
const prepared = await execution.prepare({
|
||||
runId: plan.runId,
|
||||
attemptId: admitted.receipt.attemptId,
|
||||
stepRunId: collect.stepRunId,
|
||||
callbackTokenHash,
|
||||
deadlineAtMs: startingAtMs + 60_000,
|
||||
logArtifactId: 'local-0123456789abcdef0123456789abcd',
|
||||
atMs: startingAtMs,
|
||||
eventId: `wfc-start-${profile}`,
|
||||
});
|
||||
if (prepared.status !== 'applied') {
|
||||
throw new Error('Workflow Task did not enter starting');
|
||||
}
|
||||
const run = await runs.findRunById(plan.runId);
|
||||
const attempt = await runs.findAttemptById(admitted.receipt.attemptId);
|
||||
if (!run || !attempt) {
|
||||
throw new Error('Workflow Task starting authority is missing');
|
||||
}
|
||||
const running = await execution.recordRunning({
|
||||
run,
|
||||
attempt,
|
||||
callbackTokenHash,
|
||||
executorHandle: `qlp:v1:workflow-control-${profile}`,
|
||||
pid: 321,
|
||||
startedAtMs: runningAtMs,
|
||||
attemptEventId: `wfc-running-a-${profile}`,
|
||||
stepMutationId: `wfc-running-s-${profile}`,
|
||||
});
|
||||
if (running.status !== 'applied') {
|
||||
throw new Error('Workflow Task did not enter running');
|
||||
}
|
||||
const cancellation = database
|
||||
.prepare(
|
||||
`UPDATE "Runs"
|
||||
SET cancel_requested_at_ms = ?,
|
||||
cancel_reason = 'user'
|
||||
WHERE id = ? AND status = 'running'
|
||||
AND cancel_requested_at_ms IS NULL`,
|
||||
)
|
||||
.run(runningAtMs + 1, plan.runId);
|
||||
if (cancellation.changes !== 1) {
|
||||
throw new Error('Workflow cancellation intent was not recorded');
|
||||
}
|
||||
return Object.freeze({
|
||||
runId: plan.runId,
|
||||
attemptId: admitted.receipt.attemptId,
|
||||
stepRunId: collect.stepRunId,
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runCrashScenario({
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
}) {
|
||||
const database = client(databasePath);
|
||||
const runs = new LocalSqliteRunRepository(database);
|
||||
const value = fixture(profile);
|
||||
const plan = executionPlan(value);
|
||||
const run = await runs.findRunById(plan.runId);
|
||||
const attempt = await runs.findLatestAttemptByRunId(plan.runId);
|
||||
if (!run || !attempt) {
|
||||
throw new Error('Workflow Task control authority is missing');
|
||||
}
|
||||
const authority = new LocalSqliteOperationAuthority(
|
||||
crashClient(database, pointName, markerPath),
|
||||
);
|
||||
await new LocalSqliteWorkflowTaskExecutionRepository(
|
||||
authority,
|
||||
).recordControlTerminal(command(run, attempt, pointName, profile));
|
||||
throw new Error(`crash point ${pointName} was not reached`);
|
||||
}
|
||||
|
||||
async function verifyScenario({ databasePath, pointName, profile }) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
if (!point) throw new Error(`unknown crash point ${pointName}`);
|
||||
const database = client(databasePath);
|
||||
const authority = new LocalSqliteOperationAuthority(database);
|
||||
try {
|
||||
const value = fixture(profile);
|
||||
const plan = executionPlan(value);
|
||||
const runs = new LocalSqliteRunRepository(database);
|
||||
const execution = new LocalSqliteWorkflowTaskExecutionRepository(authority);
|
||||
let run = await runs.findRunById(plan.runId);
|
||||
let attempt = await runs.findLatestAttemptByRunId(plan.runId);
|
||||
if (!run || !attempt) {
|
||||
throw new Error('Workflow Task recovery authority is missing');
|
||||
}
|
||||
if (point.durable !== (attempt.status === 'cancelled')) {
|
||||
throw new Error(`${profile}/${pointName} durability is inconsistent`);
|
||||
}
|
||||
const recovered = await execution.recordControlTerminal(
|
||||
command(run, attempt, pointName, profile),
|
||||
);
|
||||
if (recovered !== (point.durable ? 'already_terminal' : 'terminal')) {
|
||||
throw new Error(`${profile}/${pointName} replay is inconsistent`);
|
||||
}
|
||||
const cancellation =
|
||||
new LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository(
|
||||
authority,
|
||||
);
|
||||
const converged = await cancellation.convergePage({ limit: 8 });
|
||||
if (
|
||||
converged.settledRuns !== 1 ||
|
||||
converged.settledAttempts !== 0 ||
|
||||
converged.blocked !== 0
|
||||
) {
|
||||
throw new Error(`${profile}/${pointName} parent did not converge`);
|
||||
}
|
||||
run = await runs.findRunById(plan.runId);
|
||||
attempt = await runs.findLatestAttemptByRunId(plan.runId);
|
||||
const facts = database
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM "RunAttempts"
|
||||
WHERE run_id = ?) AS attempts,
|
||||
(SELECT COUNT(*) FROM "RunEvents"
|
||||
WHERE run_id = ? AND type =
|
||||
'workflow.task_attempt.cancelled') AS attemptEvents,
|
||||
(SELECT COUNT(*) FROM "RunEvents"
|
||||
WHERE run_id = ? AND type = 'step.cancelled') AS stepEvents,
|
||||
(SELECT COUNT(*) FROM "RunEvents"
|
||||
WHERE run_id = ? AND type =
|
||||
'workflow.cancelled') AS workflowEvents,
|
||||
(SELECT COUNT(*) FROM "StepRuns"
|
||||
WHERE run_id = ? AND status = 'cancelled') AS cancelledSteps`,
|
||||
)
|
||||
.get(plan.runId, plan.runId, plan.runId, plan.runId, plan.runId);
|
||||
if (
|
||||
run?.status !== 'cancelled' ||
|
||||
run.version !== run.eventSequence ||
|
||||
attempt?.status !== 'cancelled' ||
|
||||
facts.attempts !== 1 ||
|
||||
facts.attemptEvents !== 1 ||
|
||||
facts.stepEvents !== 2 ||
|
||||
facts.workflowEvents !== 1 ||
|
||||
facts.cancelledSteps !== 2
|
||||
) {
|
||||
throw new Error(`${profile}/${pointName} terminal facts are incomplete`);
|
||||
}
|
||||
const replay = await execution.recordControlTerminal(
|
||||
command(run, attempt, pointName, profile),
|
||||
);
|
||||
if (replay !== 'already_terminal') {
|
||||
throw new Error(`${profile}/${pointName} terminal replay drifted`);
|
||||
}
|
||||
const empty = await cancellation.convergePage({ limit: 8 });
|
||||
if (
|
||||
empty.scanned !== 0 ||
|
||||
empty.settledRuns !== 0 ||
|
||||
empty.settledAttempts !== 0
|
||||
) {
|
||||
throw new Error(`${profile}/${pointName} cancellation replay drifted`);
|
||||
}
|
||||
await auditLocalSqliteReadiness(database);
|
||||
const integrity = database.prepare('PRAGMA integrity_check').get();
|
||||
const foreignKey = database
|
||||
.prepare('SELECT * FROM pragma_foreign_key_check LIMIT 1')
|
||||
.get();
|
||||
const journal = database.prepare('PRAGMA journal_mode').get();
|
||||
return Object.freeze({
|
||||
profile,
|
||||
pointName,
|
||||
crashAfterConclusiveStop: true,
|
||||
crashBeforeCommit: !point.durable,
|
||||
durableAfterCrash: point.durable,
|
||||
exactTerminalReplay: replay === 'already_terminal',
|
||||
parentConverged: run.status === 'cancelled',
|
||||
integrityCheck: Object.values(integrity)[0],
|
||||
foreignKeyCheck: foreignKey === undefined ? 'ok' : 'failed',
|
||||
journalMode: journal.journal_mode,
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CRASH_POINTS,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const [, , action, databasePath, markerPath, pointName, profile] =
|
||||
process.argv;
|
||||
if (action !== 'crash') {
|
||||
throw new Error('fixture action must be crash');
|
||||
}
|
||||
runCrashScenario({
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
}).catch((error) => {
|
||||
process.stderr.write(`${error.stack ?? error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionRegistry,
|
||||
} = require('@qinglong/runtime-core/project-tool-definition-snapshot');
|
||||
const {
|
||||
createStepRunMutation,
|
||||
transitionStepRunMutation,
|
||||
} = require('@qinglong/runtime-core/step-run');
|
||||
const {
|
||||
createToolExecutionCompletionCommand,
|
||||
createToolExecutionResultArtifact,
|
||||
toolExecutionResultKeyBinding,
|
||||
} = require('@qinglong/runtime-core/tool-execution-completion');
|
||||
const {
|
||||
createToolExecutionEvidenceBundle,
|
||||
toolExecutionAdmissionEvidence,
|
||||
TOOL_EXECUTION_START_AUDIT_OPERATION,
|
||||
} = require('@qinglong/runtime-core/tool-execution-evidence');
|
||||
const {
|
||||
createToolExecutionStartCommand,
|
||||
} = require('@qinglong/runtime-core/tool-execution-start-barrier');
|
||||
const {
|
||||
TrustedToolHandlerBindingRegistry,
|
||||
admitTrustedToolExecution,
|
||||
createTrustedToolHandlerBinding,
|
||||
createTrustedToolInvocationPlan,
|
||||
trustedToolContractIdentityDigest,
|
||||
} = require('@qinglong/runtime-core/trusted-tool-invocation');
|
||||
const {
|
||||
TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
} = require('@qinglong/runtime-core/trusted-tool-execution');
|
||||
const {
|
||||
prepareToolInvocation,
|
||||
} = require('@qinglong/runtime-core/tool-registry');
|
||||
const {
|
||||
createToolResultKeyCatalogBootstrapCommand,
|
||||
createToolResultKeyRetirementCommand,
|
||||
createToolResultKeyRotationCommand,
|
||||
requireActiveToolResultKey,
|
||||
toolResultKeyCatalogFence,
|
||||
toolResultKeyMaterialProof,
|
||||
} = require('@qinglong/runtime-core/tool-result-key-catalog');
|
||||
const {
|
||||
createToolExecutionResultRekeyCommand,
|
||||
createToolResultKeyRetirementReceiptCommand,
|
||||
} = require('@qinglong/runtime-core/tool-result-rekey');
|
||||
|
||||
const { openLocalSqliteClient } = require('../../dist/storage/config');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('../../dist/migration/migration');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqliteStepRunRepository,
|
||||
} = require('../../dist/run/stepRunRepository');
|
||||
const {
|
||||
LocalSqliteToolExecutionCompletionRepository,
|
||||
} = require('../../dist/tool-execution/toolExecutionCompletionRepository');
|
||||
const {
|
||||
LocalSqliteToolExecutionStartBarrierRepository,
|
||||
} = require('../../dist/tool-execution/toolExecutionStartBarrierRepository');
|
||||
const {
|
||||
LocalSqliteToolInvocationArtifactRepository,
|
||||
} = require('../../dist/tool-execution/toolInvocationArtifactRepository');
|
||||
const {
|
||||
LocalSqliteToolResultKeyCatalogRepository,
|
||||
} = require('../../dist/tool-execution/toolResultKeyCatalogRepository');
|
||||
const {
|
||||
LocalSqliteToolResultRekeyRepository,
|
||||
} = require('../../dist/tool-execution/toolResultRekeyRepository');
|
||||
|
||||
const PROJECT_ID = 'crash-tool-result-project';
|
||||
const RUN_ID = 'crash-tool-result-run';
|
||||
const STEP_RUN_ID = 'crash-tool-result-step';
|
||||
const START_ID = 'crash-tool-result-start';
|
||||
const RESULT_KEY_A_ID = 'crash-result-key-a';
|
||||
const RESULT_KEY_B_ID = 'crash-result-key-b';
|
||||
const RESULT_KEY_A = Buffer.alloc(32, 41);
|
||||
const RESULT_KEY_B = Buffer.alloc(32, 42);
|
||||
const INVOCATION_KEY = Buffer.alloc(32, 43);
|
||||
const SUBJECT = Object.freeze({
|
||||
type: 'user',
|
||||
id: 'usr-crash-tool-result',
|
||||
});
|
||||
const POLICY_FENCE = Object.freeze({
|
||||
projectVersion: 1,
|
||||
bindingVersion: 1,
|
||||
});
|
||||
const TOOL = Object.freeze({
|
||||
name: 'crash.result.read',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const OUTPUT = Object.freeze({
|
||||
summary: 'SQLite crash matrix durable result',
|
||||
});
|
||||
const OUTPUT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-output-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const RESULT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-result-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const CRASH_POINTS = Object.freeze({
|
||||
completion_before_begin: Object.freeze({
|
||||
operation: 'completion',
|
||||
timing: 'beforeExec',
|
||||
sql: 'BEGIN IMMEDIATE',
|
||||
durable: false,
|
||||
}),
|
||||
completion_after_binding: Object.freeze({
|
||||
operation: 'completion',
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "ToolExecutionResultKeyBindings"',
|
||||
durable: false,
|
||||
}),
|
||||
completion_after_commit: Object.freeze({
|
||||
operation: 'completion',
|
||||
timing: 'afterExec',
|
||||
sql: 'COMMIT',
|
||||
durable: true,
|
||||
}),
|
||||
rekey_after_overlay: Object.freeze({
|
||||
operation: 'rekey',
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "ToolExecutionResultRekeyOverlays"',
|
||||
durable: false,
|
||||
}),
|
||||
rekey_after_head: Object.freeze({
|
||||
operation: 'rekey',
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "ToolExecutionResultRekeyHeads"',
|
||||
durable: false,
|
||||
}),
|
||||
rekey_after_commit: Object.freeze({
|
||||
operation: 'rekey',
|
||||
timing: 'afterExec',
|
||||
sql: 'COMMIT',
|
||||
durable: true,
|
||||
}),
|
||||
receipt_after_insert: Object.freeze({
|
||||
operation: 'receipt',
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "ToolResultKeyRetirementReceipts"',
|
||||
durable: false,
|
||||
}),
|
||||
receipt_after_commit: Object.freeze({
|
||||
operation: 'receipt',
|
||||
timing: 'afterExec',
|
||||
sql: 'COMMIT',
|
||||
durable: true,
|
||||
}),
|
||||
retire_after_insert: Object.freeze({
|
||||
operation: 'retire',
|
||||
timing: 'afterRun',
|
||||
sql: 'INSERT INTO "ToolResultKeyCatalogGenerations"',
|
||||
durable: false,
|
||||
}),
|
||||
retire_after_commit: Object.freeze({
|
||||
operation: 'retire',
|
||||
timing: 'afterExec',
|
||||
sql: 'COMMIT',
|
||||
durable: true,
|
||||
}),
|
||||
});
|
||||
|
||||
function hash(domain, value) {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: 'install-crash-tool-result',
|
||||
projectId: PROJECT_ID,
|
||||
packageName: 'crash',
|
||||
lockDigest: 'a'.repeat(64),
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
resources: [],
|
||||
});
|
||||
return createProjectToolDefinitionSnapshot({
|
||||
projectId: PROJECT_ID,
|
||||
contributions: [
|
||||
{
|
||||
generation,
|
||||
revisionDigest: 'c'.repeat(64),
|
||||
definitions: [
|
||||
{
|
||||
...TOOL,
|
||||
description: 'Read one SQLite crash matrix fixture',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
runId: { type: 'string', minLength: 1, maxLength: 64 },
|
||||
},
|
||||
required: ['runId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string', maxLength: 1024 },
|
||||
},
|
||||
required: ['summary'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
effect: 'read',
|
||||
risk: 'low',
|
||||
requiredPermissions: ['run.read'],
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function principal() {
|
||||
return Object.freeze({
|
||||
subject: SUBJECT,
|
||||
authenticationId: 'auth-crash-tool-result',
|
||||
authenticatedAtMs: 800,
|
||||
expiresAtMs: 10_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
}
|
||||
|
||||
function authorizer() {
|
||||
return Object.freeze({
|
||||
async authorize() {
|
||||
return Object.freeze({
|
||||
effect: 'allow',
|
||||
reasons: Object.freeze(['role_grant']),
|
||||
fence: POLICY_FENCE,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bindingRegistry(definitionSnapshot) {
|
||||
const binding = createTrustedToolHandlerBinding(definitionSnapshot, {
|
||||
tool: TOOL,
|
||||
adapter: {
|
||||
id: 'builtin.crash-result-read',
|
||||
version: '1.0.0',
|
||||
},
|
||||
executionClass: 'builtin_in_process',
|
||||
profiles: ['edge', 'standalone'],
|
||||
authorities: ['database.read'],
|
||||
timeoutSeconds: 20,
|
||||
redactionContract: {
|
||||
id: 'redaction.crash-result-read',
|
||||
version: '1.0.0',
|
||||
},
|
||||
auditContract: {
|
||||
id: 'audit.tool-call',
|
||||
version: '1.0.0',
|
||||
},
|
||||
});
|
||||
return Object.freeze({
|
||||
binding,
|
||||
bindings: new TrustedToolHandlerBindingRegistry(definitionSnapshot, [
|
||||
binding,
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
function openClient(databasePath, profile) {
|
||||
return openLocalSqliteClient(
|
||||
{
|
||||
databasePath,
|
||||
profile,
|
||||
busyTimeoutMs: 5_000,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareCompletionFixture(client, authority, profile) {
|
||||
client.exec(`
|
||||
INSERT INTO "QingLong3Projects" (
|
||||
id, name, slug, status, version, created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
'${PROJECT_ID}', 'Crash Tool Result', 'crash-tool-result',
|
||||
'active', 1, 1, 1
|
||||
);
|
||||
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 (
|
||||
'${RUN_ID}', '${PROJECT_ID}', 'crash-tool-result-task', 'v1',
|
||||
'manual', 'manual', 'runtime', 'running', 0, 0, 0, 1
|
||||
);
|
||||
`);
|
||||
const definitionSnapshot = snapshot();
|
||||
const { binding, bindings } = bindingRegistry(definitionSnapshot);
|
||||
const stepRuns = new LocalSqliteStepRunRepository(authority);
|
||||
const creation = createStepRunMutation(
|
||||
{
|
||||
id: STEP_RUN_ID,
|
||||
runId: RUN_ID,
|
||||
stepKey: 'workflow.crash-result-read',
|
||||
kind: 'tool',
|
||||
definitionRef: `tool:${TOOL.name}@${TOOL.version}`,
|
||||
definitionDigest: definitionSnapshot.definitions[0].definitionDigest,
|
||||
required: true,
|
||||
initialStatus: 'ready',
|
||||
inputRef: 'artifact:crash-tool-result-input',
|
||||
mutationId: 'crash-tool-result-create',
|
||||
createdAtMs: 1_000,
|
||||
},
|
||||
{
|
||||
expectedRunVersion: 0,
|
||||
expectedRunEventSequence: 0,
|
||||
eventId: '51000000-0000-4000-8000-000000000001',
|
||||
dedupeKey: 'crash-tool-result:create',
|
||||
actor: SUBJECT,
|
||||
},
|
||||
);
|
||||
assert.equal((await stepRuns.apply(creation)).status, 'applied');
|
||||
|
||||
const invocation = await prepareToolInvocation(
|
||||
projectToolDefinitionRegistry(definitionSnapshot),
|
||||
{
|
||||
projectId: PROJECT_ID,
|
||||
principal: principal(),
|
||||
nowMs: 900,
|
||||
tool: TOOL,
|
||||
input: { runId: RUN_ID },
|
||||
},
|
||||
authorizer(),
|
||||
);
|
||||
const planBundle = createTrustedToolInvocationPlan(bindings, invocation, {
|
||||
actionRef: `tool-plan:${RUN_ID}`,
|
||||
inputArtifactId: 'crash-tool-result-input-artifact',
|
||||
previewArtifactId: 'crash-tool-result-preview-artifact',
|
||||
artifactKeyId: 'crash-tool-invocation-key',
|
||||
artifactKey: INVOCATION_KEY,
|
||||
artifactNonce: Buffer.alloc(12, 44),
|
||||
profile,
|
||||
preview: {
|
||||
title: 'SQLite Crash Tool Result',
|
||||
summary: 'Creates one encrypted crash-matrix completion',
|
||||
fields: [
|
||||
{
|
||||
kind: 'identifier',
|
||||
label: 'Run',
|
||||
value: RUN_ID,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
sealedAtMs: 1_100,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await new LocalSqliteToolInvocationArtifactRepository(authority).put(
|
||||
planBundle.inputArtifact,
|
||||
planBundle.previewArtifact,
|
||||
),
|
||||
{ status: 'inserted' },
|
||||
);
|
||||
const startedAtMs = 1_200;
|
||||
const evidence = createToolExecutionEvidenceBundle({
|
||||
traceId: '1'.repeat(32),
|
||||
spanId: '2'.repeat(16),
|
||||
projectId: PROJECT_ID,
|
||||
runId: RUN_ID,
|
||||
stepRunId: STEP_RUN_ID,
|
||||
invocationPlanDigest: planBundle.plan.planDigest,
|
||||
bindingDigest: binding.bindingDigest,
|
||||
adapterDigest: trustedToolContractIdentityDigest(binding.adapter),
|
||||
redactionContractDigest: trustedToolContractIdentityDigest(
|
||||
binding.redactionContract,
|
||||
),
|
||||
auditContractDigest: trustedToolContractIdentityDigest(
|
||||
binding.auditContract,
|
||||
),
|
||||
audit: {
|
||||
eventId: '41000000-0000-4000-8000-000000000001',
|
||||
requestId: 'crash-tool-result-request',
|
||||
operationId: TOOL_EXECUTION_START_AUDIT_OPERATION,
|
||||
projectId: PROJECT_ID,
|
||||
subject: SUBJECT,
|
||||
authenticationId: 'auth-crash-tool-result',
|
||||
outcome: 'allowed',
|
||||
reasons: ['tool_execution_start'],
|
||||
fence: POLICY_FENCE,
|
||||
occurredAtMs: startedAtMs,
|
||||
},
|
||||
createdAtMs: startedAtMs,
|
||||
});
|
||||
const admission = await admitTrustedToolExecution(
|
||||
bindings,
|
||||
planBundle.plan,
|
||||
{
|
||||
principal: principal(),
|
||||
profile,
|
||||
nowMs: startedAtMs,
|
||||
authorizer: authorizer(),
|
||||
evidence: {
|
||||
stepRun: {
|
||||
id: creation.stepRun.id,
|
||||
version: creation.stepRun.version,
|
||||
digest: creation.stepRun.stepRunDigest,
|
||||
},
|
||||
...toolExecutionAdmissionEvidence(evidence),
|
||||
},
|
||||
},
|
||||
);
|
||||
const runningMutation = transitionStepRunMutation(
|
||||
creation.stepRun,
|
||||
{
|
||||
expectedVersion: creation.stepRun.version,
|
||||
expectedDigest: creation.stepRun.stepRunDigest,
|
||||
mutationId: 'crash-tool-result-running',
|
||||
to: 'running',
|
||||
atMs: startedAtMs,
|
||||
},
|
||||
{
|
||||
expectedRunVersion: 1,
|
||||
expectedRunEventSequence: 1,
|
||||
eventId: '51000000-0000-4000-8000-000000000002',
|
||||
dedupeKey: 'crash-tool-result:running',
|
||||
actor: SUBJECT,
|
||||
},
|
||||
);
|
||||
const start = await new LocalSqliteToolExecutionStartBarrierRepository(
|
||||
authority,
|
||||
).prepare(
|
||||
createToolExecutionStartCommand({
|
||||
startId: START_ID,
|
||||
admission,
|
||||
evidence,
|
||||
stepRunMutation: runningMutation,
|
||||
}),
|
||||
);
|
||||
assert.equal(start.status, 'created');
|
||||
|
||||
const catalogRepository =
|
||||
new LocalSqliteToolResultKeyCatalogRepository(authority);
|
||||
const catalog = await catalogRepository.append(
|
||||
createToolResultKeyCatalogBootstrapCommand({
|
||||
keyId: RESULT_KEY_A_ID,
|
||||
materialProof: toolResultKeyMaterialProof(
|
||||
RESULT_KEY_A_ID,
|
||||
RESULT_KEY_A,
|
||||
),
|
||||
mutationId: 'crash-result-key-bootstrap-a',
|
||||
}),
|
||||
);
|
||||
const executionResultUnsigned = Object.freeze({
|
||||
schema: TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
startId: START_ID,
|
||||
barrierDigest: start.barrier.barrierDigest,
|
||||
adapterDigest: start.barrier.adapterDigest,
|
||||
output: OUTPUT,
|
||||
outputDigest: hash(OUTPUT_DIGEST_DOMAIN, OUTPUT),
|
||||
completedAtMs: 1_300,
|
||||
});
|
||||
const executionResult = Object.freeze({
|
||||
...executionResultUnsigned,
|
||||
resultDigest: hash(RESULT_DIGEST_DOMAIN, executionResultUnsigned),
|
||||
});
|
||||
const resultArtifact = createToolExecutionResultArtifact(
|
||||
{
|
||||
artifactId: 'crash-tool-result-output-artifact',
|
||||
projectId: PROJECT_ID,
|
||||
runId: RUN_ID,
|
||||
stepRunId: STEP_RUN_ID,
|
||||
tool: TOOL,
|
||||
executionResult,
|
||||
keyId: RESULT_KEY_A_ID,
|
||||
key: RESULT_KEY_A,
|
||||
},
|
||||
projectToolDefinitionRegistry(definitionSnapshot),
|
||||
() => Buffer.alloc(12, 45),
|
||||
);
|
||||
const running = await stepRuns.findById(STEP_RUN_ID);
|
||||
assert.ok(running);
|
||||
const succeededMutation = transitionStepRunMutation(
|
||||
running,
|
||||
{
|
||||
expectedVersion: running.version,
|
||||
expectedDigest: running.stepRunDigest,
|
||||
mutationId: 'crash-tool-result-succeeded',
|
||||
to: 'succeeded',
|
||||
atMs: executionResult.completedAtMs,
|
||||
outputRef: resultArtifact.artifactId,
|
||||
},
|
||||
{
|
||||
expectedRunVersion: 2,
|
||||
expectedRunEventSequence: 2,
|
||||
eventId: '51000000-0000-4000-8000-000000000003',
|
||||
dedupeKey: 'crash-tool-result:succeeded',
|
||||
actor: SUBJECT,
|
||||
},
|
||||
);
|
||||
return Object.freeze({
|
||||
catalogRepository,
|
||||
definitionSnapshot,
|
||||
completionCommand: createToolExecutionCompletionCommand({
|
||||
barrier: start.barrier,
|
||||
executionResult,
|
||||
resultArtifact,
|
||||
resultKeyCatalogFence: toolResultKeyCatalogFence(
|
||||
catalog.catalog,
|
||||
requireActiveToolResultKey(catalog.catalog),
|
||||
),
|
||||
stepRunMutation: succeededMutation,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function setupScenario({
|
||||
databasePath,
|
||||
statePath,
|
||||
profile,
|
||||
operation,
|
||||
}) {
|
||||
const client = openClient(databasePath, profile);
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
try {
|
||||
const prepared = await prepareCompletionFixture(
|
||||
client,
|
||||
authority,
|
||||
profile,
|
||||
);
|
||||
const state = {
|
||||
profile,
|
||||
operation,
|
||||
completionCommand: prepared.completionCommand,
|
||||
};
|
||||
if (operation !== 'completion') {
|
||||
const completed =
|
||||
await new LocalSqliteToolExecutionCompletionRepository(
|
||||
authority,
|
||||
).commit(prepared.completionCommand);
|
||||
assert.equal(completed.status, 'created');
|
||||
const rotated = await prepared.catalogRepository.append(
|
||||
createToolResultKeyRotationCommand(
|
||||
await prepared.catalogRepository.findCurrent(),
|
||||
{
|
||||
keyId: RESULT_KEY_B_ID,
|
||||
materialProof: toolResultKeyMaterialProof(
|
||||
RESULT_KEY_B_ID,
|
||||
RESULT_KEY_B,
|
||||
),
|
||||
mutationId: 'crash-result-key-rotate-b',
|
||||
},
|
||||
),
|
||||
);
|
||||
const rekeyCommand = createToolExecutionResultRekeyCommand({
|
||||
artifact: prepared.completionCommand.resultArtifact,
|
||||
binding: toolExecutionResultKeyBinding(
|
||||
prepared.completionCommand,
|
||||
),
|
||||
previousOverlay: null,
|
||||
overlayId: 'crash-tool-result-rekey-overlay',
|
||||
mutationId: 'crash-tool-result-rekey',
|
||||
targetCatalogFence: toolResultKeyCatalogFence(
|
||||
rotated.catalog,
|
||||
requireActiveToolResultKey(rotated.catalog),
|
||||
),
|
||||
targetKey: RESULT_KEY_B,
|
||||
output: OUTPUT,
|
||||
rekeyedAtMs: 1_400,
|
||||
registry: projectToolDefinitionRegistry(
|
||||
prepared.definitionSnapshot,
|
||||
),
|
||||
nonceFactory: () => Buffer.alloc(12, 46),
|
||||
});
|
||||
state.rekeyCommand = rekeyCommand;
|
||||
state.receiptCommand =
|
||||
createToolResultKeyRetirementReceiptCommand({
|
||||
expectedCatalogGeneration: rotated.catalog.generation,
|
||||
expectedCatalogDigest: rotated.catalog.catalogDigest,
|
||||
keyId: RESULT_KEY_A_ID,
|
||||
mutationId: 'crash-tool-result-retirement-receipt',
|
||||
});
|
||||
if (operation !== 'rekey') {
|
||||
const rekeyed =
|
||||
await new LocalSqliteToolResultRekeyRepository(
|
||||
authority,
|
||||
).append(rekeyCommand);
|
||||
assert.equal(rekeyed.status, 'created');
|
||||
}
|
||||
if (operation === 'retire') {
|
||||
const receipt =
|
||||
await new LocalSqliteToolResultRekeyRepository(
|
||||
authority,
|
||||
).create(state.receiptCommand);
|
||||
assert.equal(receipt.status, 'created');
|
||||
state.retireCommand = createToolResultKeyRetirementCommand(
|
||||
rotated.catalog,
|
||||
{
|
||||
keyId: RESULT_KEY_A_ID,
|
||||
retirementReceiptDigest: receipt.receipt.receiptDigest,
|
||||
mutationId: 'crash-result-key-retire-a',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(statePath, JSON.stringify(state), {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
function writeCrashMarker(markerPath, pointName) {
|
||||
const file = fs.openSync(markerPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
schema: 'qinglong/sqlite-tool-result-crash-marker@v1',
|
||||
point: pointName,
|
||||
pid: process.pid,
|
||||
}),
|
||||
);
|
||||
fs.fsyncSync(file);
|
||||
} finally {
|
||||
fs.closeSync(file);
|
||||
}
|
||||
}
|
||||
|
||||
function crashClient(client, pointName, markerPath) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
if (!point) throw new Error(`unknown crash point ${pointName}`);
|
||||
let triggered = false;
|
||||
const crash = () => {
|
||||
if (triggered) return;
|
||||
triggered = true;
|
||||
writeCrashMarker(markerPath, pointName);
|
||||
process.kill(process.pid, 'SIGKILL');
|
||||
throw new Error(`SIGKILL did not terminate ${pointName}`);
|
||||
};
|
||||
const matches = (timing, sql) =>
|
||||
!triggered &&
|
||||
point.timing === timing &&
|
||||
sql.trim().includes(point.sql);
|
||||
return new Proxy(client, {
|
||||
get(target, property) {
|
||||
if (property === 'exec') {
|
||||
return (sql) => {
|
||||
if (matches('beforeExec', sql)) crash();
|
||||
const result = target.exec(sql);
|
||||
if (matches('afterExec', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
if (property === 'prepare') {
|
||||
return (sql) => {
|
||||
const statement = target.prepare(sql);
|
||||
return new Proxy(statement, {
|
||||
get(statementTarget, statementProperty) {
|
||||
const value = Reflect.get(
|
||||
statementTarget,
|
||||
statementProperty,
|
||||
statementTarget,
|
||||
);
|
||||
if (statementProperty === 'run') {
|
||||
return (...values) => {
|
||||
const result = value.apply(statementTarget, values);
|
||||
if (matches('afterRun', sql)) crash();
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return typeof value === 'function'
|
||||
? value.bind(statementTarget)
|
||||
: value;
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runCrashScenario({
|
||||
databasePath,
|
||||
statePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
}) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
if (!point) throw new Error(`unknown crash point ${pointName}`);
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
if (state.operation !== point.operation) {
|
||||
throw new Error(
|
||||
`crash point ${pointName} does not match ${state.operation}`,
|
||||
);
|
||||
}
|
||||
const client = openClient(databasePath, state.profile);
|
||||
const authority = new LocalSqliteOperationAuthority(
|
||||
crashClient(client, pointName, markerPath),
|
||||
);
|
||||
if (point.operation === 'completion') {
|
||||
await new LocalSqliteToolExecutionCompletionRepository(
|
||||
authority,
|
||||
).commit(state.completionCommand);
|
||||
} else if (point.operation === 'rekey') {
|
||||
await new LocalSqliteToolResultRekeyRepository(authority).append(
|
||||
state.rekeyCommand,
|
||||
);
|
||||
} else if (point.operation === 'receipt') {
|
||||
await new LocalSqliteToolResultRekeyRepository(authority).create(
|
||||
state.receiptCommand,
|
||||
);
|
||||
} else {
|
||||
await new LocalSqliteToolResultKeyCatalogRepository(authority).append(
|
||||
state.retireCommand,
|
||||
);
|
||||
}
|
||||
throw new Error(`crash point ${pointName} was not reached`);
|
||||
}
|
||||
|
||||
function completionFacts(client) {
|
||||
return {
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT
|
||||
step.status AS "stepStatus",
|
||||
step.version AS "stepVersion",
|
||||
run.version AS "runVersion",
|
||||
run.event_sequence AS "runEventSequence",
|
||||
(SELECT count(*) FROM "ToolExecutionCompletions"
|
||||
WHERE start_id = ?) AS "completionCount",
|
||||
(SELECT count(*) FROM "ToolExecutionResultKeyBindings"
|
||||
WHERE start_id = ?) AS "bindingCount",
|
||||
(SELECT count(*) FROM "StepRunMutations"
|
||||
WHERE mutation_id = 'crash-tool-result-succeeded')
|
||||
AS "completionMutationCount",
|
||||
(SELECT count(*) FROM "RunEvents"
|
||||
WHERE id = '51000000-0000-4000-8000-000000000003')
|
||||
AS "completionEventCount"
|
||||
FROM "StepRuns" AS step
|
||||
JOIN "Runs" AS run ON run.id = step.run_id
|
||||
WHERE step.id = ? AND run.id = ?`,
|
||||
)
|
||||
.get(START_ID, START_ID, STEP_RUN_ID, RUN_ID),
|
||||
};
|
||||
}
|
||||
|
||||
function rekeyFacts(client) {
|
||||
return {
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM "ToolExecutionResultRekeyOverlays"
|
||||
WHERE overlay_id = 'crash-tool-result-rekey-overlay')
|
||||
AS "overlayCount",
|
||||
(SELECT count(*) FROM "ToolExecutionResultRekeyHeads"
|
||||
WHERE artifact_id = 'crash-tool-result-output-artifact')
|
||||
AS "headCount"`,
|
||||
)
|
||||
.get(),
|
||||
};
|
||||
}
|
||||
|
||||
function receiptCount(client) {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "ToolResultKeyRetirementReceipts"
|
||||
WHERE mutation_id = 'crash-tool-result-retirement-receipt'`,
|
||||
)
|
||||
.get().count;
|
||||
}
|
||||
|
||||
async function verifyScenario({
|
||||
databasePath,
|
||||
statePath,
|
||||
pointName,
|
||||
}) {
|
||||
const point = CRASH_POINTS[pointName];
|
||||
if (!point) throw new Error(`unknown crash point ${pointName}`);
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
const client = openClient(databasePath, state.profile);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
try {
|
||||
assert.equal(
|
||||
client.prepare('PRAGMA integrity_check').get().integrity_check,
|
||||
'ok',
|
||||
);
|
||||
assert.equal(
|
||||
client.prepare('PRAGMA journal_mode').get().journal_mode,
|
||||
state.profile === 'edge' ? 'delete' : 'wal',
|
||||
);
|
||||
let replayStatus;
|
||||
if (point.operation === 'completion') {
|
||||
assert.deepEqual(
|
||||
completionFacts(client),
|
||||
point.durable
|
||||
? {
|
||||
stepStatus: 'succeeded',
|
||||
stepVersion: 3,
|
||||
runVersion: 3,
|
||||
runEventSequence: 3,
|
||||
completionCount: 1,
|
||||
bindingCount: 1,
|
||||
completionMutationCount: 1,
|
||||
completionEventCount: 1,
|
||||
}
|
||||
: {
|
||||
stepStatus: 'running',
|
||||
stepVersion: 2,
|
||||
runVersion: 2,
|
||||
runEventSequence: 2,
|
||||
completionCount: 0,
|
||||
bindingCount: 0,
|
||||
completionMutationCount: 0,
|
||||
completionEventCount: 0,
|
||||
},
|
||||
);
|
||||
const repository =
|
||||
new LocalSqliteToolExecutionCompletionRepository(authority);
|
||||
replayStatus = (
|
||||
await repository.commit(state.completionCommand)
|
||||
).status;
|
||||
assert.equal(
|
||||
(
|
||||
await repository.commit(state.completionCommand)
|
||||
).status,
|
||||
'existing',
|
||||
);
|
||||
assert.deepEqual(completionFacts(client), {
|
||||
stepStatus: 'succeeded',
|
||||
stepVersion: 3,
|
||||
runVersion: 3,
|
||||
runEventSequence: 3,
|
||||
completionCount: 1,
|
||||
bindingCount: 1,
|
||||
completionMutationCount: 1,
|
||||
completionEventCount: 1,
|
||||
});
|
||||
} else if (point.operation === 'rekey') {
|
||||
assert.deepEqual(
|
||||
rekeyFacts(client),
|
||||
point.durable
|
||||
? { overlayCount: 1, headCount: 1 }
|
||||
: { overlayCount: 0, headCount: 0 },
|
||||
);
|
||||
const repository =
|
||||
new LocalSqliteToolResultRekeyRepository(authority);
|
||||
replayStatus = (
|
||||
await repository.append(state.rekeyCommand)
|
||||
).status;
|
||||
assert.equal(
|
||||
(await repository.append(state.rekeyCommand)).status,
|
||||
'existing',
|
||||
);
|
||||
assert.deepEqual(rekeyFacts(client), {
|
||||
overlayCount: 1,
|
||||
headCount: 1,
|
||||
});
|
||||
} else if (point.operation === 'receipt') {
|
||||
assert.equal(receiptCount(client), point.durable ? 1 : 0);
|
||||
const repository =
|
||||
new LocalSqliteToolResultRekeyRepository(authority);
|
||||
replayStatus = (
|
||||
await repository.create(state.receiptCommand)
|
||||
).status;
|
||||
assert.equal(
|
||||
(await repository.create(state.receiptCommand)).status,
|
||||
'existing',
|
||||
);
|
||||
assert.equal(receiptCount(client), 1);
|
||||
} else {
|
||||
const catalogRepository =
|
||||
new LocalSqliteToolResultKeyCatalogRepository(authority);
|
||||
const before = await catalogRepository.findCurrent();
|
||||
assert.ok(before);
|
||||
assert.equal(before.generation, point.durable ? 3 : 2);
|
||||
assert.equal(
|
||||
before.keys.find((entry) => entry.keyId === RESULT_KEY_A_ID)
|
||||
.state,
|
||||
point.durable ? 'retired' : 'decrypt_only',
|
||||
);
|
||||
replayStatus = (
|
||||
await catalogRepository.append(state.retireCommand)
|
||||
).status;
|
||||
assert.equal(
|
||||
(
|
||||
await catalogRepository.append(state.retireCommand)
|
||||
).status,
|
||||
'existing',
|
||||
);
|
||||
const after = await catalogRepository.findCurrent();
|
||||
assert.ok(after);
|
||||
assert.equal(after.generation, 3);
|
||||
assert.equal(
|
||||
after.keys.find((entry) => entry.keyId === RESULT_KEY_A_ID)
|
||||
.state,
|
||||
'retired',
|
||||
);
|
||||
}
|
||||
assert.equal(replayStatus, point.durable ? 'existing' : 'created');
|
||||
return Object.freeze({
|
||||
point: pointName,
|
||||
operation: point.operation,
|
||||
crashBeforeCommit: !point.durable,
|
||||
durableAfterCrash: point.durable,
|
||||
replayStatus,
|
||||
integrityCheck: 'ok',
|
||||
journalMode: state.profile === 'edge' ? 'delete' : 'wal',
|
||||
});
|
||||
} finally {
|
||||
await authority.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const [, , action, databasePath, statePath, markerPath, pointName] =
|
||||
process.argv;
|
||||
if (action !== 'crash') {
|
||||
throw new Error('fixture action must be crash');
|
||||
}
|
||||
runCrashScenario({
|
||||
databasePath,
|
||||
statePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
}).catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.stack : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CRASH_POINTS,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
createLocalExecutionContextRecipe,
|
||||
createLocalTaskExecutionRevision,
|
||||
} = require('@qinglong/runtime-core/local-dispatch');
|
||||
const {
|
||||
RunRepositoryConstraintError,
|
||||
} = require('@qinglong/runtime-core/run-repository');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-local-dispatch-store-'),
|
||||
);
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(async () => {
|
||||
await runtime.close();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function run(id, priority, queuedAtMs, cancelled = false) {
|
||||
return {
|
||||
id: `run-${id}`,
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'queued',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority,
|
||||
createdAtMs: 1,
|
||||
queuedAtMs,
|
||||
...(cancelled ? { cancelRequestedAtMs: 2, cancelReason: 'user' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function attempt(id, createdAtMs) {
|
||||
return {
|
||||
id: `attempt-${id}`,
|
||||
runId: `run-${id}`,
|
||||
attempt: 1,
|
||||
status: 'claimed',
|
||||
executorType: 'local_process',
|
||||
callbackSequence: 0,
|
||||
createdAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
test('append-only definitions replay by content and reject identity conflicts', async (t) => {
|
||||
const runtime = await fixture(t);
|
||||
const recipe = createLocalExecutionContextRecipe({
|
||||
environment: [{ name: 'VALUE', kind: 'public', value: 'one' }],
|
||||
createdAtMs: 1,
|
||||
});
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalExecutionContextRecipe(recipe),
|
||||
'inserted',
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalExecutionContextRecipe({
|
||||
...recipe,
|
||||
createdAtMs: 2,
|
||||
}),
|
||||
'existing',
|
||||
);
|
||||
const revision = createLocalTaskExecutionRevision({
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 'revision-1',
|
||||
executorType: 'local_process',
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['one'] },
|
||||
contextRef: recipe.contextRef,
|
||||
createdAtMs: 1,
|
||||
});
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalTaskExecutionRevision(revision),
|
||||
'inserted',
|
||||
);
|
||||
assert.equal(
|
||||
await runtime.localDispatch.appendLocalTaskExecutionRevision({
|
||||
...revision,
|
||||
createdAtMs: 2,
|
||||
}),
|
||||
'existing',
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.localDispatch.appendLocalTaskExecutionRevision(
|
||||
createLocalTaskExecutionRevision({
|
||||
...revision,
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['different'] },
|
||||
}),
|
||||
),
|
||||
RunRepositoryConstraintError,
|
||||
);
|
||||
});
|
||||
|
||||
test('candidate pages are bounded, ordered and exclude cancellation intent', async (t) => {
|
||||
const runtime = await fixture(t);
|
||||
await runtime.runRepository.transaction(async (transaction) => {
|
||||
for (const value of [
|
||||
['a', 10, 2, 2, false],
|
||||
['b', 10, 1, 3, false],
|
||||
['c', 5, 1, 1, false],
|
||||
['cancelled', 100, 1, 1, true],
|
||||
]) {
|
||||
await transaction.insertRun(run(value[0], value[1], value[2], value[4]));
|
||||
await transaction.insertAttempt(attempt(value[0], value[3]));
|
||||
}
|
||||
});
|
||||
const first = await runtime.localDispatch.listLocalDispatchCandidates({
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
first.candidates.map(({ runId }) => runId),
|
||||
['run-b', 'run-a'],
|
||||
);
|
||||
assert.equal(first.truncated, true);
|
||||
const last = first.candidates.at(-1);
|
||||
const second = await runtime.localDispatch.listLocalDispatchCandidates({
|
||||
limit: 2,
|
||||
after: {
|
||||
priority: last.priority,
|
||||
queuedAtMs: last.queuedAtMs,
|
||||
attemptCreatedAtMs: last.attemptCreatedAtMs,
|
||||
attemptId: last.attemptId,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
second.candidates.map(({ runId }) => runId),
|
||||
['run-c'],
|
||||
);
|
||||
assert.equal(second.truncated, false);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
bootstrapLocalProfileStorage,
|
||||
} = require('../dist/profile/localProfile');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-local-profile-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'qinglong3.sqlite');
|
||||
}
|
||||
|
||||
test('disabled local Profile does not inspect or create the database path', async () => {
|
||||
const records = [];
|
||||
const databasePath = path.join(
|
||||
os.tmpdir(),
|
||||
'ql3-local-profile-parent-does-not-exist',
|
||||
'database.sqlite',
|
||||
);
|
||||
const result = await bootstrapLocalProfileStorage({
|
||||
enabled: false,
|
||||
profile: 'edge',
|
||||
databasePath,
|
||||
audit: (record) => records.push(record),
|
||||
});
|
||||
assert.equal(result.status, 'disabled');
|
||||
assert.equal(await result.stop(), 'stopped');
|
||||
assert.equal(fs.existsSync(databasePath), false);
|
||||
assert.deepEqual(records, [{ profile: 'edge', state: 'disabled' }]);
|
||||
});
|
||||
|
||||
test('enabled local Profile owns one ready repository and idempotent stop', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const records = [];
|
||||
const result = await bootstrapLocalProfileStorage({
|
||||
enabled: true,
|
||||
profile: 'edge',
|
||||
databasePath,
|
||||
audit: (record) => records.push(record),
|
||||
});
|
||||
assert.equal(result.status, 'storage_ready');
|
||||
assert.equal(result.profile, 'edge');
|
||||
assert.equal(result.evidence.journalMode, 'delete');
|
||||
assert.equal(await result.runs.findRunById('missing'), null);
|
||||
assert.deepEqual(await result.startupRecovery.inspectCandidates(), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
assert.deepEqual(await Promise.all([result.stop(), result.stop()]), [
|
||||
'stopped',
|
||||
'stopped',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
records.map(({ state }) => state),
|
||||
['storage_ready', 'stopped'],
|
||||
);
|
||||
});
|
||||
|
||||
test('unprepared storage fails closed without auto-migration', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
new (require('node:sqlite').DatabaseSync)(databasePath).close();
|
||||
const records = [];
|
||||
await assert.rejects(
|
||||
bootstrapLocalProfileStorage({
|
||||
enabled: true,
|
||||
profile: 'standalone',
|
||||
databasePath,
|
||||
audit: (record) => records.push(record),
|
||||
}),
|
||||
/not ready/,
|
||||
);
|
||||
assert.deepEqual(records, [{ profile: 'standalone', state: 'failed' }]);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
bootstrapEdgeStorage,
|
||||
} = require('@qinglong/local-sqlite/profile/edge');
|
||||
const {
|
||||
bootstrapStandaloneStorage,
|
||||
} = require('@qinglong/local-sqlite/profile/standalone');
|
||||
|
||||
for (const [profile, bootstrap] of [
|
||||
['edge', bootstrapEdgeStorage],
|
||||
['standalone', bootstrapStandaloneStorage],
|
||||
]) {
|
||||
test(`${profile} subpath fixes the Profile and leaves disabled storage untouched`, async () => {
|
||||
const records = [];
|
||||
const databasePath = path.join(
|
||||
os.tmpdir(),
|
||||
`ql3-${profile}-missing`,
|
||||
'db.sqlite',
|
||||
);
|
||||
const result = await bootstrap({
|
||||
enabled: false,
|
||||
databasePath,
|
||||
audit: (record) => records.push(record),
|
||||
});
|
||||
assert.equal(result.profile, profile);
|
||||
assert.equal(result.status, 'disabled');
|
||||
assert.equal(fs.existsSync(databasePath), false);
|
||||
assert.deepEqual(records, [{ profile, state: 'disabled' }]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
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 {
|
||||
REVOKED_API_CREDENTIAL_DIGEST,
|
||||
} = require('@qinglong/runtime-core/api-credential-administration');
|
||||
const {
|
||||
LocalOwnerCredentialRecoveryInProgressError,
|
||||
LocalOwnerCredentialRecoveryMutationConflictError,
|
||||
LocalOwnerCredentialRecoveryNotAcknowledgedError,
|
||||
} = require('@qinglong/runtime-core/local-owner-credential-recovery');
|
||||
const { openLocalSqliteBootstrapDatabase } = require('../dist/storage/bootstrap');
|
||||
const { migrateLocalSqlitePath } = require('../dist/migration/migration');
|
||||
|
||||
const SUBJECT_ID = `usr_${'a'.repeat(22)}`;
|
||||
const PREVIOUS_CREDENTIAL_ID = `own_${'b'.repeat(22)}`;
|
||||
const PEPPER_KEY_ID = 'owner-key-1';
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-owner-recovery-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'qinglong3.sqlite');
|
||||
}
|
||||
|
||||
async function prepared(t) {
|
||||
const databasePath = fixture(t);
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3IdentitySubjects" (
|
||||
subject_type, subject_id, status, version, created_at_ms, updated_at_ms
|
||||
) VALUES ('user', ?, 'active', 1, 100, 100)`,
|
||||
)
|
||||
.run(SUBJECT_ID);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
||||
pepper_key_id, material_digest, backup_digest, state, version,
|
||||
register_mutation_id, activate_mutation_id, registered_at_ms,
|
||||
activated_at_ms
|
||||
) VALUES (?, ?, ?, 'active', 2, ?, ?, 90, 95)`,
|
||||
)
|
||||
.run(
|
||||
PEPPER_KEY_ID,
|
||||
'1'.repeat(64),
|
||||
'2'.repeat(64),
|
||||
'00000000-0000-4000-8000-000000000091',
|
||||
'00000000-0000-4000-8000-000000000092',
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
||||
generation, mutation_id, expected_generation, previous_pepper_key_id,
|
||||
active_pepper_key_id, material_digest, backup_digest, activated_at_ms
|
||||
) VALUES (1, ?, 0, NULL, ?, ?, ?, 95)`,
|
||||
)
|
||||
.run(
|
||||
'00000000-0000-4000-8000-000000000092',
|
||||
PEPPER_KEY_ID,
|
||||
'1'.repeat(64),
|
||||
'2'.repeat(64),
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentials" (
|
||||
credential_id, version, state, subject_type, subject_id,
|
||||
secret_digest, created_at_ms, not_before_at_ms, expires_at_ms
|
||||
) VALUES (?, 1, 'active', 'user', ?, ?, 100, 100, 100000)`,
|
||||
)
|
||||
.run(PREVIOUS_CREDENTIAL_ID, SUBJECT_ID, '3'.repeat(64));
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
credential_id, credential_version, pepper_key_id
|
||||
) VALUES (?, 1, ?)`,
|
||||
)
|
||||
.run(PREVIOUS_CREDENTIAL_ID, PEPPER_KEY_ID);
|
||||
client.close();
|
||||
return { options, databasePath };
|
||||
}
|
||||
|
||||
function audit(eventId, requestId, operationId, occurredAtMs) {
|
||||
return {
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId: null,
|
||||
subject: { type: 'system', id: 'owner-credential-recovery' },
|
||||
authenticationId: 'local-owner-console',
|
||||
outcome: 'allowed',
|
||||
reasons: ['credential_recovery'],
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function issueCommand(index = 1) {
|
||||
const mutationId = `00000000-0000-4000-8000-0000000001${String(
|
||||
index,
|
||||
).padStart(2, '0')}`;
|
||||
const requestId = `recover-issue-${index}`;
|
||||
const replacementCredential = {
|
||||
credentialId: `own_${String(index).repeat(22)}`,
|
||||
version: 1,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
state: 'active',
|
||||
subject: { type: 'user', id: SUBJECT_ID },
|
||||
subjectStatus: 'active',
|
||||
secretDigest: String(index).repeat(64),
|
||||
createdAtMs: 1000 + index,
|
||||
notBeforeAtMs: 1000 + index,
|
||||
expiresAtMs: 10000 + index,
|
||||
};
|
||||
return {
|
||||
mutationId,
|
||||
requestId,
|
||||
previousCredentialId: PREVIOUS_CREDENTIAL_ID,
|
||||
expectedPreviousVersion: 1,
|
||||
replacementCredential,
|
||||
mutation: {
|
||||
mutationId,
|
||||
operation: 'issue',
|
||||
credentialId: replacementCredential.credentialId,
|
||||
credentialVersion: 1,
|
||||
expectedPreviousVersion: 0,
|
||||
changedBy: { type: 'system', id: 'owner-credential-recovery' },
|
||||
createdAtMs: replacementCredential.createdAtMs,
|
||||
},
|
||||
audit: audit(
|
||||
mutationId,
|
||||
requestId,
|
||||
'credential.issue',
|
||||
replacementCredential.createdAtMs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function acknowledgement(command, overrides = {}) {
|
||||
return {
|
||||
issueMutationId: command.mutationId,
|
||||
requestId: command.requestId,
|
||||
credentialId: command.replacementCredential.credentialId,
|
||||
factDigest: command.replacementCredential.secretDigest,
|
||||
deliveryDigest: 'd'.repeat(64),
|
||||
acknowledgedAtMs: 1100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function completeCommand(issue, index = 1) {
|
||||
const mutationId = `00000000-0000-4000-8000-0000000002${String(
|
||||
index,
|
||||
).padStart(2, '0')}`;
|
||||
const requestId = `recover-complete-${index}`;
|
||||
const revokedCredential = {
|
||||
credentialId: PREVIOUS_CREDENTIAL_ID,
|
||||
version: 2,
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
state: 'revoked',
|
||||
subject: { type: 'user', id: SUBJECT_ID },
|
||||
subjectStatus: 'active',
|
||||
secretDigest: REVOKED_API_CREDENTIAL_DIGEST,
|
||||
createdAtMs: 1200,
|
||||
notBeforeAtMs: 1200,
|
||||
expiresAtMs: 1201,
|
||||
};
|
||||
return {
|
||||
issueMutationId: issue.mutationId,
|
||||
mutationId,
|
||||
requestId,
|
||||
expectedPreviousVersion: 1,
|
||||
revokedCredential,
|
||||
mutation: {
|
||||
mutationId,
|
||||
operation: 'revoke',
|
||||
credentialId: PREVIOUS_CREDENTIAL_ID,
|
||||
credentialVersion: 2,
|
||||
expectedPreviousVersion: 1,
|
||||
changedBy: { type: 'system', id: 'owner-credential-recovery' },
|
||||
createdAtMs: 1200,
|
||||
},
|
||||
audit: audit(mutationId, requestId, 'credential.revoke', 1200),
|
||||
};
|
||||
}
|
||||
|
||||
test('keeps the old credential active until exact delivery acknowledgement', async (t) => {
|
||||
const { options } = await prepared(t);
|
||||
const database = await openLocalSqliteBootstrapDatabase(options);
|
||||
t.after(() => database.close());
|
||||
const issue = issueCommand();
|
||||
|
||||
assert.equal(
|
||||
(await database.ownerCredentialRecovery.issue(issue)).status,
|
||||
'inserted',
|
||||
);
|
||||
assert.equal(
|
||||
(await database.ownerCredentialRecovery.issue(issue)).status,
|
||||
'existing',
|
||||
);
|
||||
assert.equal(
|
||||
(await database.apiCredentials.resolve(PREVIOUS_CREDENTIAL_ID)).state,
|
||||
'active',
|
||||
);
|
||||
await assert.rejects(
|
||||
database.ownerCredentialRecovery.complete(completeCommand(issue)),
|
||||
LocalOwnerCredentialRecoveryNotAcknowledgedError,
|
||||
);
|
||||
assert.equal(
|
||||
(await database.apiCredentials.resolve(PREVIOUS_CREDENTIAL_ID)).state,
|
||||
'active',
|
||||
);
|
||||
|
||||
const ack = acknowledgement(issue);
|
||||
assert.equal(
|
||||
(await database.ownerCredentialRecovery.acknowledge(ack)).recovery.state,
|
||||
'acknowledged',
|
||||
);
|
||||
assert.equal(
|
||||
(await database.ownerCredentialRecovery.acknowledge(ack)).status,
|
||||
'existing',
|
||||
);
|
||||
assert.equal(
|
||||
(await database.apiCredentials.resolve(PREVIOUS_CREDENTIAL_ID)).state,
|
||||
'active',
|
||||
);
|
||||
|
||||
const completed = await database.ownerCredentialRecovery.complete(
|
||||
completeCommand(issue),
|
||||
);
|
||||
assert.equal(completed.recovery.state, 'completed');
|
||||
assert.equal(
|
||||
(await database.apiCredentials.resolve(PREVIOUS_CREDENTIAL_ID)).state,
|
||||
'revoked',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await database.apiCredentials.resolve(
|
||||
issue.replacementCredential.credentialId,
|
||||
)
|
||||
).state,
|
||||
'active',
|
||||
);
|
||||
assert.equal(
|
||||
(await database.ownerPepper.inspectReferences(PEPPER_KEY_ID, 1300))
|
||||
.currentCredentialReferences,
|
||||
1,
|
||||
);
|
||||
await database.ownerPepper.register({
|
||||
mutationId: '00000000-0000-4000-8000-000000000301',
|
||||
pepperKeyId: 'owner-key-2',
|
||||
materialDigest: '4'.repeat(64),
|
||||
backupDigest: '5'.repeat(64),
|
||||
registeredAtMs: 1301,
|
||||
});
|
||||
await database.ownerPepper.activate({
|
||||
mutationId: '00000000-0000-4000-8000-000000000302',
|
||||
pepperKeyId: 'owner-key-2',
|
||||
expectedGeneration: 1,
|
||||
expectedActivePepperKeyId: PEPPER_KEY_ID,
|
||||
activatedAtMs: 1302,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await database.ownerPepper.inspectReferences(PEPPER_KEY_ID, 20000),
|
||||
{
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
inspectedAtMs: 20000,
|
||||
currentCredentialReferences: 0,
|
||||
inFlightRecoveryReferences: 0,
|
||||
historicalCredentialReferences: 3,
|
||||
runtimeReferencesClear: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('serializes concurrent recovery and fails closed on acknowledgement drift', async (t) => {
|
||||
const { options } = await prepared(t);
|
||||
const first = await openLocalSqliteBootstrapDatabase(options);
|
||||
const second = await openLocalSqliteBootstrapDatabase(options);
|
||||
t.after(() => Promise.all([first.close(), second.close()]));
|
||||
const candidates = [issueCommand(1), issueCommand(2)];
|
||||
const settled = await Promise.allSettled([
|
||||
first.ownerCredentialRecovery.issue(candidates[0]),
|
||||
second.ownerCredentialRecovery.issue(candidates[1]),
|
||||
]);
|
||||
assert.equal(
|
||||
settled.filter(({ status }) => status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.ok(
|
||||
settled.find(({ status }) => status === 'rejected').reason instanceof
|
||||
LocalOwnerCredentialRecoveryInProgressError,
|
||||
);
|
||||
const winnerIndex = settled.findIndex(({ status }) => status === 'fulfilled');
|
||||
const winner = candidates[winnerIndex];
|
||||
await assert.rejects(
|
||||
first.ownerCredentialRecovery.acknowledge(
|
||||
acknowledgement(winner, {
|
||||
deliveryDigest: 'e'.repeat(64),
|
||||
factDigest: 'f'.repeat(64),
|
||||
}),
|
||||
),
|
||||
LocalOwnerCredentialRecoveryMutationConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
(await first.apiCredentials.resolve(PREVIOUS_CREDENTIAL_ID)).state,
|
||||
'active',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps future active credentials as runtime pepper references', async (t) => {
|
||||
const { options, databasePath } = await prepared(t);
|
||||
const futureCredentialId = `own_${'f'.repeat(22)}`;
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentials" (
|
||||
credential_id, version, state, subject_type, subject_id,
|
||||
secret_digest, created_at_ms, not_before_at_ms, expires_at_ms
|
||||
) VALUES (?, 1, 'active', 'user', ?, ?, 200, 300000, 400000)`,
|
||||
)
|
||||
.run(futureCredentialId, SUBJECT_ID, '6'.repeat(64));
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
credential_id, credential_version, pepper_key_id
|
||||
) VALUES (?, 1, ?)`,
|
||||
)
|
||||
.run(futureCredentialId, PEPPER_KEY_ID);
|
||||
client.close();
|
||||
|
||||
const database = await openLocalSqliteBootstrapDatabase(options);
|
||||
t.after(() => database.close());
|
||||
await database.ownerPepper.register({
|
||||
mutationId: '00000000-0000-4000-8000-000000000401',
|
||||
pepperKeyId: 'owner-key-2',
|
||||
materialDigest: '7'.repeat(64),
|
||||
backupDigest: '8'.repeat(64),
|
||||
registeredAtMs: 300,
|
||||
});
|
||||
await database.ownerPepper.activate({
|
||||
mutationId: '00000000-0000-4000-8000-000000000402',
|
||||
pepperKeyId: 'owner-key-2',
|
||||
expectedGeneration: 1,
|
||||
expectedActivePepperKeyId: PEPPER_KEY_ID,
|
||||
activatedAtMs: 301,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await database.ownerPepper.inspectReferences(PEPPER_KEY_ID, 200000),
|
||||
{
|
||||
pepperKeyId: PEPPER_KEY_ID,
|
||||
inspectedAtMs: 200000,
|
||||
currentCredentialReferences: 1,
|
||||
inFlightRecoveryReferences: 0,
|
||||
historicalCredentialReferences: 2,
|
||||
runtimeReferencesClear: false,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
(await database.ownerPepper.inspectReferences(PEPPER_KEY_ID, 500000))
|
||||
.runtimeReferencesClear,
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
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 {
|
||||
LocalOwnerDeliveryAcknowledgementGcMutationConflictError,
|
||||
LocalOwnerDeliveryAcknowledgementGcReferenceConflictError,
|
||||
MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS,
|
||||
MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS,
|
||||
} = require('@qinglong/runtime-core/local-owner-delivery-acknowledgement-gc');
|
||||
const {
|
||||
openLocalSqliteAcknowledgementGcDatabase,
|
||||
} = require('../dist/maintenance/acknowledgementGc');
|
||||
const { openLocalSqliteBootstrapDatabase } = require('../dist/storage/bootstrap');
|
||||
const { migrateLocalSqlitePath } = require('../dist/migration/migration');
|
||||
|
||||
const NOW = 1_760_000_000_000;
|
||||
const CREDENTIAL_TTL_MS = 600_000;
|
||||
const SUBJECT_ID = `usr_${Buffer.alloc(16, 31).toString('base64url')}`;
|
||||
const CREDENTIAL_ID = `own_${Buffer.alloc(16, 32).toString('base64url')}`;
|
||||
const ACK_MUTATION_ID = '00000000-0000-4000-8000-000000000d01';
|
||||
const DELIVERY_DIGEST = 'd'.repeat(64);
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-ack-gc-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return {
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
profile: 'edge',
|
||||
};
|
||||
}
|
||||
|
||||
function issuer() {
|
||||
return {
|
||||
subject: { type: 'system', id: 'owner-bootstrap' },
|
||||
authenticationId: 'local-console-test',
|
||||
authenticatedAtMs: NOW - 1_000,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
assurance: 'local_console',
|
||||
};
|
||||
}
|
||||
|
||||
function gcCommand(compactedAtMs, overrides = {}) {
|
||||
const mutationId = overrides.mutationId ?? randomUUID();
|
||||
const requestId = overrides.requestId ?? `ack-gc-${mutationId}`;
|
||||
return {
|
||||
mutationId,
|
||||
requestId,
|
||||
acknowledgementMutationId: ACK_MUTATION_ID,
|
||||
expectedKind: 'credential',
|
||||
expectedDeliveryDigest: DELIVERY_DIGEST,
|
||||
bridgeClearEvidence: {
|
||||
kind: 'credential',
|
||||
acknowledgementMutationId: ACK_MUTATION_ID,
|
||||
inspectedAtMs: compactedAtMs,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
},
|
||||
retentionPolicy: {
|
||||
version: 1,
|
||||
replayRetentionMs: MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_REPLAY_RETENTION_MS,
|
||||
auditRetentionMs: MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS,
|
||||
},
|
||||
compactedAtMs,
|
||||
audit: {
|
||||
eventId: mutationId,
|
||||
requestId,
|
||||
operationId: 'owner.delivery_acknowledgement.gc',
|
||||
projectId: null,
|
||||
subject: { type: 'system', id: 'owner-acknowledgement-gc' },
|
||||
authenticationId: 'local-owner-console',
|
||||
outcome: 'allowed',
|
||||
reasons: ['delivery_acknowledgement_gc'],
|
||||
fence: null,
|
||||
occurredAtMs: compactedAtMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readyAcknowledgement(t) {
|
||||
const options = fixture(t);
|
||||
await migrateLocalSqlitePath(options);
|
||||
const database = await openLocalSqliteBootstrapDatabase(options);
|
||||
await database.ownerPepper.register({
|
||||
mutationId: '00000000-0000-4000-8000-000000000d91',
|
||||
pepperKeyId: 'legacy-v1',
|
||||
materialDigest: 'a'.repeat(64),
|
||||
backupDigest: 'b'.repeat(64),
|
||||
registeredAtMs: NOW - 2_000,
|
||||
});
|
||||
await database.ownerPepper.activate({
|
||||
mutationId: '00000000-0000-4000-8000-000000000d92',
|
||||
pepperKeyId: 'legacy-v1',
|
||||
expectedGeneration: 0,
|
||||
activatedAtMs: NOW - 1_000,
|
||||
});
|
||||
await database.ownerBootstrap.provision({
|
||||
mutationId: ACK_MUTATION_ID,
|
||||
requestId: 'provision-d01',
|
||||
identity: {
|
||||
subject: { type: 'user', id: SUBJECT_ID },
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: NOW,
|
||||
updatedAtMs: NOW,
|
||||
},
|
||||
credential: {
|
||||
credentialId: CREDENTIAL_ID,
|
||||
version: 1,
|
||||
pepperKeyId: 'legacy-v1',
|
||||
state: 'active',
|
||||
subject: { type: 'user', id: SUBJECT_ID },
|
||||
subjectStatus: 'active',
|
||||
secretDigest: 'c'.repeat(64),
|
||||
createdAtMs: NOW,
|
||||
notBeforeAtMs: NOW,
|
||||
expiresAtMs: NOW + CREDENTIAL_TTL_MS,
|
||||
},
|
||||
issuer: issuer(),
|
||||
audit: {
|
||||
eventId: ACK_MUTATION_ID,
|
||||
requestId: 'provision-d01',
|
||||
operationId: 'identity.bootstrap_provision',
|
||||
projectId: null,
|
||||
subject: issuer().subject,
|
||||
authenticationId: issuer().authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['local_console_provisioning'],
|
||||
fence: null,
|
||||
occurredAtMs: NOW,
|
||||
},
|
||||
createdAtMs: NOW,
|
||||
});
|
||||
const acknowledgement = {
|
||||
kind: 'credential',
|
||||
mutationId: ACK_MUTATION_ID,
|
||||
requestId: 'provision-d01',
|
||||
subjectId: SUBJECT_ID,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
factDigest: 'c'.repeat(64),
|
||||
deliveryDigest: DELIVERY_DIGEST,
|
||||
ttlMs: CREDENTIAL_TTL_MS,
|
||||
acknowledgedAtMs: NOW + 1,
|
||||
};
|
||||
await database.ownerBootstrap.recordDeliveryAcknowledgement(acknowledgement);
|
||||
await database.close();
|
||||
return { options, acknowledgement };
|
||||
}
|
||||
|
||||
test('compacts one expired acknowledgement and reconstructs exact replay', async (t) => {
|
||||
const { options, acknowledgement } = await readyAcknowledgement(t);
|
||||
const compactedAtMs =
|
||||
NOW + MIN_LOCAL_OWNER_ACKNOWLEDGEMENT_AUDIT_RETENTION_MS + 1_000;
|
||||
const first = await openLocalSqliteAcknowledgementGcDatabase(options);
|
||||
const second = await openLocalSqliteAcknowledgementGcDatabase(options);
|
||||
t.after(() => Promise.all([first.close(), second.close()]));
|
||||
const command = gcCommand(compactedAtMs);
|
||||
const results = await Promise.all([
|
||||
first.acknowledgementGc.compact(command),
|
||||
second.acknowledgementGc.compact(command),
|
||||
]);
|
||||
assert.deepEqual(results.map((result) => result.status).sort(), [
|
||||
'existing',
|
||||
'inserted',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
await first.ownerBootstrap.resolveDeliveryAcknowledgement(ACK_MUTATION_ID),
|
||||
acknowledgement,
|
||||
);
|
||||
assert.equal(
|
||||
(await first.acknowledgementGc.resolveByAcknowledgement(ACK_MUTATION_ID))
|
||||
.acknowledgementSemanticDigest.length,
|
||||
64,
|
||||
);
|
||||
const client = new DatabaseSync(options.databasePath, { readOnly: true });
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3LocalOwnerDeliveryAcknowledgements"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3LocalOwnerDeliveryAcknowledgementGc"`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
client.close();
|
||||
await assert.rejects(
|
||||
first.acknowledgementGc.compact(
|
||||
gcCommand(compactedAtMs, { mutationId: randomUUID() }),
|
||||
),
|
||||
LocalOwnerDeliveryAcknowledgementGcMutationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects compaction while the credential is still active', async (t) => {
|
||||
const { options } = await readyAcknowledgement(t);
|
||||
const database = await openLocalSqliteAcknowledgementGcDatabase(options);
|
||||
t.after(() => database.close());
|
||||
await assert.rejects(
|
||||
database.acknowledgementGc.compact(gcCommand(NOW + 2)),
|
||||
LocalOwnerDeliveryAcknowledgementGcReferenceConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
await database.acknowledgementGc.resolveByAcknowledgement(ACK_MUTATION_ID),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await database.ownerBootstrap.resolveDeliveryAcknowledgement(
|
||||
ACK_MUTATION_ID,
|
||||
)
|
||||
).deliveryDigest,
|
||||
DELIVERY_DIGEST,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
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 {
|
||||
LocalOwnerPepperMaterialGcInProgressError,
|
||||
LocalOwnerPepperMaterialGcMutationConflictError,
|
||||
LocalOwnerPepperMaterialGcReferenceConflictError,
|
||||
LocalOwnerPepperMaterialGcRetentionPendingError,
|
||||
MIN_LOCAL_OWNER_PEPPER_ACK_RETENTION_MS,
|
||||
MIN_LOCAL_OWNER_PEPPER_AUDIT_RETENTION_MS,
|
||||
MIN_LOCAL_OWNER_PEPPER_BACKUP_RETENTION_MS,
|
||||
} = require('@qinglong/runtime-core/local-owner-pepper-material-gc');
|
||||
const { openLocalSqlitePepperGcDatabase } = require('../dist/maintenance/pepperGc');
|
||||
const { migrateLocalSqlitePath } = require('../dist/migration/migration');
|
||||
|
||||
const RETIRED_KEY_ID = 'owner-key-retired';
|
||||
const ACTIVE_KEY_ID = 'owner-key-active';
|
||||
const RETIRED_DIGEST = '1'.repeat(64);
|
||||
const ACTIVE_DIGEST = '2'.repeat(64);
|
||||
const PREPARED_AT_MS = 3_000_000_000;
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-pepper-gc-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'qinglong3.sqlite');
|
||||
}
|
||||
|
||||
async function preparedDatabase(t, futureReference = false) {
|
||||
const databasePath = fixture(t);
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
||||
pepper_key_id, material_digest, backup_digest, state, version,
|
||||
register_mutation_id, activate_mutation_id, retire_mutation_id,
|
||||
registered_at_ms, activated_at_ms, retired_at_ms
|
||||
) VALUES (?, ?, ?, 'retired', 3, ?, ?, ?, 10, 50, 100)`,
|
||||
)
|
||||
.run(
|
||||
RETIRED_KEY_ID,
|
||||
RETIRED_DIGEST,
|
||||
RETIRED_DIGEST,
|
||||
'00000000-0000-4000-8000-000000000011',
|
||||
'00000000-0000-4000-8000-000000000012',
|
||||
'00000000-0000-4000-8000-000000000014',
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
||||
pepper_key_id, material_digest, backup_digest, state, version,
|
||||
register_mutation_id, activate_mutation_id, registered_at_ms,
|
||||
activated_at_ms
|
||||
) VALUES (?, ?, ?, 'active', 2, ?, ?, 60, 100)`,
|
||||
)
|
||||
.run(
|
||||
ACTIVE_KEY_ID,
|
||||
ACTIVE_DIGEST,
|
||||
ACTIVE_DIGEST,
|
||||
'00000000-0000-4000-8000-000000000021',
|
||||
'00000000-0000-4000-8000-000000000022',
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
||||
generation, mutation_id, expected_generation,
|
||||
previous_pepper_key_id, active_pepper_key_id,
|
||||
material_digest, backup_digest, activated_at_ms
|
||||
) VALUES (1, ?, 0, NULL, ?, ?, ?, 50)`,
|
||||
)
|
||||
.run(
|
||||
'00000000-0000-4000-8000-000000000012',
|
||||
RETIRED_KEY_ID,
|
||||
RETIRED_DIGEST,
|
||||
RETIRED_DIGEST,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
||||
generation, mutation_id, expected_generation,
|
||||
previous_pepper_key_id, active_pepper_key_id,
|
||||
material_digest, backup_digest, activated_at_ms
|
||||
) VALUES (2, ?, 1, ?, ?, ?, ?, 100)`,
|
||||
)
|
||||
.run(
|
||||
'00000000-0000-4000-8000-000000000022',
|
||||
RETIRED_KEY_ID,
|
||||
ACTIVE_KEY_ID,
|
||||
ACTIVE_DIGEST,
|
||||
ACTIVE_DIGEST,
|
||||
);
|
||||
if (futureReference) {
|
||||
const subjectId = `usr_${'g'.repeat(22)}`;
|
||||
const credentialId = `own_${'h'.repeat(22)}`;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3IdentitySubjects" (
|
||||
subject_type, subject_id, status, version, created_at_ms, updated_at_ms
|
||||
) VALUES ('user', ?, 'active', 1, 200, 200)`,
|
||||
)
|
||||
.run(subjectId);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentials" (
|
||||
credential_id, version, state, subject_type, subject_id,
|
||||
secret_digest, created_at_ms, not_before_at_ms, expires_at_ms
|
||||
) VALUES (?, 1, 'active', 'user', ?, ?, 200, 3500000000, 4000000000)`,
|
||||
)
|
||||
.run(credentialId, subjectId, '3'.repeat(64));
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||
credential_id, credential_version, pepper_key_id
|
||||
) VALUES (?, 1, ?)`,
|
||||
)
|
||||
.run(credentialId, RETIRED_KEY_ID);
|
||||
}
|
||||
client.close();
|
||||
return options;
|
||||
}
|
||||
|
||||
function policy() {
|
||||
return {
|
||||
version: 1,
|
||||
acknowledgementRetentionMs: MIN_LOCAL_OWNER_PEPPER_ACK_RETENTION_MS,
|
||||
auditRetentionMs: MIN_LOCAL_OWNER_PEPPER_AUDIT_RETENTION_MS,
|
||||
backupRetentionMs: MIN_LOCAL_OWNER_PEPPER_BACKUP_RETENTION_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function audit(mutationId, requestId, operation, occurredAtMs) {
|
||||
return {
|
||||
eventId: mutationId,
|
||||
requestId,
|
||||
operationId: `owner.pepper.material_gc.${operation}`,
|
||||
projectId: null,
|
||||
subject: { type: 'system', id: 'owner-pepper-gc' },
|
||||
authenticationId: 'local-owner-console',
|
||||
outcome: 'allowed',
|
||||
reasons: ['pepper_material_gc'],
|
||||
fence: null,
|
||||
occurredAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareCommand(index = 1, preparedAtMs = PREPARED_AT_MS) {
|
||||
const mutationId = `00000000-0000-4000-8000-0000000006${String(
|
||||
index,
|
||||
).padStart(2, '0')}`;
|
||||
const requestId = `pepper-gc-prepare-${index}`;
|
||||
return {
|
||||
mutationId,
|
||||
requestId,
|
||||
pepperKeyId: RETIRED_KEY_ID,
|
||||
expectedMaterialDigest: RETIRED_DIGEST,
|
||||
expectedBackupMaterialDigest: RETIRED_DIGEST,
|
||||
expectedActivePepperKeyId: ACTIVE_KEY_ID,
|
||||
expectedActiveGeneration: 2,
|
||||
expectedActiveMaterialDigest: ACTIVE_DIGEST,
|
||||
retentionPolicy: policy(),
|
||||
preparedAtMs,
|
||||
audit: audit(mutationId, requestId, 'prepare', preparedAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
function completeCommand(prepare) {
|
||||
const mutationId = '00000000-0000-4000-8000-000000000701';
|
||||
const requestId = 'pepper-gc-complete-1';
|
||||
const completedAtMs = PREPARED_AT_MS + 1;
|
||||
return {
|
||||
prepareMutationId: prepare.mutationId,
|
||||
mutationId,
|
||||
requestId,
|
||||
destructionProofDigest: '4'.repeat(64),
|
||||
completedAtMs,
|
||||
audit: audit(mutationId, requestId, 'complete', completedAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
test('prepares and completes one exact idempotent GC ledger', async (t) => {
|
||||
const options = await preparedDatabase(t);
|
||||
const database = await openLocalSqlitePepperGcDatabase(options);
|
||||
t.after(() => database.close());
|
||||
const prepare = prepareCommand();
|
||||
const inserted = await database.materialGc.prepare(prepare);
|
||||
assert.equal(inserted.status, 'inserted');
|
||||
assert.equal(inserted.record.state, 'prepared');
|
||||
assert.equal(
|
||||
inserted.record.retentionEligibleAtMs,
|
||||
100 + MIN_LOCAL_OWNER_PEPPER_BACKUP_RETENTION_MS,
|
||||
);
|
||||
assert.equal((await database.materialGc.prepare(prepare)).status, 'existing');
|
||||
|
||||
const complete = completeCommand(prepare);
|
||||
assert.equal(
|
||||
(await database.materialGc.complete(complete)).record.state,
|
||||
'completed',
|
||||
);
|
||||
assert.equal(
|
||||
(await database.materialGc.complete(complete)).status,
|
||||
'existing',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects retention, future references and concurrent open GC', async (t) => {
|
||||
const retentionOptions = await preparedDatabase(t);
|
||||
const retentionDatabase = await openLocalSqlitePepperGcDatabase(
|
||||
retentionOptions,
|
||||
);
|
||||
t.after(() => retentionDatabase.close());
|
||||
await assert.rejects(
|
||||
retentionDatabase.materialGc.prepare(prepareCommand(1, 1_000)),
|
||||
LocalOwnerPepperMaterialGcRetentionPendingError,
|
||||
);
|
||||
|
||||
const referenceOptions = await preparedDatabase(t, true);
|
||||
const referenceDatabase = await openLocalSqlitePepperGcDatabase(
|
||||
referenceOptions,
|
||||
);
|
||||
t.after(() => referenceDatabase.close());
|
||||
await assert.rejects(
|
||||
referenceDatabase.materialGc.prepare(prepareCommand()),
|
||||
LocalOwnerPepperMaterialGcReferenceConflictError,
|
||||
);
|
||||
|
||||
const concurrentOptions = await preparedDatabase(t);
|
||||
const first = await openLocalSqlitePepperGcDatabase(concurrentOptions);
|
||||
const second = await openLocalSqlitePepperGcDatabase(concurrentOptions);
|
||||
t.after(() => Promise.all([first.close(), second.close()]));
|
||||
await first.materialGc.prepare(prepareCommand(1));
|
||||
await assert.rejects(
|
||||
second.materialGc.prepare(prepareCommand(2)),
|
||||
LocalOwnerPepperMaterialGcInProgressError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a backup digest that is not bound to the retired catalog row', async (t) => {
|
||||
const options = await preparedDatabase(t);
|
||||
const database = await openLocalSqlitePepperGcDatabase(options);
|
||||
t.after(() => database.close());
|
||||
await assert.rejects(
|
||||
database.materialGc.prepare({
|
||||
...prepareCommand(),
|
||||
expectedBackupMaterialDigest: '9'.repeat(64),
|
||||
}),
|
||||
LocalOwnerPepperMaterialGcMutationConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
await database.materialGc.resolve(prepareCommand().mutationId),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
LocalOwnerPepperCatalogFullError,
|
||||
LocalOwnerPepperGenerationConflictError,
|
||||
LocalOwnerPepperMutationConflictError,
|
||||
} = require('@qinglong/runtime-core/local-owner-pepper');
|
||||
const {
|
||||
openLocalSqliteBootstrapDatabase,
|
||||
} = require('../dist/storage/bootstrap');
|
||||
const { migrateLocalSqlitePath } = require('../dist/migration/migration');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-pepper-catalog-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'qinglong3.sqlite');
|
||||
}
|
||||
|
||||
function registration(index, overrides = {}) {
|
||||
return {
|
||||
mutationId: `018f4f58-7d5a-4d82-8f7d-5da12f05c0${String(index).padStart(2, '0')}`,
|
||||
pepperKeyId: `owner-key-${index}`,
|
||||
materialDigest: index.toString(16).padStart(64, '0'),
|
||||
backupDigest: (index + 16).toString(16).padStart(64, '0'),
|
||||
registeredAtMs: 100 + index,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('registers, activates and rotates with one append-only generation winner', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
const options = { databasePath, profile: 'edge' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const first = await openLocalSqliteBootstrapDatabase(options);
|
||||
const second = await openLocalSqliteBootstrapDatabase(options);
|
||||
t.after(() => Promise.all([first.close(), second.close()]));
|
||||
|
||||
const key1 = registration(1);
|
||||
assert.equal((await first.ownerPepper.register(key1)).status, 'inserted');
|
||||
assert.equal((await second.ownerPepper.register(key1)).status, 'existing');
|
||||
const activation1 = {
|
||||
mutationId: '018f4f58-7d5a-4d82-8f7d-5da12f05d001',
|
||||
pepperKeyId: key1.pepperKeyId,
|
||||
expectedGeneration: 0,
|
||||
activatedAtMs: 200,
|
||||
};
|
||||
assert.equal(
|
||||
(await first.ownerPepper.activate(activation1)).activation.generation,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(await second.ownerPepper.activate(activation1)).status,
|
||||
'existing',
|
||||
);
|
||||
|
||||
const key2 = registration(2);
|
||||
const key3 = registration(3);
|
||||
await first.ownerPepper.register(key2);
|
||||
await first.ownerPepper.register(key3);
|
||||
const contenders = [key2, key3].map((key, index) =>
|
||||
[first, second][index].ownerPepper.activate({
|
||||
mutationId: `018f4f58-7d5a-4d82-8f7d-5da12f05d00${index + 2}`,
|
||||
pepperKeyId: key.pepperKeyId,
|
||||
expectedGeneration: 1,
|
||||
expectedActivePepperKeyId: key1.pepperKeyId,
|
||||
activatedAtMs: 300 + index,
|
||||
}),
|
||||
);
|
||||
const settled = await Promise.allSettled(contenders);
|
||||
assert.equal(settled.filter(({ status }) => status === 'fulfilled').length, 1);
|
||||
const rejection = settled.find(({ status }) => status === 'rejected');
|
||||
assert.ok(rejection.reason instanceof LocalOwnerPepperGenerationConflictError);
|
||||
|
||||
const active = await first.ownerPepper.resolveActive();
|
||||
assert.equal(active.generation, 2);
|
||||
assert.equal((await first.ownerPepper.resolveKey(key1.pepperKeyId)).state, 'retired');
|
||||
assert.equal(
|
||||
(await first.ownerPepper.resolveKey(active.activePepperKeyId)).state,
|
||||
'active',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects semantic mutation drift and caps the catalog at eight keys', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
const options = { databasePath, profile: 'standalone' };
|
||||
await migrateLocalSqlitePath(options);
|
||||
const database = await openLocalSqliteBootstrapDatabase(options);
|
||||
t.after(() => database.close());
|
||||
|
||||
const first = registration(1);
|
||||
await database.ownerPepper.register(first);
|
||||
await assert.rejects(
|
||||
database.ownerPepper.register({ ...first, backupDigest: 'f'.repeat(64) }),
|
||||
LocalOwnerPepperMutationConflictError,
|
||||
);
|
||||
for (let index = 2; index <= 8; index += 1) {
|
||||
await database.ownerPepper.register(registration(index));
|
||||
}
|
||||
await assert.rejects(
|
||||
database.ownerPepper.register(registration(9)),
|
||||
LocalOwnerPepperCatalogFullError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ApprovalPolicyFenceConflictError,
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
PLUGIN_PACKAGE_INSTALL_ACTION_TYPE,
|
||||
PluginPackageAdmissionBindingConflictError,
|
||||
PluginPackageAdmissionReceiptConflictError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-admission');
|
||||
const {
|
||||
createPluginPackageInstallProposal,
|
||||
resolvePluginPackageInstallProposal,
|
||||
} = require('@qinglong/runtime-core/plugin-package-proposal');
|
||||
const {
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
LocalSqliteApprovalRequestRepository,
|
||||
} = require('@qinglong/local-sqlite/approved-action');
|
||||
const {
|
||||
LocalSqliteApprovedActionExecutionRepository,
|
||||
} = require('@qinglong/local-sqlite/approved-action-execution');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallProposalRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-proposal');
|
||||
|
||||
const REQUESTER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'package_dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function packageAction() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
return {
|
||||
input: {
|
||||
lockId: 'proposal-monitor-v1',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'a'.repeat(64)}`,
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
artifactBytes: 2048,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
},
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons,
|
||||
occurredAtMs,
|
||||
) {
|
||||
return {
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId: 'default',
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons,
|
||||
fence: FENCE,
|
||||
occurredAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(
|
||||
t,
|
||||
{ admittedAtMs = Date.now(), leaseDurationMs = 60_000 } = {},
|
||||
) {
|
||||
const proposedAtMs = admittedAtMs - 40;
|
||||
const requestedAtMs = admittedAtMs - 30;
|
||||
const decidedAtMs = admittedAtMs - 20;
|
||||
const consumedAtMs = admittedAtMs - 10;
|
||||
const claimedAtMs = admittedAtMs - 5;
|
||||
const expiresAtMs = admittedAtMs + 60_000;
|
||||
const client = new DatabaseSync(':memory:');
|
||||
t.after(() => client.close());
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',1,'active','owner',
|
||||
'grant-owner-1','user','usr_owner',0)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
const action = packageAction();
|
||||
const binding = {
|
||||
permission: 'package.manage',
|
||||
actionType: PLUGIN_PACKAGE_INSTALL_ACTION_TYPE,
|
||||
actionRef: 'proposal:monitor-v1',
|
||||
actionDigest: pluginPackageInstallActionDigest(action.input),
|
||||
previewDigest: pluginPackageInstallPlanDigest(action.plan),
|
||||
};
|
||||
const proposal = createPluginPackageInstallProposal({
|
||||
actionRef: binding.actionRef,
|
||||
actionInput: action.input,
|
||||
proposedBy: REQUESTER,
|
||||
proposalFence: FENCE,
|
||||
createdAtMs: proposedAtMs,
|
||||
});
|
||||
await new LocalSqlitePluginPackageInstallProposalRepository(
|
||||
client,
|
||||
).createProposal({
|
||||
proposal,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000100',
|
||||
binding.actionRef,
|
||||
'plugin_package.propose',
|
||||
REQUESTER,
|
||||
'auth-owner',
|
||||
'allowed',
|
||||
['package_proposal'],
|
||||
proposedAtMs,
|
||||
),
|
||||
});
|
||||
const approval = new LocalSqliteApprovalRequestRepository(client);
|
||||
await approval.create({
|
||||
request: createApprovalRequest({
|
||||
id: 'approval-monitor-v1',
|
||||
projectId: 'default',
|
||||
action: binding,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: REQUESTER,
|
||||
requestedAtMs,
|
||||
expiresAtMs,
|
||||
requestFence: FENCE,
|
||||
}),
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000101',
|
||||
'http-request-1',
|
||||
'approval.request',
|
||||
REQUESTER,
|
||||
'auth-owner',
|
||||
'approval_required',
|
||||
['package_review'],
|
||||
requestedAtMs,
|
||||
),
|
||||
});
|
||||
await approval.decide({
|
||||
requestId: 'approval-monitor-v1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-monitor-v1',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: REQUESTER,
|
||||
authenticationId: 'auth-owner-step-up',
|
||||
authenticatedAtMs: requestedAtMs,
|
||||
expiresAtMs,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000102',
|
||||
'http-request-1',
|
||||
'approval.decide',
|
||||
REQUESTER,
|
||||
'auth-owner-step-up',
|
||||
'allowed',
|
||||
['role_grant'],
|
||||
decidedAtMs,
|
||||
),
|
||||
});
|
||||
const consumed = await approval.consume({
|
||||
requestId: 'approval-monitor-v1',
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-monitor-v1',
|
||||
dispatchId: 'dispatch-monitor-v1',
|
||||
action: binding,
|
||||
requestedBy: REQUESTER,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000103',
|
||||
'dispatch-cycle-1',
|
||||
'approval.consume',
|
||||
SYSTEM,
|
||||
'auth-package-dispatcher',
|
||||
'allowed',
|
||||
['role_grant'],
|
||||
consumedAtMs,
|
||||
),
|
||||
});
|
||||
const executions = new LocalSqliteApprovedActionExecutionRepository(client);
|
||||
const claimed = await executions.claimExecution({
|
||||
dispatchId: consumed.dispatch.id,
|
||||
owner: 'package_dispatcher',
|
||||
leaseToken: 'lease-monitor-v1',
|
||||
nowMs: claimedAtMs,
|
||||
leaseDurationMs,
|
||||
});
|
||||
assert.equal(claimed.status, 'claimed');
|
||||
const started = await executions.startExecution({
|
||||
dispatchId: consumed.dispatch.id,
|
||||
approvalRequestId: consumed.dispatch.approvalRequestId,
|
||||
actionDigest: consumed.dispatch.action.actionDigest,
|
||||
owner: 'package_dispatcher',
|
||||
leaseToken: 'lease-monitor-v1',
|
||||
expectedVersion: claimed.snapshot.execution.version,
|
||||
startedAtMs: admittedAtMs,
|
||||
});
|
||||
const lock = resolvePluginPackageInstallProposal(
|
||||
proposal,
|
||||
consumed.dispatch,
|
||||
admittedAtMs,
|
||||
);
|
||||
return {
|
||||
client,
|
||||
executions,
|
||||
repository: new LocalSqlitePluginPackageInstallRepository(client),
|
||||
request: {
|
||||
lock,
|
||||
proposalDigest: proposal.proposalDigest,
|
||||
execution: started.execution,
|
||||
installationId: 'install-monitor-v1',
|
||||
mutationId: 'admit-monitor-v1',
|
||||
admittedAtMs,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000104',
|
||||
consumed.dispatch.id,
|
||||
'plugin_package.admit',
|
||||
SYSTEM,
|
||||
'auth-package-dispatcher',
|
||||
'allowed',
|
||||
['approved_action'],
|
||||
admittedAtMs,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('admits one approved Package atomically and exactly replays its receipt', async (t) => {
|
||||
const { client, repository, request } = await fixture(t);
|
||||
const admitted = await repository.admit(request);
|
||||
assert.equal(admitted.status, 'admitted');
|
||||
assert.equal(admitted.record.state, 'queued');
|
||||
assert.equal(admitted.receipt.dispatchId, 'dispatch-monitor-v1');
|
||||
assert.deepEqual(
|
||||
await repository.findAdmissionReceipt('dispatch-monitor-v1'),
|
||||
admitted.receipt,
|
||||
);
|
||||
const replay = await repository.admit(request);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay, { ...admitted, status: 'existing' });
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM "QingLong3PluginPackageInstalls") AS installs,
|
||||
(SELECT count(*) FROM "QingLong3PluginPackageInstallMutations") AS mutations,
|
||||
(SELECT count(*) FROM "QingLong3PluginPackageAdmissionReceipts") AS receipts,
|
||||
(SELECT count(*) FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'plugin_package.admit') AS audits`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ installs: 1, mutations: 1, receipts: 1, audits: 1 },
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.admit({
|
||||
...request,
|
||||
audit: { ...request.audit, authenticationId: 'auth-drift' },
|
||||
}),
|
||||
PluginPackageAdmissionReceiptConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects proposal and execution fence drift before admission', async (t) => {
|
||||
const { client, executions, repository, request } = await fixture(t);
|
||||
await assert.rejects(
|
||||
repository.admit({
|
||||
...request,
|
||||
proposalDigest: 'f'.repeat(64),
|
||||
}),
|
||||
PluginPackageAdmissionBindingConflictError,
|
||||
);
|
||||
await executions.renewExecution({
|
||||
dispatchId: request.execution.dispatchId,
|
||||
owner: request.execution.leaseOwner,
|
||||
leaseToken: request.execution.leaseToken,
|
||||
expectedVersion: request.execution.version,
|
||||
nowMs: request.admittedAtMs + 5,
|
||||
leaseDurationMs: 60_000,
|
||||
});
|
||||
await assert.rejects(
|
||||
repository.admit(request),
|
||||
PluginPackageAdmissionBindingConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "QingLong3PluginPackageAdmissionReceipts"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an admission observed after its durable execution lease expired', async (t) => {
|
||||
const { client, repository, request } = await fixture(t, {
|
||||
admittedAtMs: Date.now() - 100,
|
||||
leaseDurationMs: 20,
|
||||
});
|
||||
await assert.rejects(
|
||||
repository.admit(request),
|
||||
PluginPackageAdmissionBindingConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "QingLong3PluginPackageAdmissionReceipts"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('rolls the full admission back when the requester Policy fence changed', async (t) => {
|
||||
const { client, repository, request } = await fixture(t);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',2,'revoked',NULL,
|
||||
'revoke-owner-1','user','usr_owner',?)`,
|
||||
)
|
||||
.run(request.admittedAtMs + 5);
|
||||
await assert.rejects(
|
||||
repository.admit(request),
|
||||
ApprovalPolicyFenceConflictError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM "QingLong3PluginPackageInstalls") AS installs,
|
||||
(SELECT count(*) FROM "QingLong3PluginPackageAdmissionReceipts") AS receipts,
|
||||
(SELECT count(*) FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'plugin_package.admit') AS audits`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ installs: 0, receipts: 0, audits: 0 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PluginPackageAutomationPublicationConflictError,
|
||||
PluginPackageAutomationPublicationUnavailableError,
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
createPluginPackageQuarantineEvent,
|
||||
} = require('@qinglong/runtime-core/plugin-package-quarantine');
|
||||
const {
|
||||
pluginPackageAutomationPublicationFixture,
|
||||
registerPluginPackageAutomationPublicationRepositoryContract,
|
||||
} = require('../../../test/contracts/pluginPackageAutomationPublicationRepositoryContract.cjs');
|
||||
const {
|
||||
activateInstall,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublicationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
|
||||
const digest = (value) => value.repeat(64);
|
||||
|
||||
function insertQuarantine(client, fixture) {
|
||||
const record = fixture.install.active;
|
||||
const event = createPluginPackageQuarantineEvent({
|
||||
mutationId: `quarantine-${fixture.namespace}`,
|
||||
revocationReceiptDigest: digest('d'),
|
||||
impactDigest: digest('e'),
|
||||
target: {
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
installationId: record.installationId,
|
||||
lockDigest: record.lockDigest,
|
||||
installState: record.state,
|
||||
installVersion: record.version,
|
||||
installRecordDigest: record.recordDigest,
|
||||
activeLockDigest: record.activeLockDigest,
|
||||
},
|
||||
proposer: { type: 'user', id: 'owner-a' },
|
||||
confirmer: { type: 'user', id: 'owner-b' },
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
occurredAtMs: record.updatedAtMs + 1,
|
||||
});
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageQuarantineEvents" (
|
||||
event_digest, mutation_id, revocation_receipt_digest, impact_digest,
|
||||
project_id, package_name, installation_id, lock_digest,
|
||||
install_state, install_version, install_record_digest,
|
||||
active_lock_digest, proposer_type, proposer_id, confirmer_type,
|
||||
confirmer_id, authorization_mode, reason_code, occurred_at_ms,
|
||||
event_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
event.eventDigest,
|
||||
event.mutationId,
|
||||
event.revocationReceiptDigest,
|
||||
event.impactDigest,
|
||||
event.target.projectId,
|
||||
event.target.packageName,
|
||||
event.target.installationId,
|
||||
event.target.lockDigest,
|
||||
event.target.installState,
|
||||
event.target.installVersion,
|
||||
event.target.installRecordDigest,
|
||||
event.target.activeLockDigest,
|
||||
event.proposer.type,
|
||||
event.proposer.id,
|
||||
event.confirmer.type,
|
||||
event.confirmer.id,
|
||||
event.authorizationMode,
|
||||
event.reasonCode,
|
||||
event.occurredAtMs,
|
||||
JSON.stringify(event),
|
||||
);
|
||||
}
|
||||
|
||||
async function createRepository(_t, fixture) {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, fixture.projectId, fixture.projectId);
|
||||
return {
|
||||
client,
|
||||
repository:
|
||||
new LocalSqlitePluginPackageAutomationPublicationRepository(client),
|
||||
materializedRepository:
|
||||
new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
client,
|
||||
fixture.registry,
|
||||
),
|
||||
close: () => client.close(),
|
||||
};
|
||||
}
|
||||
|
||||
registerPluginPackageAutomationPublicationRepositoryContract({
|
||||
name: 'SQLite Plugin Package automation publication repository',
|
||||
namespace: 'sqlite-automation-publication',
|
||||
profile: 'edge',
|
||||
createRepository,
|
||||
});
|
||||
|
||||
test('fails closed when publication JSON is changed in place', async (t) => {
|
||||
const fixture = pluginPackageAutomationPublicationFixture(
|
||||
'sqlite-automation-corrupt',
|
||||
{ profile: 'edge', name: 'daily' },
|
||||
);
|
||||
const harness = await createRepository(t, fixture);
|
||||
t.after(() => harness.close());
|
||||
await harness.materializedRepository.publish(fixture.revision);
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
fixture.revision,
|
||||
fixture.registry,
|
||||
1_000,
|
||||
);
|
||||
await harness.repository.publish(publication);
|
||||
harness.client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
harness.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageAutomationPublications"
|
||||
SET publication_json =
|
||||
json_set(publication_json, '$.definitions.workflows[0].name', 'Drift')
|
||||
WHERE publication_digest = ?`,
|
||||
)
|
||||
.run(publication.publicationDigest);
|
||||
await assert.rejects(
|
||||
harness.repository.findByDigest(publication.publicationDigest),
|
||||
PluginPackageAutomationPublicationUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('lists only materialized active generations whose automation head is stale', async (t) => {
|
||||
const fixture = pluginPackageAutomationPublicationFixture(
|
||||
'sqlite-automation-pending',
|
||||
{ profile: 'edge', name: 'daily' },
|
||||
);
|
||||
const harness = await createRepository(t, fixture);
|
||||
t.after(() => harness.close());
|
||||
const installRepository =
|
||||
new LocalSqlitePluginPackageInstallRepository(harness.client);
|
||||
await activateInstall(installRepository, fixture);
|
||||
assert.deepEqual(await harness.repository.listPendingPage({ limit: 1 }), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
await harness.materializedRepository.publish(fixture.revision);
|
||||
assert.deepEqual(await harness.repository.listPendingPage({ limit: 1 }), {
|
||||
candidates: [
|
||||
{
|
||||
projectId: fixture.projectId,
|
||||
packageName: fixture.packageName,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
fixture.revision,
|
||||
fixture.registry,
|
||||
1_000,
|
||||
);
|
||||
await harness.repository.publish(publication);
|
||||
assert.deepEqual(await harness.repository.listPendingPage({ limit: 1 }), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('admits only the exact active current automation publication', async (t) => {
|
||||
const fixture = pluginPackageAutomationPublicationFixture(
|
||||
'sqlite-automation-start-guard',
|
||||
{ profile: 'edge', name: 'daily' },
|
||||
);
|
||||
const harness = await createRepository(t, fixture);
|
||||
t.after(() => harness.close());
|
||||
await activateInstall(
|
||||
new LocalSqlitePluginPackageInstallRepository(harness.client),
|
||||
fixture,
|
||||
);
|
||||
await harness.materializedRepository.publish(fixture.revision);
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
fixture.revision,
|
||||
fixture.registry,
|
||||
1_000,
|
||||
);
|
||||
await harness.repository.publish(publication);
|
||||
|
||||
assert.equal(
|
||||
await harness.repository.isStartAllowed(
|
||||
fixture.projectId,
|
||||
fixture.packageName,
|
||||
publication.publicationDigest,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await harness.repository.isStartAllowed(
|
||||
fixture.projectId,
|
||||
fixture.packageName,
|
||||
digest('f'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('quarantine atomically removes pending work and rejects publication', async (t) => {
|
||||
const fixture = pluginPackageAutomationPublicationFixture(
|
||||
'sqlite-automation-quarantine',
|
||||
{ profile: 'edge', name: 'daily' },
|
||||
);
|
||||
const harness = await createRepository(t, fixture);
|
||||
t.after(() => harness.close());
|
||||
await activateInstall(
|
||||
new LocalSqlitePluginPackageInstallRepository(harness.client),
|
||||
fixture,
|
||||
);
|
||||
await harness.materializedRepository.publish(fixture.revision);
|
||||
assert.deepEqual(await harness.repository.listPendingPage({ limit: 1 }), {
|
||||
candidates: [
|
||||
{
|
||||
projectId: fixture.projectId,
|
||||
packageName: fixture.packageName,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
insertQuarantine(harness.client, fixture);
|
||||
assert.deepEqual(await harness.repository.listPendingPage({ limit: 1 }), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
fixture.revision,
|
||||
fixture.registry,
|
||||
1_000,
|
||||
);
|
||||
await assert.rejects(
|
||||
harness.repository.publish(publication),
|
||||
PluginPackageAutomationPublicationConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
await harness.repository.isStartAllowed(
|
||||
fixture.projectId,
|
||||
fixture.packageName,
|
||||
publication.publicationDigest,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes storage only through the explicit subpath', () => {
|
||||
const entrypoint = require('@qinglong/local-sqlite/plugin-package-automation-publication');
|
||||
assert.equal(
|
||||
entrypoint.LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist')
|
||||
.LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
registerPluginPackageInstallRepositoryContract,
|
||||
} = require('../../../test/contracts/pluginPackageInstallRepositoryContract.cjs');
|
||||
|
||||
const {
|
||||
PluginPackageInstallMutationConflictError,
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
PluginPackageInstallUnavailableError,
|
||||
createPluginPackageInstall,
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallCommit,
|
||||
pluginPackageInstallCreate,
|
||||
pluginPackageInstallPlanDigest,
|
||||
transitionPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const CONTENT_DIGEST = 'b'.repeat(64);
|
||||
|
||||
function manifest(packageName = 'example-monitor') {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: packageName,
|
||||
displayName: packageName,
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: {
|
||||
tasks: [],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment() {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const packageManifest = manifest(overrides.packageName ?? 'example-monitor');
|
||||
const installEnvironment = environment();
|
||||
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
||||
const source = {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${ARTIFACT_DIGEST}`,
|
||||
artifactDigest: ARTIFACT_DIGEST,
|
||||
artifactBytes: 2048,
|
||||
contentDigest: CONTENT_DIGEST,
|
||||
};
|
||||
const actionInput = {
|
||||
lockId: overrides.lockId ?? 'lock-001',
|
||||
projectId: 'default',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
source,
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: `approval-${overrides.lockId ?? '001'}`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `dispatch-${overrides.lockId ?? '001'}`,
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 10_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const install = createPluginPackageInstall(lock, {
|
||||
installationId: overrides.installationId ?? 'install-001',
|
||||
mutationId: overrides.mutationId ?? 'mutation-create',
|
||||
occurredAtMs: overrides.occurredAtMs ?? 201,
|
||||
});
|
||||
return { lock, install };
|
||||
}
|
||||
|
||||
function stage(lock, install, overrides = {}) {
|
||||
return transitionPluginPackageInstall(lock, install, {
|
||||
type: 'stage_completed',
|
||||
mutationId: overrides.mutationId ?? 'mutation-stage',
|
||||
occurredAtMs: overrides.occurredAtMs ?? install.updatedAtMs + 1,
|
||||
stageRef: `local-stage:${lock.lockDigest}`,
|
||||
artifactDigest: lock.source.artifactDigest,
|
||||
manifestDigest: lock.manifestDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
});
|
||||
}
|
||||
|
||||
async function repository(t) {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
t.after(() => client.close());
|
||||
return {
|
||||
client,
|
||||
repository: new LocalSqlitePluginPackageInstallRepository(client),
|
||||
};
|
||||
}
|
||||
|
||||
registerPluginPackageInstallRepositoryContract({
|
||||
name: 'SQLite Plugin Package install repository',
|
||||
async createRepository() {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
return {
|
||||
repository: new LocalSqlitePluginPackageInstallRepository(client),
|
||||
close: () => client.close(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
test('creates and finds one queued install with a durable head and mutation', async (t) => {
|
||||
const { client, repository: store } = await repository(t);
|
||||
const value = fixture();
|
||||
const result = await store.create(
|
||||
pluginPackageInstallCreate(value.lock, value.install, null),
|
||||
);
|
||||
assert.equal(result.status, 'created');
|
||||
assert.deepEqual(
|
||||
await store.find('default', 'example-monitor'),
|
||||
value.install,
|
||||
);
|
||||
assert.deepEqual(await store.findLock(value.lock.lockDigest), value.lock);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM "QingLong3PluginPackageInstalls") AS installs,
|
||||
(SELECT count(*) FROM "QingLong3PluginPackageInstallHeads") AS heads,
|
||||
(SELECT count(*) FROM "QingLong3PluginPackageInstallMutations") AS mutations`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ installs: 1, heads: 1, mutations: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test('replays an exact create and rejects reuse with different locked facts', async (t) => {
|
||||
const { repository: store } = await repository(t);
|
||||
const value = fixture();
|
||||
const command = pluginPackageInstallCreate(value.lock, value.install, null);
|
||||
await store.create(command);
|
||||
const replay = await store.create(command);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.record, value.install);
|
||||
|
||||
const drift = fixture({
|
||||
lockId: 'lock-drift',
|
||||
installationId: value.install.installationId,
|
||||
mutationId: value.install.lastMutationId,
|
||||
});
|
||||
await assert.rejects(
|
||||
store.create(pluginPackageInstallCreate(drift.lock, drift.install, null)),
|
||||
PluginPackageInstallMutationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('commits exact CAS transitions and rejects stale durable state', async (t) => {
|
||||
const { repository: store } = await repository(t);
|
||||
const value = fixture();
|
||||
await store.create(
|
||||
pluginPackageInstallCreate(value.lock, value.install, null),
|
||||
);
|
||||
const staged = stage(value.lock, value.install);
|
||||
const command = pluginPackageInstallCommit(value.install, staged);
|
||||
const committed = await store.commit(command);
|
||||
assert.equal(committed.status, 'committed');
|
||||
assert.deepEqual(await store.find('default', 'example-monitor'), staged);
|
||||
assert.equal((await store.commit(command)).status, 'existing');
|
||||
|
||||
const competing = stage(value.lock, value.install, {
|
||||
mutationId: 'mutation-competing-stage',
|
||||
});
|
||||
await assert.rejects(
|
||||
store.commit(pluginPackageInstallCommit(value.install, competing)),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps old mutation replay idempotent after the record advances', async (t) => {
|
||||
const { repository: store } = await repository(t);
|
||||
const value = fixture();
|
||||
const create = pluginPackageInstallCreate(value.lock, value.install, null);
|
||||
await store.create(create);
|
||||
const staged = stage(value.lock, value.install);
|
||||
await store.commit(pluginPackageInstallCommit(value.install, staged));
|
||||
const activating = transitionPluginPackageInstall(value.lock, staged, {
|
||||
type: 'activation_started',
|
||||
mutationId: 'mutation-activate',
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
await store.commit(pluginPackageInstallCommit(staged, activating));
|
||||
|
||||
const replay = await store.create(create);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.record, activating);
|
||||
});
|
||||
|
||||
test('replaces only an exact terminal head and preserves install history', async (t) => {
|
||||
const { client, repository: store } = await repository(t);
|
||||
const first = fixture();
|
||||
await store.create(
|
||||
pluginPackageInstallCreate(first.lock, first.install, null),
|
||||
);
|
||||
const failed = transitionPluginPackageInstall(first.lock, first.install, {
|
||||
type: 'failed',
|
||||
mutationId: 'mutation-fail',
|
||||
occurredAtMs: 202,
|
||||
reason: 'stage_failed',
|
||||
});
|
||||
await store.commit(pluginPackageInstallCommit(first.install, failed));
|
||||
|
||||
const retry = fixture({
|
||||
lockId: 'lock-retry',
|
||||
installationId: 'install-002',
|
||||
mutationId: 'mutation-retry',
|
||||
occurredAtMs: 203,
|
||||
});
|
||||
await store.create(
|
||||
pluginPackageInstallCreate(retry.lock, retry.install, failed),
|
||||
);
|
||||
assert.deepEqual(
|
||||
await store.find('default', 'example-monitor'),
|
||||
retry.install,
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(`SELECT count(*) AS count FROM "QingLong3PluginPackageInstalls"`)
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
|
||||
const stale = fixture({
|
||||
lockId: 'lock-stale',
|
||||
installationId: 'install-003',
|
||||
mutationId: 'mutation-stale',
|
||||
occurredAtMs: 204,
|
||||
});
|
||||
await assert.rejects(
|
||||
store.create(pluginPackageInstallCreate(stale.lock, stale.install, failed)),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('paginates only current recoverable heads with a stable cursor', async (t) => {
|
||||
const { repository: store } = await repository(t);
|
||||
const alpha = fixture({
|
||||
packageName: 'alpha',
|
||||
lockId: 'lock-alpha',
|
||||
installationId: 'install-alpha',
|
||||
mutationId: 'mutation-alpha',
|
||||
});
|
||||
const beta = fixture({
|
||||
packageName: 'beta',
|
||||
lockId: 'lock-beta',
|
||||
installationId: 'install-beta',
|
||||
mutationId: 'mutation-beta',
|
||||
});
|
||||
await store.create(
|
||||
pluginPackageInstallCreate(alpha.lock, alpha.install, null),
|
||||
);
|
||||
await store.create(pluginPackageInstallCreate(beta.lock, beta.install, null));
|
||||
const first = await store.listRecoveryPage({ limit: 1 });
|
||||
assert.equal(first.truncated, true);
|
||||
assert.deepEqual(first.records, [alpha.install]);
|
||||
assert.deepEqual(first.next, {
|
||||
packageName: 'alpha',
|
||||
installationId: 'install-alpha',
|
||||
});
|
||||
const second = await store.listRecoveryPage({
|
||||
limit: 1,
|
||||
after: first.next,
|
||||
});
|
||||
assert.deepEqual(second.records, [beta.install]);
|
||||
assert.equal(second.truncated, false);
|
||||
|
||||
const failed = transitionPluginPackageInstall(alpha.lock, alpha.install, {
|
||||
type: 'failed',
|
||||
mutationId: 'mutation-alpha-fail',
|
||||
occurredAtMs: 202,
|
||||
reason: 'source_unavailable',
|
||||
});
|
||||
await store.commit(pluginPackageInstallCommit(alpha.install, failed));
|
||||
assert.deepEqual((await store.listRecoveryPage({ limit: 64 })).records, [
|
||||
beta.install,
|
||||
]);
|
||||
});
|
||||
|
||||
test('lists every current installation head by project with a bounded cursor', async (t) => {
|
||||
const { repository: store } = await repository(t);
|
||||
const alpha = fixture({
|
||||
packageName: 'alpha',
|
||||
lockId: 'inventory-alpha',
|
||||
installationId: 'inventory-alpha',
|
||||
mutationId: 'inventory-alpha-create',
|
||||
});
|
||||
const beta = fixture({
|
||||
packageName: 'beta',
|
||||
lockId: 'inventory-beta',
|
||||
installationId: 'inventory-beta',
|
||||
mutationId: 'inventory-beta-create',
|
||||
});
|
||||
await store.create(
|
||||
pluginPackageInstallCreate(alpha.lock, alpha.install, null),
|
||||
);
|
||||
await store.create(pluginPackageInstallCreate(beta.lock, beta.install, null));
|
||||
const failed = transitionPluginPackageInstall(alpha.lock, alpha.install, {
|
||||
type: 'failed',
|
||||
mutationId: 'inventory-alpha-failed',
|
||||
occurredAtMs: 202,
|
||||
reason: 'source_unavailable',
|
||||
});
|
||||
await store.commit(pluginPackageInstallCommit(alpha.install, failed));
|
||||
|
||||
const first = await store.listCurrentPage({
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(first.truncated, true);
|
||||
assert.deepEqual(first.items, [{ record: failed, quarantine: null }]);
|
||||
assert.deepEqual(first.next, { packageName: 'alpha' });
|
||||
|
||||
const second = await store.listCurrentPage({
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
after: first.next,
|
||||
});
|
||||
assert.deepEqual(second.items, [{ record: beta.install, quarantine: null }]);
|
||||
assert.equal(second.truncated, false);
|
||||
assert.equal(second.next, undefined);
|
||||
assert.deepEqual(
|
||||
await store.listCurrentPage({ projectId: 'missing', limit: 64 }),
|
||||
{ items: [], truncated: false },
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed for archived projects and corrupt persisted JSON', async (t) => {
|
||||
const { client, repository: store } = await repository(t);
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3Projects" SET "status" = 'archived' WHERE "id" = 'default'`,
|
||||
)
|
||||
.run();
|
||||
const value = fixture();
|
||||
await assert.rejects(
|
||||
store.create(pluginPackageInstallCreate(value.lock, value.install, null)),
|
||||
PluginPackageInstallTransitionConflictError,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3Projects" SET "status" = 'active' WHERE "id" = 'default'`,
|
||||
)
|
||||
.run();
|
||||
await store.create(
|
||||
pluginPackageInstallCreate(value.lock, value.install, null),
|
||||
);
|
||||
client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageInstalls"
|
||||
SET "lock_json" = '{"schema":"corrupt"}'
|
||||
WHERE "installation_id" = ?`,
|
||||
)
|
||||
.run(value.install.installationId);
|
||||
await assert.rejects(
|
||||
store.findLock(value.lock.lockDigest),
|
||||
PluginPackageInstallUnavailableError,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageInstalls"
|
||||
SET "lock_json" = ?
|
||||
WHERE "installation_id" = ?`,
|
||||
)
|
||||
.run(JSON.stringify(value.lock), value.install.installationId);
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageInstalls"
|
||||
SET "record_json" = '{"schema":"corrupt"}'
|
||||
WHERE "installation_id" = ?`,
|
||||
)
|
||||
.run(value.install.installationId);
|
||||
await assert.rejects(
|
||||
store.find('default', 'example-monitor'),
|
||||
PluginPackageInstallUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes the repository only through its explicit SQLite subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
assert.equal(root.LocalSqlitePluginPackageInstallRepository, undefined);
|
||||
assert.equal(
|
||||
subpath.LocalSqlitePluginPackageInstallRepository,
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CRASH_POINTS,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
} = require('./fixtures/pluginPackageLifecycleCrashMatrixFixture.cjs');
|
||||
|
||||
const FIXTURE_PATH = path.join(
|
||||
__dirname,
|
||||
'fixtures',
|
||||
'pluginPackageLifecycleCrashMatrixFixture.cjs',
|
||||
);
|
||||
|
||||
test(
|
||||
'survives the Plugin Package lifecycle crash matrix',
|
||||
{ timeout: 240_000 },
|
||||
async (context) => {
|
||||
const reports = [];
|
||||
for (const profile of ['edge', 'standalone']) {
|
||||
for (const action of ['disable', 'enable']) {
|
||||
for (const [pointName, point] of Object.entries(CRASH_POINTS)) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
`ql3-package-lifecycle-${profile}-${action}-`,
|
||||
),
|
||||
);
|
||||
context.after(() => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const databasePath = path.join(directory, 'runtime.sqlite');
|
||||
const markerPath = path.join(directory, 'crash-marker.json');
|
||||
const event = await setupScenario({ action, databasePath, profile });
|
||||
const crashed = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
FIXTURE_PATH,
|
||||
'crash',
|
||||
databasePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
profile,
|
||||
action,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 30_000 },
|
||||
);
|
||||
assert.equal(
|
||||
crashed.error,
|
||||
undefined,
|
||||
`${profile}/${action}/${pointName}: ${crashed.error?.message}`,
|
||||
);
|
||||
assert.equal(
|
||||
crashed.signal,
|
||||
'SIGKILL',
|
||||
`${profile}/${action}/${pointName}: status=${crashed.status}, stderr=${crashed.stderr}`,
|
||||
);
|
||||
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
||||
assert.deepEqual(marker, {
|
||||
schema:
|
||||
'qinglong/sqlite-plugin-package-lifecycle-crash-marker@v1',
|
||||
action,
|
||||
point: pointName,
|
||||
pid: marker.pid,
|
||||
});
|
||||
reports.push(
|
||||
await verifyScenario({
|
||||
action,
|
||||
databasePath,
|
||||
event,
|
||||
pointName,
|
||||
profile,
|
||||
}),
|
||||
);
|
||||
assert.equal(reports.at(-1).durableAfterCrash, point.durable);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(reports.length, 32);
|
||||
assert.equal(
|
||||
reports.filter(({ crashBeforeCommit }) => crashBeforeCommit).length,
|
||||
28,
|
||||
);
|
||||
assert.equal(
|
||||
reports.filter(({ durableAfterCrash }) => durableAfterCrash).length,
|
||||
4,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...new Set(reports.map(({ journalMode }) => journalMode))].sort(),
|
||||
['delete', 'wal'],
|
||||
);
|
||||
assert.ok(reports.every(({ synchronous }) => synchronous === 2));
|
||||
assert.ok(
|
||||
reports.every(
|
||||
({ exactReplay, integrityCheck, foreignKeyCheck }) =>
|
||||
exactReplay &&
|
||||
integrityCheck === 'ok' &&
|
||||
foreignKeyCheck === 'ok',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,672 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
pluginPackageAutomationDefinitionsFromRevision,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
InvalidPluginPackageLifecycleError,
|
||||
PluginPackageLifecycleConflictError,
|
||||
PluginPackageLifecycleUnavailableError,
|
||||
createPluginPackageLifecycleEvent,
|
||||
} = require('@qinglong/runtime-core/plugin-package-lifecycle');
|
||||
const {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionSnapshotContribution,
|
||||
} = require('@qinglong/runtime-core/project-tool-definition-snapshot');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqliteApprovalRequestRepository,
|
||||
} = require('../dist/approved-action/approvalRequestRepository');
|
||||
const { LocalSqliteOperationAuthority } = require('../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublicationRepository');
|
||||
const {
|
||||
EDGE_PLUGIN_PACKAGE_LIFECYCLE_ACTIVE_SOURCE_LIMIT,
|
||||
LocalSqlitePluginPackageLifecycleRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageLifecycleRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageTaskReconciliationRepository');
|
||||
const {
|
||||
LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
} = require('../dist/tool-execution/projectToolDefinitionSnapshotRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const {
|
||||
LocalSqliteReadinessError,
|
||||
auditLocalSqliteReadiness,
|
||||
} = require('../dist/readiness/readiness');
|
||||
|
||||
const OWNER = Object.freeze({ type: 'user', id: 'owner-001' });
|
||||
const OTHER_OWNER = Object.freeze({ type: 'user', id: 'owner-002' });
|
||||
const SYSTEM = Object.freeze({ type: 'system', id: 'lifecycle-dispatcher' });
|
||||
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
|
||||
|
||||
function audit(
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
projectId,
|
||||
occurredAtMs,
|
||||
) {
|
||||
return {
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons: [outcome === 'approval_required' ? 'package_review' : 'role_grant'],
|
||||
fence: FENCE,
|
||||
occurredAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function auditId(sequence, offset) {
|
||||
return `90000000-0000-4000-8000-${String(sequence * 10 + offset).padStart(
|
||||
12,
|
||||
'0',
|
||||
)}`;
|
||||
}
|
||||
|
||||
async function harness(t, namespace, fixtureOptions = {}) {
|
||||
const fixture = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
profile: 'edge',
|
||||
...fixtureOptions,
|
||||
});
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, fixture.projectId, fixture.projectId);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
project_id, subject_type, subject_id, version, state, role,
|
||||
mutation_id, changed_by_type, changed_by_id, created_at_ms
|
||||
) VALUES (?, 'user', ?, 1, 'active', 'owner', ?, 'user', ?, 1)`,
|
||||
)
|
||||
.run(
|
||||
fixture.projectId,
|
||||
OWNER.id,
|
||||
`grant-${namespace}`,
|
||||
OWNER.id,
|
||||
);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
t.after(() => authority.close());
|
||||
return {
|
||||
fixture,
|
||||
client,
|
||||
authority,
|
||||
approval: new LocalSqliteApprovalRequestRepository(authority),
|
||||
automations:
|
||||
new LocalSqlitePluginPackageAutomationPublicationRepository(authority),
|
||||
install: new LocalSqlitePluginPackageInstallRepository(authority),
|
||||
materialized: new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
),
|
||||
reconciliation: new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
),
|
||||
snapshots: new LocalSqliteProjectToolDefinitionSnapshotRepository(
|
||||
authority,
|
||||
),
|
||||
lifecycle: new LocalSqlitePluginPackageLifecycleRepository(authority, {
|
||||
registry: fixture.registry,
|
||||
activeSourceLimit:
|
||||
EDGE_PLUGIN_PACKAGE_LIFECYCLE_ACTIVE_SOURCE_LIMIT,
|
||||
}),
|
||||
approvalSequence: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function publishActivePackage(value) {
|
||||
await activateInstall(value.install, value.fixture);
|
||||
await value.materialized.publish(value.fixture.revision);
|
||||
if (
|
||||
pluginPackageAutomationDefinitionsFromRevision(
|
||||
value.fixture.revision,
|
||||
value.fixture.registry,
|
||||
)
|
||||
) {
|
||||
await value.automations.publish(
|
||||
createInitialPluginPackageAutomationPublication(
|
||||
value.fixture.revision,
|
||||
value.fixture.registry,
|
||||
1_000,
|
||||
),
|
||||
);
|
||||
}
|
||||
await value.reconciliation.reconcile(value.fixture.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return value.fixture.revision.generation;
|
||||
},
|
||||
});
|
||||
await value.snapshots.publish(
|
||||
createProjectToolDefinitionSnapshot({
|
||||
projectId: value.fixture.projectId,
|
||||
contributions: [
|
||||
projectToolDefinitionSnapshotContribution(
|
||||
value.fixture.revision,
|
||||
value.fixture.registry,
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function approveLifecycleEvent(
|
||||
value,
|
||||
impact,
|
||||
subjects = Object.freeze({
|
||||
requestedBy: OWNER,
|
||||
approvedBy: OWNER,
|
||||
}),
|
||||
) {
|
||||
value.approvalSequence += 1;
|
||||
const sequence = value.approvalSequence;
|
||||
const requestId = `lifecycle-approval-${sequence}`;
|
||||
const dispatchId = `lifecycle-dispatch-${sequence}`;
|
||||
const requestedAtMs = 10_000 * sequence + 1;
|
||||
const decidedAtMs = requestedAtMs + 1;
|
||||
const consumedAtMs = requestedAtMs + 2;
|
||||
const occurredAtMs = requestedAtMs + 3;
|
||||
const expiresAtMs = requestedAtMs + 1_000;
|
||||
const action = {
|
||||
permission: 'package.manage',
|
||||
actionType: `plugin_package.lifecycle.${impact.action}`,
|
||||
actionRef: `lifecycle:${impact.impactDigest}`,
|
||||
actionDigest: require('@qinglong/runtime-core/plugin-package-lifecycle')
|
||||
.pluginPackageLifecycleActionDigest(impact),
|
||||
previewDigest: impact.impactDigest,
|
||||
};
|
||||
await value.approval.create({
|
||||
request: createApprovalRequest({
|
||||
id: requestId,
|
||||
projectId: value.fixture.projectId,
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: subjects.requestedBy,
|
||||
requestedAtMs,
|
||||
expiresAtMs,
|
||||
requestFence: FENCE,
|
||||
}),
|
||||
audit: audit(
|
||||
auditId(sequence, 1),
|
||||
`lifecycle-http-${sequence}`,
|
||||
'approval.request',
|
||||
subjects.requestedBy,
|
||||
`auth-request-${sequence}`,
|
||||
'approval_required',
|
||||
value.fixture.projectId,
|
||||
requestedAtMs,
|
||||
),
|
||||
});
|
||||
await value.approval.decide({
|
||||
requestId,
|
||||
expectedVersion: 1,
|
||||
decisionId: `lifecycle-decision-${sequence}`,
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: subjects.approvedBy,
|
||||
authenticationId: `auth-approve-${sequence}`,
|
||||
authenticatedAtMs: decidedAtMs - 1,
|
||||
expiresAtMs,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
auditId(sequence, 2),
|
||||
`lifecycle-http-${sequence}`,
|
||||
'approval.decide',
|
||||
subjects.approvedBy,
|
||||
`auth-approve-${sequence}`,
|
||||
'allowed',
|
||||
value.fixture.projectId,
|
||||
decidedAtMs,
|
||||
),
|
||||
});
|
||||
const consumed = await value.approval.consume({
|
||||
requestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId: `lifecycle-consume-${sequence}`,
|
||||
dispatchId,
|
||||
action,
|
||||
requestedBy: subjects.requestedBy,
|
||||
consumedBy: SYSTEM,
|
||||
consumedAtMs,
|
||||
authorizationFence: FENCE,
|
||||
audit: audit(
|
||||
auditId(sequence, 3),
|
||||
`lifecycle-dispatch-cycle-${sequence}`,
|
||||
'approval.consume',
|
||||
SYSTEM,
|
||||
`auth-dispatch-${sequence}`,
|
||||
'allowed',
|
||||
value.fixture.projectId,
|
||||
consumedAtMs,
|
||||
),
|
||||
});
|
||||
return createPluginPackageLifecycleEvent({
|
||||
dispatchId: consumed.dispatch.id,
|
||||
impact,
|
||||
requestedBy: subjects.requestedBy,
|
||||
approvedBy: subjects.approvedBy,
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function taskHeads(value) {
|
||||
return value.client
|
||||
.prepare(
|
||||
`SELECT head.task_id AS "taskId",
|
||||
revision.revision,
|
||||
revision.enabled
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
WHERE head.project_id = ?
|
||||
ORDER BY head.task_id`,
|
||||
)
|
||||
.all(value.fixture.projectId)
|
||||
.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
test('atomically disables, exactly replays and restores only lifecycle Tasks', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-roundtrip');
|
||||
await publishActivePackage(value);
|
||||
const before = await value.snapshots.findCurrent(value.fixture.projectId);
|
||||
assert.equal(before.snapshot.sources.length, 1);
|
||||
|
||||
const disableImpact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(disableImpact.expected.disposition, 'active');
|
||||
assert.deepEqual(disableImpact.taskIds, [
|
||||
`pkg:${value.fixture.packageName}:alpha`,
|
||||
`pkg:${value.fixture.packageName}:beta`,
|
||||
]);
|
||||
const disableEvent = await approveLifecycleEvent(value, disableImpact);
|
||||
let authorizationChecks = 0;
|
||||
const disabled = await value.lifecycle.transition(disableEvent, () => {
|
||||
authorizationChecks += 1;
|
||||
});
|
||||
assert.equal(disabled.status, 'created');
|
||||
assert.equal(authorizationChecks, 2);
|
||||
assert.equal(disabled.receipt.lifecycle.disposition, 'disabled');
|
||||
assert.equal(disabled.receipt.capability.status, 'withdrawn');
|
||||
assert.equal(disabled.receipt.capability.retainedSourceCount, 0);
|
||||
assert.deepEqual(taskHeads(value), [
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:alpha`,
|
||||
revision: 2,
|
||||
enabled: 0,
|
||||
},
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:beta`,
|
||||
revision: 2,
|
||||
enabled: 0,
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(await value.snapshots.findCurrent(value.fixture.projectId)).snapshot.sources
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const replay = await value.lifecycle.transition(disableEvent, () => {
|
||||
authorizationChecks += 1;
|
||||
});
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(authorizationChecks, 4);
|
||||
assert.deepEqual(replay.receipt, disabled.receipt);
|
||||
|
||||
const enableImpact = await value.lifecycle.plan(
|
||||
'enable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.deepEqual(enableImpact.taskIds, disableImpact.taskIds);
|
||||
const enableEvent = await approveLifecycleEvent(value, enableImpact);
|
||||
const enabled = await value.lifecycle.transition(enableEvent, () => {});
|
||||
assert.equal(enabled.receipt.lifecycle.disposition, 'active');
|
||||
assert.equal(enabled.receipt.capability.status, 'restored');
|
||||
assert.equal(enabled.receipt.capability.retainedSourceCount, 1);
|
||||
assert.deepEqual(taskHeads(value), [
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:alpha`,
|
||||
revision: 3,
|
||||
enabled: 1,
|
||||
},
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:beta`,
|
||||
revision: 3,
|
||||
enabled: 1,
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(await value.snapshots.findCurrent(value.fixture.projectId)).snapshot.sources
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await value.lifecycle.findByEventDigest(enableEvent.eventDigest),
|
||||
enabled.receipt,
|
||||
);
|
||||
await auditLocalSqliteReadiness(value.client);
|
||||
});
|
||||
|
||||
test('atomically withdraws and restores Workflow and Prompt publications', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-automation', {
|
||||
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 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
await publishActivePackage(value);
|
||||
const initial = await value.automations.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(initial.state, 'active');
|
||||
assert.equal(initial.version, 1);
|
||||
|
||||
const disableImpact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
const disableEvent = await approveLifecycleEvent(value, disableImpact);
|
||||
let checks = 0;
|
||||
await assert.rejects(
|
||||
value.lifecycle.transition(disableEvent, () => {
|
||||
checks += 1;
|
||||
if (checks === 2) throw new Error('authorization expired');
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof PluginPackageLifecycleUnavailableError &&
|
||||
error.cause?.message === 'authorization expired',
|
||||
);
|
||||
assert.deepEqual(
|
||||
await value.automations.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
),
|
||||
initial,
|
||||
);
|
||||
assert.deepEqual(
|
||||
taskHeads(value).map(({ revision, enabled }) => ({ revision, enabled })),
|
||||
[
|
||||
{ revision: 1, enabled: 1 },
|
||||
{ revision: 1, enabled: 1 },
|
||||
],
|
||||
);
|
||||
|
||||
const disabled = await value.lifecycle.transition(disableEvent, () => {});
|
||||
const withdrawn = await value.automations.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(disabled.status, 'created');
|
||||
assert.equal(withdrawn.state, 'withdrawn');
|
||||
assert.equal(withdrawn.version, 2);
|
||||
assert.equal(withdrawn.lifecycleEventDigest, disableEvent.eventDigest);
|
||||
assert.equal(withdrawn.previousPublicationDigest, initial.publicationDigest);
|
||||
assert.deepEqual(withdrawn.definitions, initial.definitions);
|
||||
|
||||
const enableImpact = await value.lifecycle.plan(
|
||||
'enable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
const enableEvent = await approveLifecycleEvent(value, enableImpact);
|
||||
await value.lifecycle.transition(enableEvent, () => {});
|
||||
const restored = await value.automations.findCurrent(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.equal(restored.state, 'active');
|
||||
assert.equal(restored.version, 3);
|
||||
assert.equal(restored.lifecycleEventDigest, enableEvent.eventDigest);
|
||||
assert.equal(
|
||||
restored.previousPublicationDigest,
|
||||
withdrawn.publicationDigest,
|
||||
);
|
||||
assert.deepEqual(restored.definitions, initial.definitions);
|
||||
await auditLocalSqliteReadiness(value.client);
|
||||
});
|
||||
|
||||
test('diagnoses live Run blockers and retires history only after they clear', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-uninstall');
|
||||
await publishActivePackage(value);
|
||||
const disableImpact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
await value.lifecycle.transition(
|
||||
await approveLifecycleEvent(value, disableImpact),
|
||||
() => {},
|
||||
);
|
||||
const task = taskHeads(value)[0];
|
||||
value.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 (?, ?, ?, ?, 'manual', 'manual', 'runtime', 'queued',
|
||||
0, 0, 0, 1)`,
|
||||
)
|
||||
.run(
|
||||
'019f9b00-0000-4000-a000-000000000001',
|
||||
value.fixture.projectId,
|
||||
task.taskId,
|
||||
String(task.revision),
|
||||
);
|
||||
const blocked = await value.lifecycle.plan(
|
||||
'uninstall',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.deepEqual(
|
||||
blocked.blockingReferences.map(({ kind, ownerId }) => ({ kind, ownerId })),
|
||||
[
|
||||
{
|
||||
kind: 'execution_recovery',
|
||||
ownerId: '019f9b00-0000-4000-a000-000000000001',
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createPluginPackageLifecycleEvent({
|
||||
dispatchId: 'blocked-dispatch',
|
||||
impact: blocked,
|
||||
requestedBy: OWNER,
|
||||
approvedBy: OWNER,
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs: 1,
|
||||
}),
|
||||
InvalidPluginPackageLifecycleError,
|
||||
);
|
||||
|
||||
value.client
|
||||
.prepare(`UPDATE "Runs" SET status = 'succeeded' WHERE id = ?`)
|
||||
.run('019f9b00-0000-4000-a000-000000000001');
|
||||
const impact = await value.lifecycle.plan(
|
||||
'uninstall',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
assert.deepEqual(impact.blockingReferences, []);
|
||||
const retired = await value.lifecycle.transition(
|
||||
await approveLifecycleEvent(value, impact),
|
||||
() => {},
|
||||
);
|
||||
assert.equal(retired.receipt.lifecycle.disposition, 'uninstalled');
|
||||
assert.equal(retired.receipt.capability.status, 'retired');
|
||||
assert.deepEqual(retired.receipt.capability.taskTransitions, []);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleEvents"
|
||||
WHERE project_id = ? AND package_name = ?`,
|
||||
)
|
||||
.get(value.fixture.projectId, value.fixture.packageName).count,
|
||||
2,
|
||||
);
|
||||
await auditLocalSqliteReadiness(value.client);
|
||||
});
|
||||
|
||||
test('rejects stale approved impact without leaving Task or lifecycle facts', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-stale');
|
||||
await publishActivePackage(value);
|
||||
const impact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
const event = await approveLifecycleEvent(value, impact);
|
||||
const task = taskHeads(value)[0];
|
||||
value.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 (?, ?, ?, ?, 'manual', 'manual', 'runtime', 'queued',
|
||||
0, 0, 0, 1)`,
|
||||
)
|
||||
.run(
|
||||
'019f9b00-0000-4000-a000-000000000002',
|
||||
value.fixture.projectId,
|
||||
task.taskId,
|
||||
String(task.revision),
|
||||
);
|
||||
await assert.rejects(
|
||||
value.lifecycle.transition(event, () => {}),
|
||||
PluginPackageLifecycleConflictError,
|
||||
);
|
||||
assert.deepEqual(taskHeads(value).map(({ revision, enabled }) => ({
|
||||
revision,
|
||||
enabled,
|
||||
})), [
|
||||
{ revision: 1, enabled: 1 },
|
||||
{ revision: 1, enabled: 1 },
|
||||
]);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleEvents"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on dispatch subject drift and incomplete Task evidence', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-corrupt');
|
||||
await publishActivePackage(value);
|
||||
const impact = await value.lifecycle.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
);
|
||||
const approved = await approveLifecycleEvent(value, impact);
|
||||
const drifted = createPluginPackageLifecycleEvent({
|
||||
dispatchId: approved.dispatchId,
|
||||
impact,
|
||||
requestedBy: OTHER_OWNER,
|
||||
approvedBy: OTHER_OWNER,
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs: approved.occurredAtMs,
|
||||
});
|
||||
await assert.rejects(
|
||||
value.lifecycle.transition(drifted, () => {}),
|
||||
PluginPackageLifecycleConflictError,
|
||||
);
|
||||
|
||||
const created = await value.lifecycle.transition(approved, () => {});
|
||||
value.client.exec('PRAGMA foreign_keys = OFF');
|
||||
value.client
|
||||
.prepare(
|
||||
`DELETE FROM "QingLong3PluginPackageLifecycleTasks"
|
||||
WHERE event_digest = ? AND task_id = ?`,
|
||||
)
|
||||
.run(
|
||||
approved.eventDigest,
|
||||
`pkg:${value.fixture.packageName}:alpha`,
|
||||
);
|
||||
await assert.rejects(
|
||||
value.lifecycle.findByEventDigest(approved.eventDigest),
|
||||
PluginPackageLifecycleUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
auditLocalSqliteReadiness(value.client),
|
||||
LocalSqliteReadinessError,
|
||||
);
|
||||
assert.equal(created.receipt.capability.taskTransitions.length, 2);
|
||||
});
|
||||
|
||||
test('publishes lifecycle storage only through its explicit subpath', () => {
|
||||
const entrypoint = require('@qinglong/local-sqlite/plugin-package-lifecycle');
|
||||
assert.equal(
|
||||
entrypoint.LocalSqlitePluginPackageLifecycleRepository,
|
||||
LocalSqlitePluginPackageLifecycleRepository,
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist').LocalSqlitePluginPackageLifecycleRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
materializedRevisionFixture,
|
||||
registerPluginPackageMaterializedRevisionRepositoryContract,
|
||||
} = require('../../../test/contracts/pluginPackageMaterializedRevisionRepositoryContract.cjs');
|
||||
const {
|
||||
PluginPackageResourceMaterializationUnavailableError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-materialization');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
|
||||
async function createRepository(_t, fixture) {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, fixture.projectId, fixture.projectId);
|
||||
return {
|
||||
client,
|
||||
repository: new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
client,
|
||||
fixture.registry,
|
||||
),
|
||||
close: () => client.close(),
|
||||
};
|
||||
}
|
||||
|
||||
registerPluginPackageMaterializedRevisionRepositoryContract({
|
||||
name: 'SQLite Plugin Package materialized revision repository',
|
||||
namespace: 'sqlite-materialized',
|
||||
profile: 'edge',
|
||||
createRepository,
|
||||
});
|
||||
|
||||
test('fails closed when durable semantic JSON is changed in place', async (t) => {
|
||||
const fixture = materializedRevisionFixture('sqlite-corrupt');
|
||||
const harness = await createRepository(t, fixture);
|
||||
t.after(() => harness.close());
|
||||
await harness.repository.publish(fixture.revision);
|
||||
harness.client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
harness.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageMaterializedRevisions"
|
||||
SET revision_json =
|
||||
json_set(revision_json, '$.resources[0].value.name', 'Changed')
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.run(fixture.revision.generation.generationDigest);
|
||||
await assert.rejects(
|
||||
harness.repository.find(fixture.revision.generation.generationDigest),
|
||||
PluginPackageResourceMaterializationUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes storage only through the explicit subpath', () => {
|
||||
const entrypoint = require('@qinglong/local-sqlite/plugin-package-materialized-revision');
|
||||
assert.equal(
|
||||
entrypoint.LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist').LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CRASH_POINTS,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
} = require('./fixtures/pluginPackageQuarantineCrashMatrixFixture.cjs');
|
||||
|
||||
const FIXTURE_PATH = path.join(
|
||||
__dirname,
|
||||
'fixtures',
|
||||
'pluginPackageQuarantineCrashMatrixFixture.cjs',
|
||||
);
|
||||
|
||||
test(
|
||||
'survives the Plugin Package quarantine withdrawal crash matrix',
|
||||
{ timeout: 120_000 },
|
||||
async (context) => {
|
||||
const reports = [];
|
||||
for (const profile of ['edge', 'standalone']) {
|
||||
for (const [pointName, point] of Object.entries(CRASH_POINTS)) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `ql3-package-quarantine-${profile}-`),
|
||||
);
|
||||
context.after(() => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const databasePath = path.join(directory, 'runtime.sqlite');
|
||||
const markerPath = path.join(directory, 'crash-marker.json');
|
||||
await setupScenario({ databasePath, profile });
|
||||
const crashed = spawnSync(
|
||||
process.execPath,
|
||||
[FIXTURE_PATH, 'crash', databasePath, markerPath, pointName, profile],
|
||||
{ encoding: 'utf8', timeout: 30_000 },
|
||||
);
|
||||
assert.equal(
|
||||
crashed.error,
|
||||
undefined,
|
||||
`${profile}/${pointName}: ${crashed.error?.message}`,
|
||||
);
|
||||
assert.equal(
|
||||
crashed.signal,
|
||||
'SIGKILL',
|
||||
`${profile}/${pointName}: status=${crashed.status}, stderr=${crashed.stderr}`,
|
||||
);
|
||||
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
||||
assert.deepEqual(marker, {
|
||||
schema: 'qinglong/sqlite-plugin-package-quarantine-crash-marker@v1',
|
||||
point: pointName,
|
||||
pid: marker.pid,
|
||||
});
|
||||
reports.push(
|
||||
await verifyScenario({ databasePath, pointName, profile }),
|
||||
);
|
||||
assert.equal(reports.at(-1).durableAfterCrash, point.durable);
|
||||
}
|
||||
}
|
||||
assert.equal(reports.length, 10);
|
||||
assert.equal(
|
||||
reports.filter(({ crashBeforeCommit }) => crashBeforeCommit).length,
|
||||
8,
|
||||
);
|
||||
assert.equal(
|
||||
reports.filter(({ durableAfterCrash }) => durableAfterCrash).length,
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...new Set(reports.map(({ journalMode }) => journalMode))].sort(),
|
||||
['delete', 'wal'],
|
||||
);
|
||||
assert.ok(
|
||||
reports.every(
|
||||
({ integrityCheck, foreignKeyCheck }) =>
|
||||
integrityCheck === 'ok' && foreignKeyCheck === 'ok',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,470 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PluginPackageQuarantineConflictError,
|
||||
PluginPackageQuarantineUnavailableError,
|
||||
createPluginPackageQuarantineEvent,
|
||||
} = require('@qinglong/runtime-core/plugin-package-quarantine');
|
||||
const {
|
||||
RunRepositoryConstraintError,
|
||||
} = require('@qinglong/runtime-core/run-repository');
|
||||
const {
|
||||
createTaskDefinitionRevisionRef,
|
||||
} = require('@qinglong/runtime-core/task-definition-execution-compiler');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const { LocalSqliteOperationAuthority } = require('../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageTaskReconciliationRepository');
|
||||
const {
|
||||
LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
} = require('../dist/tool-execution/projectToolDefinitionSnapshotRepository');
|
||||
const { LocalSqliteRunRepository } = require('../dist/run/runRepository');
|
||||
const {
|
||||
EDGE_PLUGIN_PACKAGE_QUARANTINE_ACTIVE_SOURCE_LIMIT,
|
||||
LocalSqlitePluginPackageQuarantineRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageQuarantineRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const {
|
||||
LocalSqliteReadinessError,
|
||||
auditLocalSqliteReadiness,
|
||||
} = require('../dist/readiness/readiness');
|
||||
|
||||
const digest = (value) => value.repeat(64);
|
||||
|
||||
async function harness(t, namespace) {
|
||||
const fixture = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
profile: 'edge',
|
||||
});
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, fixture.projectId, fixture.projectId);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
t.after(() => authority.close());
|
||||
return {
|
||||
fixture,
|
||||
client,
|
||||
install: new LocalSqlitePluginPackageInstallRepository(authority),
|
||||
materialized: new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
),
|
||||
reconciliation: new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
),
|
||||
snapshots: new LocalSqliteProjectToolDefinitionSnapshotRepository(
|
||||
authority,
|
||||
),
|
||||
runs: new LocalSqliteRunRepository(authority),
|
||||
quarantine: new LocalSqlitePluginPackageQuarantineRepository(authority, {
|
||||
registry: fixture.registry,
|
||||
activeSourceLimit: EDGE_PLUGIN_PACKAGE_QUARANTINE_ACTIVE_SOURCE_LIMIT,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function quarantineEvent(fixture, record = fixture.install.active) {
|
||||
return createPluginPackageQuarantineEvent({
|
||||
mutationId: `quarantine-${fixture.namespace}`,
|
||||
revocationReceiptDigest: digest('d'),
|
||||
impactDigest: digest('e'),
|
||||
target: {
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
installationId: record.installationId,
|
||||
lockDigest: record.lockDigest,
|
||||
installState: record.state,
|
||||
installVersion: record.version,
|
||||
installRecordDigest: record.recordDigest,
|
||||
activeLockDigest: record.activeLockDigest,
|
||||
},
|
||||
proposer: { type: 'user', id: 'owner-a' },
|
||||
confirmer: { type: 'user', id: 'owner-b' },
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
occurredAtMs: record.updatedAtMs + 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function publishActivePackage(value) {
|
||||
await activateInstall(value.install, value.fixture);
|
||||
await value.materialized.publish(value.fixture.revision);
|
||||
await value.reconciliation.reconcile(value.fixture.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return value.fixture.revision.generation;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('withdraws active Package Tasks and Tool source in one exact replayable transaction', async (t) => {
|
||||
const value = await harness(t, 'sqlite-quarantine-active');
|
||||
await publishActivePackage(value);
|
||||
const event = quarantineEvent(value.fixture);
|
||||
assert.deepEqual(
|
||||
await value.quarantine.findTargetsByLockDigest(event.target.lockDigest),
|
||||
[event.target],
|
||||
);
|
||||
|
||||
let authorizationChecks = 0;
|
||||
const created = await value.quarantine.quarantine(event, () => {
|
||||
authorizationChecks += 1;
|
||||
});
|
||||
assert.equal(authorizationChecks, 2);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(created.receipt.capability.status, 'withdrawn');
|
||||
assert.deepEqual(
|
||||
created.receipt.capability.taskWithdrawals.map(
|
||||
({ taskId, previousRevision, disabledRevision }) => ({
|
||||
taskId,
|
||||
previousRevision,
|
||||
disabledRevision,
|
||||
}),
|
||||
),
|
||||
[
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:alpha`,
|
||||
previousRevision: 1,
|
||||
disabledRevision: 2,
|
||||
},
|
||||
{
|
||||
taskId: `pkg:${value.fixture.packageName}:beta`,
|
||||
previousRevision: 1,
|
||||
disabledRevision: 2,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(created.receipt.capability.retainedSourceCount, 0);
|
||||
assert.notEqual(
|
||||
created.receipt.capability.previousActiveVectorDigest,
|
||||
created.receipt.capability.currentActiveVectorDigest,
|
||||
);
|
||||
assert.deepEqual(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT task_id AS "taskId", current_revision AS "revision"
|
||||
FROM "QingLong3TaskDefinitions"
|
||||
WHERE project_id = ? ORDER BY task_id`,
|
||||
)
|
||||
.all(value.fixture.projectId)
|
||||
.map((row) => ({ ...row })),
|
||||
[
|
||||
{ taskId: `pkg:${value.fixture.packageName}:alpha`, revision: 2 },
|
||||
{ taskId: `pkg:${value.fixture.packageName}:beta`, revision: 2 },
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3TaskDefinitionRevisions"
|
||||
WHERE project_id = ? AND enabled = 0`,
|
||||
)
|
||||
.get(value.fixture.projectId).count,
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3ProjectToolDefinitionSnapshotSources"
|
||||
WHERE project_id = ? AND active_vector_digest = ?`,
|
||||
)
|
||||
.get(
|
||||
value.fixture.projectId,
|
||||
created.receipt.capability.currentActiveVectorDigest,
|
||||
).count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(await value.snapshots.findCurrent(value.fixture.projectId)).snapshot
|
||||
.snapshotDigest,
|
||||
created.receipt.capability.currentToolSnapshotDigest,
|
||||
);
|
||||
|
||||
const replay = await value.quarantine.quarantine(event, () => {
|
||||
authorizationChecks += 1;
|
||||
});
|
||||
assert.equal(authorizationChecks, 4);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.receipt, created.receipt);
|
||||
assert.deepEqual(
|
||||
await value.quarantine.findByEventDigest(event.eventDigest),
|
||||
created.receipt,
|
||||
);
|
||||
});
|
||||
|
||||
test('records a queued target without inventing Task or Tool withdrawal', async (t) => {
|
||||
const value = await harness(t, 'sqlite-quarantine-queued');
|
||||
await value.install.create(value.fixture.install.create);
|
||||
const event = quarantineEvent(value.fixture, value.fixture.install.queued);
|
||||
const created = await value.quarantine.quarantine(event, () => {});
|
||||
assert.equal(created.receipt.capability.status, 'not_active');
|
||||
assert.deepEqual(created.receipt.capability.taskWithdrawals, []);
|
||||
assert.deepEqual(await value.install.listRecoveryPage({ limit: 1 }), {
|
||||
records: [],
|
||||
truncated: false,
|
||||
});
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3ProjectToolDefinitionSnapshots"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('rolls back every withdrawal fact when the target install advanced', async (t) => {
|
||||
const value = await harness(t, 'sqlite-quarantine-stale');
|
||||
await publishActivePackage(value);
|
||||
const stale = quarantineEvent(value.fixture, {
|
||||
...value.fixture.install.active,
|
||||
version: value.fixture.install.active.version - 1,
|
||||
});
|
||||
await assert.rejects(
|
||||
value.quarantine.quarantine(stale, () => {}),
|
||||
PluginPackageQuarantineConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageQuarantineEvents"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3TaskDefinitionRevisions"
|
||||
WHERE project_id = ? AND enabled = 0`,
|
||||
)
|
||||
.get(value.fixture.projectId).count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('rolls back withdrawal when the in-transaction Owner fence changes before commit', async (t) => {
|
||||
const value = await harness(t, 'sqlite-quarantine-owner-fence');
|
||||
await publishActivePackage(value);
|
||||
let checks = 0;
|
||||
await assert.rejects(
|
||||
value.quarantine.quarantine(quarantineEvent(value.fixture), () => {
|
||||
checks += 1;
|
||||
if (checks === 2) throw new Error('Owner fence changed');
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof PluginPackageQuarantineUnavailableError &&
|
||||
error.cause?.message === 'Owner fence changed',
|
||||
);
|
||||
assert.equal(checks, 2);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageQuarantineEvents"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3TaskDefinitionRevisions"
|
||||
WHERE project_id = ? AND enabled = 0`,
|
||||
)
|
||||
.get(value.fixture.projectId).count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when durable withdrawal task evidence is incomplete', async (t) => {
|
||||
const value = await harness(t, 'sqlite-quarantine-corrupt');
|
||||
await publishActivePackage(value);
|
||||
const event = quarantineEvent(value.fixture);
|
||||
await value.quarantine.quarantine(event, () => {});
|
||||
value.client.exec('PRAGMA foreign_keys = OFF');
|
||||
value.client
|
||||
.prepare(
|
||||
`DELETE FROM "QingLong3PluginPackageWithdrawalTasks"
|
||||
WHERE event_digest = ? AND task_id = ?`,
|
||||
)
|
||||
.run(event.eventDigest, `pkg:${value.fixture.packageName}:alpha`);
|
||||
await assert.rejects(
|
||||
value.quarantine.findByEventDigest(event.eventDigest),
|
||||
PluginPackageQuarantineUnavailableError,
|
||||
);
|
||||
await assert.rejects(
|
||||
auditLocalSqliteReadiness(value.client),
|
||||
LocalSqliteReadinessError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects dispatch of a Run pinned to the quarantined Package Task revision', async (t) => {
|
||||
const value = await harness(t, 'sqlite-quarantine-run-fence');
|
||||
await publishActivePackage(value);
|
||||
const task = value.client
|
||||
.prepare(
|
||||
`SELECT head.task_id AS "taskId", revision.revision,
|
||||
revision.content_digest AS "contentDigest"
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
WHERE head.project_id = ?
|
||||
ORDER BY head.task_id LIMIT 1`,
|
||||
)
|
||||
.get(value.fixture.projectId);
|
||||
const run = {
|
||||
id: '019f9a00-0000-4000-a000-000000000001',
|
||||
projectId: value.fixture.projectId,
|
||||
taskId: task.taskId,
|
||||
taskRevision: createTaskDefinitionRevisionRef({
|
||||
revision: task.revision,
|
||||
contentDigest: task.contentDigest,
|
||||
}),
|
||||
taskName: 'quarantine fence',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'queued',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: 1_000,
|
||||
queuedAtMs: 1_001,
|
||||
};
|
||||
await value.runs.transaction((transaction) => transaction.insertRun(run));
|
||||
await value.quarantine.quarantine(quarantineEvent(value.fixture), () => {});
|
||||
await assert.rejects(
|
||||
value.runs.transaction((transaction) =>
|
||||
transaction.compareAndSetRun(
|
||||
{ ...run, status: 'dispatching', version: 1 },
|
||||
0,
|
||||
),
|
||||
),
|
||||
RunRepositoryConstraintError,
|
||||
);
|
||||
assert.equal((await value.runs.findRunById(run.id)).status, 'queued');
|
||||
});
|
||||
|
||||
test('rejects a Package Run while lifecycle is disabled and admits it after enable', async (t) => {
|
||||
const value = await harness(t, 'sqlite-lifecycle-run-fence');
|
||||
await publishActivePackage(value);
|
||||
const task = value.client
|
||||
.prepare(
|
||||
`SELECT head.task_id AS "taskId", revision.revision,
|
||||
revision.content_digest AS "contentDigest"
|
||||
FROM "QingLong3TaskDefinitions" AS head
|
||||
JOIN "QingLong3TaskDefinitionRevisions" AS revision
|
||||
ON revision.project_id = head.project_id
|
||||
AND revision.task_id = head.task_id
|
||||
AND revision.revision = head.current_revision
|
||||
WHERE head.project_id = ?
|
||||
ORDER BY head.task_id LIMIT 1`,
|
||||
)
|
||||
.get(value.fixture.projectId);
|
||||
const run = {
|
||||
id: '019f9a00-0000-4000-a000-000000000002',
|
||||
projectId: value.fixture.projectId,
|
||||
taskId: task.taskId,
|
||||
taskRevision: createTaskDefinitionRevisionRef({
|
||||
revision: task.revision,
|
||||
contentDigest: task.contentDigest,
|
||||
}),
|
||||
taskName: 'lifecycle fence',
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
status: 'queued',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: 1_000,
|
||||
queuedAtMs: 1_001,
|
||||
};
|
||||
await value.runs.transaction((transaction) => transaction.insertRun(run));
|
||||
value.client.exec('PRAGMA foreign_keys = OFF');
|
||||
value.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3PluginPackageLifecycleHeads" (
|
||||
project_id, package_name, installation_id, lock_digest,
|
||||
install_record_digest, version, disposition, event_digest,
|
||||
updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, 1, 'disabled', ?, 2)`,
|
||||
)
|
||||
.run(
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
value.fixture.install.active.installationId,
|
||||
value.fixture.install.active.lockDigest,
|
||||
value.fixture.install.active.recordDigest,
|
||||
digest('f'),
|
||||
);
|
||||
value.client.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
await assert.rejects(
|
||||
value.runs.transaction((transaction) =>
|
||||
transaction.compareAndSetRun(
|
||||
{ ...run, status: 'dispatching', version: 1 },
|
||||
0,
|
||||
),
|
||||
),
|
||||
RunRepositoryConstraintError,
|
||||
);
|
||||
assert.equal((await value.runs.findRunById(run.id)).status, 'queued');
|
||||
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageLifecycleHeads"
|
||||
SET disposition = 'active', version = 2, updated_at_ms = 3
|
||||
WHERE project_id = ? AND package_name = ?`,
|
||||
)
|
||||
.run(value.fixture.projectId, value.fixture.packageName);
|
||||
assert.equal(
|
||||
await value.runs.transaction((transaction) =>
|
||||
transaction.compareAndSetRun(
|
||||
{ ...run, status: 'dispatching', version: 1 },
|
||||
0,
|
||||
),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal((await value.runs.findRunById(run.id)).status, 'dispatching');
|
||||
});
|
||||
|
||||
test('publishes quarantine storage only through its explicit subpath', () => {
|
||||
const entrypoint = require('@qinglong/local-sqlite/plugin-package-quarantine');
|
||||
assert.equal(
|
||||
entrypoint.LocalSqlitePluginPackageQuarantineRepository,
|
||||
LocalSqlitePluginPackageQuarantineRepository,
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist').LocalSqlitePluginPackageQuarantineRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PluginPackageTaskReconciliationConflictError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-task-reconciliation');
|
||||
const {
|
||||
TaskDefinitionConflictError,
|
||||
} = require('@qinglong/runtime-core/task-definition');
|
||||
const {
|
||||
registerPluginPackageTaskReconciliationRepositoryContract,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageTaskReconciliationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqliteTaskDefinitionRepository,
|
||||
} = require('../dist/task-definition/taskDefinitionRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
|
||||
async function createRepository(_t, fixture) {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, fixture.projectId, fixture.projectId);
|
||||
return {
|
||||
client,
|
||||
repository:
|
||||
new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
client,
|
||||
fixture.registry,
|
||||
),
|
||||
materializedRepository:
|
||||
new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
client,
|
||||
fixture.registry,
|
||||
),
|
||||
installRepository: new LocalSqlitePluginPackageInstallRepository(client),
|
||||
taskRepository: new LocalSqliteTaskDefinitionRepository(
|
||||
client,
|
||||
fixture.registry,
|
||||
),
|
||||
close: () => client.close(),
|
||||
};
|
||||
}
|
||||
|
||||
registerPluginPackageTaskReconciliationRepositoryContract({
|
||||
name: 'SQLite Plugin Package Task reconciliation repository',
|
||||
namespace: 'sqlite-task-reconcile',
|
||||
profile: 'edge',
|
||||
createRepository,
|
||||
async assertGenericWriteRejected(harness, fixture) {
|
||||
const task = await harness.taskRepository.findCurrentTaskDefinition(
|
||||
fixture.projectId,
|
||||
`pkg:${fixture.packageName}:alpha`,
|
||||
);
|
||||
await assert.rejects(
|
||||
harness.taskRepository.appendTaskDefinitionRevision({
|
||||
projectId: task.projectId,
|
||||
taskId: task.taskId,
|
||||
expectedRevision: task.revision,
|
||||
mutationId: '019f9000-0000-4000-a000-000000000001',
|
||||
name: 'Bypass',
|
||||
kind: task.kind,
|
||||
spec: task.spec,
|
||||
labels: task.labels,
|
||||
enabled: task.enabled,
|
||||
occurredAtMs: task.updatedAtMs + 1,
|
||||
}),
|
||||
TaskDefinitionConflictError,
|
||||
);
|
||||
},
|
||||
async assertDurableUpgrade(harness, fixture) {
|
||||
const beta = await harness.taskRepository.findCurrentTaskDefinition(
|
||||
fixture.projectId,
|
||||
`pkg:${fixture.packageName}:beta`,
|
||||
);
|
||||
assert.equal(beta.revision, 2);
|
||||
assert.equal(beta.enabled, false);
|
||||
assert.equal(
|
||||
harness.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageTaskOwnerships"
|
||||
WHERE project_id = ? AND package_name = ?`,
|
||||
)
|
||||
.get(fixture.projectId, fixture.packageName).count,
|
||||
3,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
test('rolls back when the external generation fence changes', async (t) => {
|
||||
const {
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
activateInstall,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const fixture = pluginPackageTaskReconciliationFixture(
|
||||
'sqlite-task-reconcile-fence',
|
||||
);
|
||||
const harness = await createRepository(t, fixture);
|
||||
t.after(() => harness.close());
|
||||
await activateInstall(harness.installRepository, fixture);
|
||||
await harness.materializedRepository.publish(fixture.revision);
|
||||
await assert.rejects(
|
||||
harness.repository.reconcile(fixture.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
PluginPackageTaskReconciliationConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
harness.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3TaskDefinitions" WHERE project_id = ?`,
|
||||
)
|
||||
.get(fixture.projectId).count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await harness.repository.find(fixture.revision.generation.generationDigest),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes storage only through the explicit subpath', () => {
|
||||
const entrypoint = require('@qinglong/local-sqlite/plugin-package-task-reconciliation');
|
||||
assert.equal(
|
||||
entrypoint.LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist').LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CRASH_POINTS,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
} = require('./fixtures/pluginPackageWorkflowAdmissionCrashMatrixFixture.cjs');
|
||||
|
||||
const FIXTURE_PATH = path.join(
|
||||
__dirname,
|
||||
'fixtures',
|
||||
'pluginPackageWorkflowAdmissionCrashMatrixFixture.cjs',
|
||||
);
|
||||
|
||||
test(
|
||||
'survives the Plugin Package Workflow admission crash matrix',
|
||||
{ timeout: 180_000 },
|
||||
async (context) => {
|
||||
const reports = [];
|
||||
for (const profile of ['edge', 'standalone']) {
|
||||
for (const [pointName, point] of Object.entries(CRASH_POINTS)) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
`ql3-workflow-admission-${profile}-${pointName}-`,
|
||||
),
|
||||
);
|
||||
context.after(() => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const databasePath = path.join(directory, 'runtime.sqlite');
|
||||
const markerPath = path.join(directory, 'crash-marker.json');
|
||||
await setupScenario({ databasePath, profile });
|
||||
const crashed = spawnSync(
|
||||
process.execPath,
|
||||
[FIXTURE_PATH, 'crash', databasePath, markerPath, pointName, profile],
|
||||
{ encoding: 'utf8', timeout: 30_000 },
|
||||
);
|
||||
assert.equal(
|
||||
crashed.error,
|
||||
undefined,
|
||||
`${profile}/${pointName}: ${crashed.error?.message}`,
|
||||
);
|
||||
assert.equal(
|
||||
crashed.signal,
|
||||
'SIGKILL',
|
||||
`${profile}/${pointName}: status=${crashed.status}, stderr=${crashed.stderr}`,
|
||||
);
|
||||
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
||||
assert.deepEqual(marker, {
|
||||
schema:
|
||||
'qinglong/sqlite-plugin-package-workflow-admission-crash-marker@v1',
|
||||
point: pointName,
|
||||
pid: marker.pid,
|
||||
});
|
||||
const report = await verifyScenario({
|
||||
databasePath,
|
||||
pointName,
|
||||
profile,
|
||||
});
|
||||
assert.equal(report.durableAfterCrash, point.durable);
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
assert.equal(reports.length, 16);
|
||||
assert.equal(
|
||||
reports.filter(({ crashBeforeCommit }) => crashBeforeCommit).length,
|
||||
14,
|
||||
);
|
||||
assert.equal(
|
||||
reports.filter(({ durableAfterCrash }) => durableAfterCrash).length,
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...new Set(reports.map(({ journalMode }) => journalMode))].sort(),
|
||||
['delete', 'wal'],
|
||||
);
|
||||
assert.ok(reports.every(({ synchronous }) => synchronous === 2));
|
||||
assert.ok(
|
||||
reports.every(
|
||||
({ exactReplay, integrityCheck, foreignKeyCheck }) =>
|
||||
exactReplay && integrityCheck === 'ok' && foreignKeyCheck === 'ok',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,397 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
PluginPackageWorkflowAdmissionConflictError,
|
||||
PluginPackageWorkflowAdmissionNotAllowedError,
|
||||
PluginPackageWorkflowAdmissionUnavailableError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const {
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-administration');
|
||||
const {
|
||||
transitionStepRunMutation,
|
||||
} = require('@qinglong/runtime-core/step-run');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublicationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowAdmissionRepository,
|
||||
} = require('../dist/plugin-package/workflow/pluginPackageWorkflowAdmissionRepository');
|
||||
const { LocalSqliteStepRunRepository } = require('../dist/run/stepRunRepository');
|
||||
const { auditLocalSqliteReadiness } = require('../dist/readiness/readiness');
|
||||
|
||||
function fixture(namespace) {
|
||||
const value = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
...value,
|
||||
publication: createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
2_000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function plan(value, overrides = {}) {
|
||||
return createPluginPackageWorkflowExecutionPlan({
|
||||
planId: `workflow-plan-${value.namespace}`,
|
||||
runId: `run-${value.namespace}`,
|
||||
workflowId: 'daily',
|
||||
stepRunIds: {
|
||||
collect: `step-collect-${value.namespace}`,
|
||||
summarize: `step-summarize-${value.namespace}`,
|
||||
},
|
||||
publication: value.publication,
|
||||
revision: value.revision,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
plannedAtMs: 3_000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function harness(t, namespace, { active = true } = {}) {
|
||||
const value = fixture(namespace);
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(value.projectId, value.projectId, value.projectId);
|
||||
if (active) {
|
||||
await activateInstall(
|
||||
new LocalSqlitePluginPackageInstallRepository(client),
|
||||
value,
|
||||
);
|
||||
}
|
||||
await new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
client,
|
||||
value.registry,
|
||||
).publish(value.revision);
|
||||
await new LocalSqlitePluginPackageAutomationPublicationRepository(
|
||||
client,
|
||||
).publish(value.publication);
|
||||
t.after(() => client.close());
|
||||
return {
|
||||
client,
|
||||
value,
|
||||
repository: new LocalSqlitePluginPackageWorkflowAdmissionRepository(client),
|
||||
};
|
||||
}
|
||||
|
||||
test('atomically admits one generation-bound Workflow Run and exactly replays it', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-admit',
|
||||
);
|
||||
const executionPlan = plan(value);
|
||||
const created = await repository.admit(executionPlan);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(created.receipt.finalRunVersion, 3);
|
||||
assert.equal(created.receipt.finalRunEventSequence, 3);
|
||||
|
||||
const replay = await repository.admit(
|
||||
JSON.parse(JSON.stringify(executionPlan)),
|
||||
);
|
||||
assert.deepEqual(replay, {
|
||||
status: 'existing',
|
||||
receipt: created.receipt,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await repository.findByPlanId(executionPlan.planId),
|
||||
created.receipt,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await repository.findByRunId(executionPlan.runId),
|
||||
created.receipt,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM "Runs") AS runs,
|
||||
(SELECT COUNT(*) FROM "StepRuns") AS steps,
|
||||
(SELECT COUNT(*) FROM "RunEvents") AS events,
|
||||
(SELECT COUNT(*) FROM "StepRunMutations") AS mutations,
|
||||
(SELECT COUNT(*)
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissions") AS admissions`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ runs: 1, steps: 2, events: 3, mutations: 2, admissions: 1 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 43);
|
||||
});
|
||||
|
||||
test('runs an optional authorization guard inside new and replay transactions', async (t) => {
|
||||
const { value, repository } = await harness(t, 'tx-guard');
|
||||
const executionPlan = plan(value);
|
||||
const observations = [];
|
||||
const created = await repository.admit(executionPlan, (context) => {
|
||||
observations.push({
|
||||
replay: context.replay,
|
||||
planId: context.plan.planId,
|
||||
receiptDigest: context.receipt.receiptDigest,
|
||||
});
|
||||
});
|
||||
assert.deepEqual(
|
||||
await repository.findPlanByPlanId(executionPlan.planId),
|
||||
executionPlan,
|
||||
);
|
||||
await repository.admit(executionPlan, (context) => {
|
||||
observations.push({
|
||||
replay: context.replay,
|
||||
planId: context.plan.planId,
|
||||
receiptDigest: context.receipt.receiptDigest,
|
||||
});
|
||||
});
|
||||
assert.deepEqual(observations, [
|
||||
{
|
||||
replay: false,
|
||||
planId: executionPlan.planId,
|
||||
receiptDigest: created.receipt.receiptDigest,
|
||||
},
|
||||
{
|
||||
replay: true,
|
||||
planId: executionPlan.planId,
|
||||
receiptDigest: created.receipt.receiptDigest,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('rolls back Workflow admission when the transaction authorization guard rejects', async (t) => {
|
||||
const { client, value, repository } = await harness(t, 'tx-reject');
|
||||
const executionPlan = plan(value);
|
||||
await assert.rejects(
|
||||
repository.admit(executionPlan, () => {
|
||||
throw new PluginPackageWorkflowAdministrationAuthorizationFenceConflictError();
|
||||
}),
|
||||
PluginPackageWorkflowAdministrationAuthorizationFenceConflictError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM "Runs") AS runs,
|
||||
(SELECT COUNT(*) FROM "StepRuns") AS steps,
|
||||
(SELECT COUNT(*) FROM "QingLong3PluginPackageWorkflowAdmissions") AS admissions`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ runs: 0, steps: 0, admissions: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
test('exactly replays immutable admission after the Workflow StepRun advances', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-progress-replay',
|
||||
);
|
||||
const executionPlan = plan(value);
|
||||
const created = await repository.admit(executionPlan);
|
||||
const stepRuns = new LocalSqliteStepRunRepository(client);
|
||||
const collect = await stepRuns.findByRunAndStepKey(
|
||||
executionPlan.runId,
|
||||
'collect',
|
||||
);
|
||||
assert.ok(collect);
|
||||
const running = transitionStepRunMutation(
|
||||
collect,
|
||||
{
|
||||
expectedVersion: collect.version,
|
||||
expectedDigest: collect.stepRunDigest,
|
||||
mutationId: 'workflow-progress-running',
|
||||
to: 'running',
|
||||
atMs: 4_000,
|
||||
},
|
||||
{
|
||||
expectedRunVersion: created.receipt.finalRunVersion,
|
||||
expectedRunEventSequence: created.receipt.finalRunEventSequence,
|
||||
eventId: 'workflow-progress-running-event',
|
||||
dedupeKey: 'workflow-progress-running-event',
|
||||
actor: { type: 'executor' },
|
||||
},
|
||||
);
|
||||
await stepRuns.apply(running);
|
||||
const succeeded = transitionStepRunMutation(
|
||||
running.stepRun,
|
||||
{
|
||||
expectedVersion: running.stepRun.version,
|
||||
expectedDigest: running.stepRun.stepRunDigest,
|
||||
mutationId: 'workflow-progress-succeeded',
|
||||
to: 'succeeded',
|
||||
atMs: 5_000,
|
||||
},
|
||||
{
|
||||
expectedRunVersion: created.receipt.finalRunVersion + 1,
|
||||
expectedRunEventSequence: created.receipt.finalRunEventSequence + 1,
|
||||
eventId: 'workflow-progress-succeeded-event',
|
||||
dedupeKey: 'workflow-progress-succeeded-event',
|
||||
actor: { type: 'executor' },
|
||||
},
|
||||
);
|
||||
await stepRuns.apply(succeeded);
|
||||
|
||||
assert.deepEqual(await repository.admit(executionPlan), {
|
||||
status: 'existing',
|
||||
receipt: created.receipt,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await repository.findByRunId(executionPlan.runId),
|
||||
created.receipt,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT status, version, event_sequence AS "eventSequence"
|
||||
FROM "Runs" WHERE id = ?`,
|
||||
)
|
||||
.get(executionPlan.runId),
|
||||
},
|
||||
{ status: 'running', version: 5, eventSequence: 5 },
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 43);
|
||||
});
|
||||
|
||||
test('fails closed before writing when the exact installation is not active', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-not-allowed',
|
||||
{ active: false },
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.admit(plan(value)),
|
||||
PluginPackageWorkflowAdmissionNotAllowedError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM "Runs") AS runs,
|
||||
(SELECT COUNT(*)
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissions") AS admissions`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ runs: 0, admissions: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
test('rolls back every Run artifact on identity collision or Task drift', async (t) => {
|
||||
const first = await harness(t, 'sqlite-workflow-collision');
|
||||
const firstPlan = plan(first.value);
|
||||
await first.repository.admit(firstPlan);
|
||||
const colliding = plan(first.value, {
|
||||
planId: `${firstPlan.planId}-other`,
|
||||
});
|
||||
await assert.rejects(
|
||||
first.repository.admit(colliding),
|
||||
PluginPackageWorkflowAdmissionConflictError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...first.client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM "Runs") AS runs,
|
||||
(SELECT COUNT(*) FROM "StepRuns") AS steps,
|
||||
(SELECT COUNT(*) FROM "RunEvents") AS events,
|
||||
(SELECT COUNT(*)
|
||||
FROM "QingLong3PluginPackageWorkflowAdmissions") AS admissions`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ runs: 1, steps: 2, events: 3, admissions: 1 },
|
||||
);
|
||||
|
||||
const drift = await harness(t, 'sqlite-workflow-task-drift');
|
||||
drift.client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
drift.client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageMaterializedRevisions"
|
||||
SET revision_json = json_set(
|
||||
revision_json, '$.resources[0].value.enabled', json('false')
|
||||
)
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.run(drift.value.revision.generation.generationDigest);
|
||||
await assert.rejects(
|
||||
drift.repository.admit(plan(drift.value)),
|
||||
PluginPackageWorkflowAdmissionConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
drift.client.prepare(`SELECT COUNT(*) AS count FROM "Runs"`).get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects durable Workflow admission evidence changed in place', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-corrupt',
|
||||
);
|
||||
const executionPlan = plan(value);
|
||||
await repository.admit(executionPlan);
|
||||
client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageWorkflowAdmissions"
|
||||
SET receipt_json = json_set(receipt_json, '$.workflowId', 'changed')
|
||||
WHERE plan_digest = ?`,
|
||||
)
|
||||
.run(executionPlan.planDigest);
|
||||
await assert.rejects(
|
||||
repository.findByPlanId(executionPlan.planId),
|
||||
PluginPackageWorkflowAdmissionUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes Workflow admission only through an explicit local SQLite subpath', () => {
|
||||
const subpath = require('@qinglong/local-sqlite/plugin-package-workflow-admission');
|
||||
const root = require('../dist');
|
||||
assert.equal(
|
||||
subpath.LocalSqlitePluginPackageWorkflowAdmissionRepository,
|
||||
LocalSqlitePluginPackageWorkflowAdmissionRepository,
|
||||
);
|
||||
assert.equal(
|
||||
root.LocalSqlitePluginPackageWorkflowAdmissionRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const {
|
||||
InvalidPluginPackageWorkflowFrontierError,
|
||||
PluginPackageWorkflowFrontierUnavailableError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-frontier');
|
||||
const {
|
||||
transitionStepRunMutation,
|
||||
} = require('@qinglong/runtime-core/step-run');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublicationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowAdmissionRepository,
|
||||
} = require('../dist/plugin-package/workflow/pluginPackageWorkflowAdmissionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowFrontierRepository,
|
||||
} = require('../dist/plugin-package/workflow/pluginPackageWorkflowFrontierRepository');
|
||||
const {
|
||||
LocalSqliteStepRunRepository,
|
||||
} = require('../dist/run/stepRunRepository');
|
||||
|
||||
function fixture(namespace) {
|
||||
const value = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
2_000,
|
||||
);
|
||||
const plan = createPluginPackageWorkflowExecutionPlan({
|
||||
planId: `wf-plan-${namespace}`,
|
||||
runId: `wf-run-${namespace}`,
|
||||
workflowId: 'daily',
|
||||
stepRunIds: {
|
||||
collect: `wf-collect-${namespace}`,
|
||||
summarize: `wf-summary-${namespace}`,
|
||||
},
|
||||
publication,
|
||||
revision: value.revision,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
plannedAtMs: 3_000,
|
||||
});
|
||||
return { ...value, publication, plan };
|
||||
}
|
||||
|
||||
async function harness(t, namespace) {
|
||||
const value = fixture(namespace);
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(value.projectId, value.projectId, value.projectId);
|
||||
await activateInstall(
|
||||
new LocalSqlitePluginPackageInstallRepository(client),
|
||||
value,
|
||||
);
|
||||
await new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
client,
|
||||
value.registry,
|
||||
).publish(value.revision);
|
||||
await new LocalSqlitePluginPackageAutomationPublicationRepository(
|
||||
client,
|
||||
).publish(value.publication);
|
||||
const admission =
|
||||
new LocalSqlitePluginPackageWorkflowAdmissionRepository(client);
|
||||
const admitted = await admission.admit(value.plan);
|
||||
t.after(() => client.close());
|
||||
return {
|
||||
client,
|
||||
value,
|
||||
admission,
|
||||
admitted,
|
||||
frontier:
|
||||
new LocalSqlitePluginPackageWorkflowFrontierRepository(client),
|
||||
stepRuns: new LocalSqliteStepRunRepository(client),
|
||||
};
|
||||
}
|
||||
|
||||
async function transitionStep(
|
||||
harnessValue,
|
||||
stepKey,
|
||||
to,
|
||||
runVersion,
|
||||
atMs,
|
||||
) {
|
||||
const current = await harnessValue.stepRuns.findByRunAndStepKey(
|
||||
harnessValue.value.plan.runId,
|
||||
stepKey,
|
||||
);
|
||||
assert.ok(current);
|
||||
const mutation = transitionStepRunMutation(
|
||||
current,
|
||||
{
|
||||
expectedVersion: current.version,
|
||||
expectedDigest: current.stepRunDigest,
|
||||
mutationId: `${stepKey}-${to}-${current.version}`,
|
||||
to,
|
||||
atMs,
|
||||
...(to === 'failed' ? { resultCode: 'task_failed' } : {}),
|
||||
},
|
||||
{
|
||||
expectedRunVersion: runVersion,
|
||||
expectedRunEventSequence: runVersion,
|
||||
eventId: `${stepKey}-${to}-event-${current.version}`,
|
||||
dedupeKey: `${stepKey}-${to}-event-${current.version}`,
|
||||
actor: { type: 'executor' },
|
||||
},
|
||||
);
|
||||
return harnessValue.stepRuns.apply(mutation);
|
||||
}
|
||||
|
||||
test('advances only an actionable dependency and terminalizes exactly once', async (t) => {
|
||||
const value = await harness(t, 'frontier-ok');
|
||||
assert.deepEqual(await value.frontier.listCandidates({ limit: 8 }), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
await transitionStep(value, 'collect', 'running', 3, 4_000);
|
||||
await transitionStep(value, 'collect', 'succeeded', 4, 5_000);
|
||||
const actionable = await value.frontier.listCandidates({ limit: 8 });
|
||||
assert.deepEqual(actionable, {
|
||||
candidates: [
|
||||
{
|
||||
runId: value.value.plan.runId,
|
||||
planDigest: value.value.plan.planDigest,
|
||||
admittedAtMs: 3_000,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const advanced = await value.frontier.advance(value.value.plan.runId);
|
||||
assert.equal(advanced.status, 'advanced');
|
||||
assert.equal(advanced.stepMutationCount, 1);
|
||||
assert.deepEqual(advanced.readyStepRunIds, [
|
||||
value.value.plan.steps.find(({ stepKey }) => stepKey === 'summarize')
|
||||
.stepRunId,
|
||||
]);
|
||||
assert.equal(advanced.runVersion, 6);
|
||||
assert.deepEqual(await value.frontier.listCandidates({ limit: 8 }), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
await transitionStep(
|
||||
value,
|
||||
'summarize',
|
||||
'running',
|
||||
6,
|
||||
advanced.observedAtMs + 1,
|
||||
);
|
||||
await transitionStep(
|
||||
value,
|
||||
'summarize',
|
||||
'succeeded',
|
||||
7,
|
||||
advanced.observedAtMs + 2,
|
||||
);
|
||||
assert.equal(
|
||||
(await value.frontier.listCandidates({ limit: 8 })).candidates.length,
|
||||
1,
|
||||
);
|
||||
const terminal = await value.frontier.advance(value.value.plan.runId);
|
||||
assert.equal(terminal.status, 'terminal');
|
||||
assert.equal(terminal.terminalStatus, 'succeeded');
|
||||
assert.equal(terminal.runVersion, 9);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...value.client
|
||||
.prepare(
|
||||
`SELECT status, version, event_sequence AS "eventSequence",
|
||||
finished_at_ms IS NOT NULL AS finished,
|
||||
error_code AS "errorCode"
|
||||
FROM "Runs" WHERE id = ?`,
|
||||
)
|
||||
.get(value.value.plan.runId),
|
||||
},
|
||||
{
|
||||
status: 'succeeded',
|
||||
version: 9,
|
||||
eventSequence: 9,
|
||||
finished: 1,
|
||||
errorCode: null,
|
||||
},
|
||||
);
|
||||
const replay = await value.frontier.advance(value.value.plan.runId);
|
||||
assert.equal(replay.status, 'settled');
|
||||
assert.equal(replay.terminalStatus, 'succeeded');
|
||||
assert.deepEqual(await value.admission.admit(value.value.plan), {
|
||||
status: 'existing',
|
||||
receipt: value.admitted.receipt,
|
||||
});
|
||||
});
|
||||
|
||||
test('skips downstream work and fails the aggregate atomically', async (t) => {
|
||||
const value = await harness(t, 'frontier-fail');
|
||||
await transitionStep(value, 'collect', 'running', 3, 4_000);
|
||||
await transitionStep(value, 'collect', 'failed', 4, 5_000);
|
||||
const result = await value.frontier.advance(value.value.plan.runId);
|
||||
assert.equal(result.status, 'terminal');
|
||||
assert.equal(result.stepMutationCount, 1);
|
||||
assert.equal(result.terminalStatus, 'failed');
|
||||
assert.equal(result.runVersion, 7);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...value.client
|
||||
.prepare(
|
||||
`SELECT run.status, run.version,
|
||||
run.event_sequence AS "eventSequence",
|
||||
run.error_code AS "errorCode",
|
||||
step.status AS "stepStatus",
|
||||
step.result_code AS "resultCode",
|
||||
(SELECT COUNT(*) FROM "RunEvents"
|
||||
WHERE run_id = run.id) AS events
|
||||
FROM "Runs" AS run
|
||||
JOIN "StepRuns" AS step
|
||||
ON step.run_id = run.id AND step.step_key = 'summarize'
|
||||
WHERE run.id = ?`,
|
||||
)
|
||||
.get(value.value.plan.runId),
|
||||
},
|
||||
{
|
||||
status: 'failed',
|
||||
version: 7,
|
||||
eventSequence: 7,
|
||||
errorCode: 'workflow_step_failed',
|
||||
stepStatus: 'skipped',
|
||||
resultCode: 'dependency_not_succeeded',
|
||||
events: 7,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('bounds paging before SQL and fails closed on current StepRun drift', async (t) => {
|
||||
const value = await harness(t, 'frontier-drift');
|
||||
assert.throws(
|
||||
() => value.frontier.listCandidates({ limit: 65 }),
|
||||
InvalidPluginPackageWorkflowFrontierError,
|
||||
);
|
||||
await transitionStep(value, 'collect', 'running', 3, 4_000);
|
||||
await transitionStep(value, 'collect', 'succeeded', 4, 5_000);
|
||||
value.client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "StepRuns"
|
||||
SET step_run_json = json_set(step_run_json, '$.definitionRef', 'drift')
|
||||
WHERE run_id = ? AND step_key = 'summarize'`,
|
||||
)
|
||||
.run(value.value.plan.runId);
|
||||
await assert.rejects(
|
||||
value.frontier.advance(value.value.plan.runId),
|
||||
PluginPackageWorkflowFrontierUnavailableError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...value.client
|
||||
.prepare(
|
||||
`SELECT status, version, event_sequence AS "eventSequence"
|
||||
FROM "Runs" WHERE id = ?`,
|
||||
)
|
||||
.get(value.value.plan.runId),
|
||||
},
|
||||
{ status: 'running', version: 5, eventSequence: 5 },
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes Workflow frontier only through its explicit SQLite subpath', () => {
|
||||
const subpath = require('@qinglong/local-sqlite/plugin-package-workflow-frontier');
|
||||
const root = require('../dist');
|
||||
assert.equal(
|
||||
subpath.LocalSqlitePluginPackageWorkflowFrontierRepository,
|
||||
LocalSqlitePluginPackageWorkflowFrontierRepository,
|
||||
);
|
||||
assert.equal(
|
||||
root.LocalSqlitePluginPackageWorkflowFrontierRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
+644
@@ -0,0 +1,644 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createInitialPluginPackageAutomationPublication,
|
||||
} = require('@qinglong/runtime-core/plugin-package-automation-publication');
|
||||
const {
|
||||
createPluginPackageWorkflowExecutionPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-execution-plan');
|
||||
const {
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionConflictError,
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-workflow-task-attempt-admission');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const {
|
||||
LocalSqlitePluginPackageAutomationPublicationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageAutomationPublicationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageTaskReconciliationRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowAdmissionRepository,
|
||||
} = require('../dist/plugin-package/workflow/pluginPackageWorkflowAdmissionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
} = require('../dist/plugin-package/workflow/pluginPackageWorkflowTaskAttemptAdmissionRepository');
|
||||
const { LocalSqliteRunRepository } = require('../dist/run/runRepository');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../dist/authority/operationAuthority');
|
||||
const {
|
||||
createLocalSqliteRunRuntimeCapabilities,
|
||||
} = require('../dist/run/runRuntimeCapabilities');
|
||||
const {
|
||||
LocalSqliteWorkflowTaskExecutionRepository,
|
||||
} = require('../dist/plugin-package/workflow/workflowTaskExecutionRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository,
|
||||
} = require('../dist/plugin-package/workflow/pluginPackageWorkflowCancellationConvergenceRepository');
|
||||
const { auditLocalSqliteReadiness } = require('../dist/readiness/readiness');
|
||||
|
||||
function fixture(namespace) {
|
||||
const identity = createHash('sha256')
|
||||
.update(namespace)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
const value = pluginPackageTaskReconciliationFixture(namespace, {
|
||||
workflows: [
|
||||
{
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily workflow',
|
||||
enabled: true,
|
||||
steps: [
|
||||
{ id: 'collect', task: 'alpha', needs: [] },
|
||||
{ id: 'summarize', task: 'beta', needs: ['collect'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const publication = createInitialPluginPackageAutomationPublication(
|
||||
value.revision,
|
||||
value.registry,
|
||||
2_000,
|
||||
);
|
||||
const plan = createPluginPackageWorkflowExecutionPlan({
|
||||
planId: `task-attempt-plan-${namespace}`,
|
||||
runId: `wta-run-${identity}`,
|
||||
workflowId: 'daily',
|
||||
stepRunIds: {
|
||||
collect: `wta-collect-${identity}`,
|
||||
summarize: `wta-summary-${identity}`,
|
||||
},
|
||||
publication,
|
||||
revision: value.revision,
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
plannedAtMs: 3_000,
|
||||
});
|
||||
return { ...value, publication, plan };
|
||||
}
|
||||
|
||||
async function harness(t, namespace, { reconcile = true } = {}) {
|
||||
const value = fixture(namespace);
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(value.projectId, value.projectId, value.projectId);
|
||||
await activateInstall(
|
||||
new LocalSqlitePluginPackageInstallRepository(client),
|
||||
value,
|
||||
);
|
||||
await new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
client,
|
||||
value.registry,
|
||||
).publish(value.revision);
|
||||
if (reconcile) {
|
||||
await new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
client,
|
||||
value.registry,
|
||||
).reconcile(value.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return value.revision.generation;
|
||||
},
|
||||
});
|
||||
}
|
||||
await new LocalSqlitePluginPackageAutomationPublicationRepository(
|
||||
client,
|
||||
).publish(value.publication);
|
||||
await new LocalSqlitePluginPackageWorkflowAdmissionRepository(client).admit(
|
||||
value.plan,
|
||||
);
|
||||
t.after(() => client.close());
|
||||
return {
|
||||
client,
|
||||
value,
|
||||
repository:
|
||||
new LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository(
|
||||
client,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
test('atomically admits the exact reconciled local Task revision and replays it', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-task-attempt',
|
||||
);
|
||||
const collect = value.plan.steps.find(({ stepKey }) => stepKey === 'collect');
|
||||
assert.ok(collect);
|
||||
assert.deepEqual(await repository.listCandidates({ limit: 8 }), {
|
||||
candidates: [
|
||||
{
|
||||
runId: value.plan.runId,
|
||||
stepRunId: collect.stepRunId,
|
||||
readyAtMs: 3_000,
|
||||
planDigest: value.plan.planDigest,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
const created = await repository.admit(value.plan.runId, collect.stepRunId);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(created.receipt.resourceTaskId, 'alpha');
|
||||
assert.equal(created.receipt.taskId, `pkg:${value.packageName}:alpha`);
|
||||
assert.match(created.receipt.taskRevision, /^qltd:v1:1:[0-9a-f]{64}$/);
|
||||
assert.equal(created.receipt.executorType, 'local_process');
|
||||
assert.equal(created.receipt.attemptNumber, 1);
|
||||
assert.equal(created.receipt.runVersion, 4);
|
||||
assert.deepEqual(
|
||||
await createLocalSqliteRunRuntimeCapabilities(
|
||||
new LocalSqliteOperationAuthority(client),
|
||||
).dispatch.listLocalDispatchCandidates({ limit: 8 }),
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
runId: value.plan.runId,
|
||||
stepRunId: collect.stepRunId,
|
||||
projectId: value.projectId,
|
||||
taskId: created.receipt.taskId,
|
||||
taskRevision: created.receipt.taskRevision,
|
||||
attemptId: created.receipt.attemptId,
|
||||
attemptNumber: 1,
|
||||
executorType: 'local_process',
|
||||
priority: 0,
|
||||
queuedAtMs: 3_000,
|
||||
attemptCreatedAtMs: created.receipt.admittedAtMs,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
await repository.admit(value.plan.runId, collect.stepRunId),
|
||||
{
|
||||
status: 'existing',
|
||||
receipt: created.receipt,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await repository.listCandidates({ limit: 8 }), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM "RunAttempts") AS attempts,
|
||||
(SELECT COUNT(*) FROM "RunEvents") AS events,
|
||||
(SELECT COUNT(*) FROM
|
||||
"QingLong3PluginPackageWorkflowTaskAttemptAdmissions")
|
||||
AS admissions,
|
||||
run.version AS "runVersion",
|
||||
run.event_sequence AS "eventSequence",
|
||||
step.status AS "stepStatus",
|
||||
step.attempt_count AS "stepAttemptCount"
|
||||
FROM "Runs" AS run
|
||||
JOIN "StepRuns" AS step
|
||||
ON step.run_id = run.id AND step.id = ?
|
||||
WHERE run.id = ?`,
|
||||
)
|
||||
.get(collect.stepRunId, value.plan.runId),
|
||||
},
|
||||
{
|
||||
attempts: 1,
|
||||
events: 4,
|
||||
admissions: 1,
|
||||
runVersion: 4,
|
||||
eventSequence: 4,
|
||||
stepStatus: 'ready',
|
||||
stepAttemptCount: 0,
|
||||
},
|
||||
);
|
||||
assert.equal((await auditLocalSqliteReadiness(client)).contractVersion, 43);
|
||||
});
|
||||
|
||||
test('bounds candidate paging before SQL and fences cancellation', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-task-attempt-fence',
|
||||
);
|
||||
assert.throws(
|
||||
() => repository.listCandidates({ limit: 65 }),
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
);
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "Runs"
|
||||
SET cancel_requested_at_ms = ?, cancel_reason = 'user'
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(4_000, value.plan.runId);
|
||||
assert.deepEqual(await repository.listCandidates({ limit: 8 }), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
await assert.rejects(
|
||||
repository.admit(value.plan.runId, value.plan.steps[0].stepRunId),
|
||||
InvalidPluginPackageWorkflowTaskAttemptAdmissionError,
|
||||
);
|
||||
assert.equal(
|
||||
client.prepare(`SELECT COUNT(*) AS count FROM "RunAttempts"`).get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the parent Workflow running while one local Task starts and completes', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-task-local-execution',
|
||||
);
|
||||
const collect = value.plan.steps.find(({ stepKey }) => stepKey === 'collect');
|
||||
assert.ok(collect);
|
||||
const admitted = await repository.admit(value.plan.runId, collect.stepRunId);
|
||||
const runs = new LocalSqliteRunRepository(client);
|
||||
const execution = new LocalSqliteWorkflowTaskExecutionRepository(client);
|
||||
const callbackTokenHash = 'a'.repeat(64);
|
||||
const startingAtMs = admitted.receipt.admittedAtMs + 1;
|
||||
const runningAtMs = startingAtMs + 1;
|
||||
const finishedAtMs = runningAtMs + 1;
|
||||
assert.equal(
|
||||
(
|
||||
await execution.prepare({
|
||||
runId: value.plan.runId,
|
||||
attemptId: admitted.receipt.attemptId,
|
||||
stepRunId: collect.stepRunId,
|
||||
callbackTokenHash,
|
||||
deadlineAtMs: startingAtMs + 1_000,
|
||||
logArtifactId: 'local-0123456789abcdef0123456789abcd',
|
||||
atMs: startingAtMs,
|
||||
eventId: 'local-workflow-starting-event',
|
||||
})
|
||||
).status,
|
||||
'applied',
|
||||
);
|
||||
const startingRun = await runs.findRunById(value.plan.runId);
|
||||
const startingAttempt = await runs.findAttemptById(
|
||||
admitted.receipt.attemptId,
|
||||
);
|
||||
assert.ok(startingRun);
|
||||
assert.ok(startingAttempt);
|
||||
assert.equal(
|
||||
(
|
||||
await execution.recordRunning({
|
||||
run: startingRun,
|
||||
attempt: startingAttempt,
|
||||
callbackTokenHash,
|
||||
executorHandle: 'qlp:v1:durable-local-workflow-handle',
|
||||
pid: 123,
|
||||
startedAtMs: runningAtMs,
|
||||
attemptEventId: 'local-workflow-running-attempt',
|
||||
stepMutationId: 'local-workflow-running-step',
|
||||
})
|
||||
).status,
|
||||
'applied',
|
||||
);
|
||||
const runningRun = await runs.findRunById(value.plan.runId);
|
||||
const runningAttempt = await runs.findAttemptById(admitted.receipt.attemptId);
|
||||
assert.ok(runningRun);
|
||||
assert.ok(runningAttempt);
|
||||
assert.equal(
|
||||
await execution.complete({
|
||||
run: runningRun,
|
||||
attempt: runningAttempt,
|
||||
callbackSequence: 1,
|
||||
startedAtMs: runningAtMs,
|
||||
finishedAtMs,
|
||||
exitCode: 0,
|
||||
terminalStatus: 'succeeded',
|
||||
attemptEventId: 'local-workflow-completed-attempt',
|
||||
syntheticStartMutationId: 'unused-synthetic-start',
|
||||
terminalStepMutationId: 'local-workflow-completed-step',
|
||||
}),
|
||||
'completed',
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT run.status AS "runStatus",
|
||||
attempt.status AS "attemptStatus",
|
||||
attempt.callback_sequence AS "callbackSequence",
|
||||
step.status AS "stepStatus",
|
||||
step.attempt_count AS "stepAttemptCount"
|
||||
FROM "Runs" AS run
|
||||
JOIN "RunAttempts" AS attempt
|
||||
ON attempt.run_id = run.id AND attempt.id = ?
|
||||
JOIN "StepRuns" AS step
|
||||
ON step.run_id = run.id AND step.id = ?
|
||||
WHERE run.id = ?`,
|
||||
)
|
||||
.get(admitted.receipt.attemptId, collect.stepRunId, value.plan.runId),
|
||||
},
|
||||
{
|
||||
runStatus: 'running',
|
||||
attemptStatus: 'succeeded',
|
||||
callbackSequence: 1,
|
||||
stepStatus: 'succeeded',
|
||||
stepAttemptCount: 1,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('times out one local Workflow Task without cancelling its parent Run', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-task-local-timeout',
|
||||
);
|
||||
const collect = value.plan.steps[0];
|
||||
const admitted = await repository.admit(value.plan.runId, collect.stepRunId);
|
||||
const runs = new LocalSqliteRunRepository(client);
|
||||
const execution = new LocalSqliteWorkflowTaskExecutionRepository(client);
|
||||
const callbackTokenHash = 'b'.repeat(64);
|
||||
const startingAtMs = admitted.receipt.admittedAtMs + 1;
|
||||
const deadlineAtMs = startingAtMs + 10;
|
||||
await execution.prepare({
|
||||
runId: value.plan.runId,
|
||||
attemptId: admitted.receipt.attemptId,
|
||||
stepRunId: collect.stepRunId,
|
||||
callbackTokenHash,
|
||||
deadlineAtMs,
|
||||
atMs: startingAtMs,
|
||||
eventId: 'local-workflow-timeout-starting',
|
||||
});
|
||||
let run = await runs.findRunById(value.plan.runId);
|
||||
let attempt = await runs.findAttemptById(admitted.receipt.attemptId);
|
||||
assert.ok(run);
|
||||
assert.ok(attempt);
|
||||
await execution.recordRunning({
|
||||
run,
|
||||
attempt,
|
||||
callbackTokenHash,
|
||||
executorHandle: 'qlp:v1:timeout-workflow-handle',
|
||||
pid: 124,
|
||||
startedAtMs: startingAtMs + 1,
|
||||
attemptEventId: 'local-workflow-timeout-running-attempt',
|
||||
stepMutationId: 'local-workflow-timeout-running-step',
|
||||
});
|
||||
run = await runs.findRunById(value.plan.runId);
|
||||
attempt = await runs.findAttemptById(admitted.receipt.attemptId);
|
||||
assert.ok(run);
|
||||
assert.ok(attempt);
|
||||
assert.equal(
|
||||
await execution.requestTimeout({
|
||||
run,
|
||||
attempt,
|
||||
dueAtMs: deadlineAtMs,
|
||||
eventId: 'local-workflow-timeout-requested',
|
||||
}),
|
||||
'requested',
|
||||
);
|
||||
run = await runs.findRunById(value.plan.runId);
|
||||
attempt = await runs.findAttemptById(admitted.receipt.attemptId);
|
||||
assert.ok(run);
|
||||
assert.ok(attempt);
|
||||
assert.equal(
|
||||
await execution.recordControlTerminal({
|
||||
run,
|
||||
attempt,
|
||||
reason: 'timeout',
|
||||
terminalStatus: 'timed_out',
|
||||
errorCode: 'EXECUTION_TIMED_OUT',
|
||||
errorSummary: 'Execution exceeded its configured timeout',
|
||||
finishedAtMs: deadlineAtMs + 1,
|
||||
attemptEventId: 'local-workflow-timeout-terminal-attempt',
|
||||
stepMutationId: 'local-workflow-timeout-terminal-step',
|
||||
}),
|
||||
'terminal',
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT run.status AS "runStatus",
|
||||
run.cancel_requested_at_ms AS "cancelRequestedAtMs",
|
||||
attempt.status AS "attemptStatus",
|
||||
step.status AS "stepStatus"
|
||||
FROM "Runs" AS run
|
||||
JOIN "RunAttempts" AS attempt
|
||||
ON attempt.run_id = run.id AND attempt.id = ?
|
||||
JOIN "StepRuns" AS step
|
||||
ON step.run_id = run.id AND step.id = ?
|
||||
WHERE run.id = ?`,
|
||||
)
|
||||
.get(admitted.receipt.attemptId, collect.stepRunId, value.plan.runId),
|
||||
},
|
||||
{
|
||||
runStatus: 'running',
|
||||
cancelRequestedAtMs: null,
|
||||
attemptStatus: 'timed_out',
|
||||
stepStatus: 'timed_out',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('requeues one orphaned claimed Workflow Task at a fresh Step epoch', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-task-local-recovery',
|
||||
);
|
||||
const collect = value.plan.steps[0];
|
||||
const admitted = await repository.admit(value.plan.runId, collect.stepRunId);
|
||||
const runs = new LocalSqliteRunRepository(client);
|
||||
const recovery = new LocalSqliteWorkflowTaskExecutionRepository(client);
|
||||
assert.deepEqual(await recovery.listRecoveryCandidates({ limit: 8 }), {
|
||||
candidates: [
|
||||
{
|
||||
runId: value.plan.runId,
|
||||
attemptId: admitted.receipt.attemptId,
|
||||
attemptCreatedAtMs: admitted.receipt.admittedAtMs,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const run = await runs.findRunById(value.plan.runId);
|
||||
const attempt = await runs.findAttemptById(admitted.receipt.attemptId);
|
||||
assert.ok(run);
|
||||
assert.ok(attempt);
|
||||
assert.equal(
|
||||
await recovery.recover({
|
||||
run,
|
||||
attempt,
|
||||
reason: 'unstarted_claim_expired',
|
||||
observedAtMs: admitted.receipt.admittedAtMs + 1,
|
||||
}),
|
||||
'requeued',
|
||||
);
|
||||
assert.equal((await runs.findRunById(value.plan.runId)).status, 'running');
|
||||
assert.equal(
|
||||
(await runs.findAttemptById(admitted.receipt.attemptId)).status,
|
||||
'lost',
|
||||
);
|
||||
const refreshed = await repository.listCandidates({ limit: 8 });
|
||||
assert.equal(refreshed.candidates.length, 1);
|
||||
assert.equal(refreshed.candidates[0].stepRunId, collect.stepRunId);
|
||||
const second = await repository.admit(value.plan.runId, collect.stepRunId);
|
||||
assert.equal(second.status, 'created');
|
||||
assert.equal(second.receipt.attemptNumber, 2);
|
||||
assert.notEqual(second.receipt.attemptId, admitted.receipt.attemptId);
|
||||
});
|
||||
|
||||
test('rolls back when the exact generation has no reconciled execution revision', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-task-attempt-unreconciled',
|
||||
{ reconcile: false },
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.admit(value.plan.runId, value.plan.steps[0].stepRunId),
|
||||
PluginPackageWorkflowTaskAttemptAdmissionConflictError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT version, event_sequence AS "eventSequence",
|
||||
(SELECT COUNT(*) FROM "RunAttempts") AS attempts
|
||||
FROM "Runs" WHERE id = ?`,
|
||||
)
|
||||
.get(value.plan.runId),
|
||||
},
|
||||
{ version: 3, eventSequence: 3, attempts: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on receipt drift and publishes only an explicit subpath', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-task-attempt-drift',
|
||||
);
|
||||
const stepRunId = value.plan.steps[0].stepRunId;
|
||||
await repository.admit(value.plan.runId, stepRunId);
|
||||
client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3PluginPackageWorkflowTaskAttemptAdmissions"
|
||||
SET receipt_json = json_set(receipt_json, '$.taskId', 'drift')
|
||||
WHERE run_id = ? AND step_run_id = ?`,
|
||||
)
|
||||
.run(value.plan.runId, stepRunId);
|
||||
await assert.rejects(
|
||||
repository.admit(value.plan.runId, stepRunId),
|
||||
PluginPackageWorkflowTaskAttemptAdmissionUnavailableError,
|
||||
);
|
||||
|
||||
const subpath = require('@qinglong/local-sqlite/plugin-package-workflow-task-attempt-admission');
|
||||
const root = require('../dist');
|
||||
assert.equal(
|
||||
subpath.LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
);
|
||||
assert.equal(
|
||||
root.LocalSqlitePluginPackageWorkflowTaskAttemptAdmissionRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('converges a cancelling local Workflow in bounded per-Run transactions', async (t) => {
|
||||
const { client, value, repository } = await harness(
|
||||
t,
|
||||
'sqlite-workflow-cancellation',
|
||||
);
|
||||
const collect = value.plan.steps.find(({ stepKey }) => stepKey === 'collect');
|
||||
assert.ok(collect);
|
||||
await repository.admit(value.plan.runId, collect.stepRunId);
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "Runs"
|
||||
SET cancel_requested_at_ms = ?, cancel_reason = 'user'
|
||||
WHERE id = ? AND status = 'running'`,
|
||||
)
|
||||
.run(4_000, value.plan.runId);
|
||||
|
||||
const cancellation =
|
||||
new LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository(
|
||||
client,
|
||||
);
|
||||
assert.deepEqual(await cancellation.convergePage({ limit: 8 }), {
|
||||
scanned: 1,
|
||||
settledRuns: 1,
|
||||
settledAttempts: 1,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT status, version,
|
||||
event_sequence AS "eventSequence",
|
||||
(SELECT group_concat(status, ',')
|
||||
FROM (
|
||||
SELECT DISTINCT status FROM "RunAttempts"
|
||||
WHERE run_id = run.id ORDER BY status
|
||||
)) AS "attemptStatuses",
|
||||
(SELECT group_concat(status, ',')
|
||||
FROM (
|
||||
SELECT DISTINCT status FROM "StepRuns"
|
||||
WHERE run_id = run.id ORDER BY status
|
||||
)) AS "stepStatuses",
|
||||
(SELECT group_concat(type, ',')
|
||||
FROM (
|
||||
SELECT type FROM "RunEvents"
|
||||
WHERE run_id = run.id AND sequence >= 4
|
||||
ORDER BY sequence
|
||||
)) AS events
|
||||
FROM "Runs" AS run WHERE id = ?`,
|
||||
)
|
||||
.get(value.plan.runId),
|
||||
},
|
||||
{
|
||||
status: 'cancelled',
|
||||
version: 8,
|
||||
eventSequence: 8,
|
||||
attemptStatuses: 'cancelled',
|
||||
stepStatuses: 'cancelled',
|
||||
events: [
|
||||
'workflow.task_attempt_admitted',
|
||||
'workflow.task_attempt.cancelled',
|
||||
'step.cancelled',
|
||||
'step.cancelled',
|
||||
'workflow.cancelled',
|
||||
].join(','),
|
||||
},
|
||||
);
|
||||
assert.deepEqual(await cancellation.convergePage({ limit: 8 }), {
|
||||
scanned: 0,
|
||||
settledRuns: 0,
|
||||
settledAttempts: 0,
|
||||
blocked: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const subpath = require('@qinglong/local-sqlite/plugin-package-workflow-cancellation-convergence');
|
||||
const root = require('../dist');
|
||||
assert.equal(
|
||||
subpath.LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository,
|
||||
LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository,
|
||||
);
|
||||
assert.equal(
|
||||
root.LocalSqlitePluginPackageWorkflowCancellationConvergenceRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CRASH_POINTS,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
} = require('./fixtures/pluginPackageWorkflowTaskControlCrashMatrixFixture.cjs');
|
||||
|
||||
const FIXTURE_PATH = path.join(
|
||||
__dirname,
|
||||
'fixtures',
|
||||
'pluginPackageWorkflowTaskControlCrashMatrixFixture.cjs',
|
||||
);
|
||||
|
||||
test(
|
||||
'survives Workflow Task conclusive-stop and control-terminal crash windows',
|
||||
{ timeout: 180_000 },
|
||||
async (context) => {
|
||||
const reports = [];
|
||||
for (const profile of ['edge', 'standalone']) {
|
||||
for (const [pointName, point] of Object.entries(CRASH_POINTS)) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
`ql3-workflow-control-${profile}-${pointName}-`,
|
||||
),
|
||||
);
|
||||
context.after(() => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const databasePath = path.join(directory, 'runtime.sqlite');
|
||||
const markerPath = path.join(directory, 'crash-marker.json');
|
||||
await setupScenario({ databasePath, profile });
|
||||
const crashed = spawnSync(
|
||||
process.execPath,
|
||||
[FIXTURE_PATH, 'crash', databasePath, markerPath, pointName, profile],
|
||||
{ encoding: 'utf8', timeout: 30_000 },
|
||||
);
|
||||
assert.equal(
|
||||
crashed.error,
|
||||
undefined,
|
||||
`${profile}/${pointName}: ${crashed.error?.message}`,
|
||||
);
|
||||
assert.equal(
|
||||
crashed.signal,
|
||||
'SIGKILL',
|
||||
`${profile}/${pointName}: status=${crashed.status}, stderr=${crashed.stderr}`,
|
||||
);
|
||||
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
||||
assert.deepEqual(marker, {
|
||||
schema:
|
||||
'qinglong/sqlite-plugin-package-workflow-task-control-crash-marker@v1',
|
||||
point: pointName,
|
||||
conclusiveStopObserved: true,
|
||||
pid: marker.pid,
|
||||
});
|
||||
const report = await verifyScenario({
|
||||
databasePath,
|
||||
pointName,
|
||||
profile,
|
||||
});
|
||||
assert.equal(report.durableAfterCrash, point.durable);
|
||||
reports.push(report);
|
||||
}
|
||||
}
|
||||
assert.equal(reports.length, 16);
|
||||
assert.equal(
|
||||
reports.filter(({ crashBeforeCommit }) => crashBeforeCommit).length,
|
||||
14,
|
||||
);
|
||||
assert.equal(
|
||||
reports.filter(({ durableAfterCrash }) => durableAfterCrash).length,
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...new Set(reports.map(({ journalMode }) => journalMode))].sort(),
|
||||
['delete', 'wal'],
|
||||
);
|
||||
assert.ok(
|
||||
reports.every(
|
||||
({
|
||||
crashAfterConclusiveStop,
|
||||
exactTerminalReplay,
|
||||
parentConverged,
|
||||
integrityCheck,
|
||||
foreignKeyCheck,
|
||||
}) =>
|
||||
crashAfterConclusiveStop &&
|
||||
exactTerminalReplay &&
|
||||
parentConverged &&
|
||||
integrityCheck === 'ok' &&
|
||||
foreignKeyCheck === 'ok',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
|
||||
function run(id, projectId, createdAtMs) {
|
||||
return {
|
||||
id,
|
||||
projectId,
|
||||
taskId: `task-${id}`,
|
||||
taskRevision: 'revision-1',
|
||||
taskName: id,
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
triggeredBy: 'test',
|
||||
status: 'created',
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
test('lists only one Project with descending keyset pagination', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-run-list-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const options = {
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
profile: 'edge',
|
||||
};
|
||||
await migrateLocalSqlitePath(options);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(options);
|
||||
t.after(() => runtime.close());
|
||||
await runtime.runRepository.transaction(async (transaction) => {
|
||||
for (const value of [
|
||||
run('run-a', 'default', 10),
|
||||
run('run-b', 'default', 20),
|
||||
run('run-c', 'default', 20),
|
||||
run('run-other', 'other', 30),
|
||||
]) {
|
||||
await transaction.insertRun(value);
|
||||
}
|
||||
});
|
||||
const first = await runtime.runRepository.listRunsByProject({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(
|
||||
first.map(({ id }) => id),
|
||||
['run-c', 'run-b'],
|
||||
);
|
||||
const second = await runtime.runRepository.listRunsByProject({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
after: { createdAtMs: 20, runId: 'run-b' },
|
||||
});
|
||||
assert.deepEqual(
|
||||
second.map(({ id }) => id),
|
||||
['run-a'],
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.runRepository.listRunsByProject({
|
||||
projectId: 'default',
|
||||
limit: 66,
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.runRepository.listRunsByProject({
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
after: { createdAtMs: 20, runId: 'run-b', extra: true },
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ProjectToolDefinitionSnapshotUnavailableError,
|
||||
} = require('@qinglong/runtime-core/project-tool-definition-snapshot');
|
||||
const {
|
||||
registerProjectToolDefinitionSnapshotRepositoryContract,
|
||||
projectToolDefinitionSnapshotForFixture,
|
||||
} = require('../../../test/contracts/projectToolDefinitionSnapshotRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../dist/authority/operationAuthority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
} = require('../dist/tool-execution/projectToolDefinitionSnapshotRepository');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
|
||||
async function createRepository(_t, fixture) {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, fixture.projectId, fixture.projectId);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
return {
|
||||
client,
|
||||
repository:
|
||||
new LocalSqliteProjectToolDefinitionSnapshotRepository(authority),
|
||||
installRepository:
|
||||
new LocalSqlitePluginPackageInstallRepository(authority),
|
||||
materializedRepository:
|
||||
new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
),
|
||||
close: () => authority.close(),
|
||||
};
|
||||
}
|
||||
|
||||
registerProjectToolDefinitionSnapshotRepositoryContract({
|
||||
name: 'SQLite Project Tool Definition snapshot repository',
|
||||
namespace: 'sqlite-tool-snapshot',
|
||||
profile: 'edge',
|
||||
createRepository,
|
||||
assertDurableSource(harness, value) {
|
||||
assert.deepEqual(
|
||||
{
|
||||
...harness.client
|
||||
.prepare(
|
||||
`SELECT installation_id AS "installationId",
|
||||
generation_digest AS "generationDigest",
|
||||
revision_digest AS "revisionDigest"
|
||||
FROM "QingLong3ProjectToolDefinitionSnapshotSources"`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{
|
||||
installationId: value.install.active.installationId,
|
||||
generationDigest: value.revision.generation.generationDigest,
|
||||
revisionDigest: value.revision.revisionDigest,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
test('SQLite Project Tool Definition snapshot fails closed on source loss', async (t) => {
|
||||
const value = pluginPackageTaskReconciliationFixture(
|
||||
'sqlite-tool-snapshot-corrupt',
|
||||
{ profile: 'edge' },
|
||||
);
|
||||
const harness = await createRepository(t, value);
|
||||
t.after(() => harness.close());
|
||||
await activateInstall(harness.installRepository, value);
|
||||
await harness.materializedRepository.publish(value.revision);
|
||||
await harness.repository.publish(
|
||||
projectToolDefinitionSnapshotForFixture(value),
|
||||
);
|
||||
harness.client.exec('PRAGMA foreign_keys = OFF');
|
||||
harness.client
|
||||
.prepare(
|
||||
`DELETE FROM "QingLong3ProjectToolDefinitionSnapshotSources"
|
||||
WHERE project_id = ?`,
|
||||
)
|
||||
.run(value.projectId);
|
||||
await assert.rejects(
|
||||
harness.repository.findCurrent(value.projectId),
|
||||
ProjectToolDefinitionSnapshotUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes snapshot storage only through the explicit subpath', () => {
|
||||
const entrypoint = require('@qinglong/local-sqlite/project-tool-definition-snapshot');
|
||||
assert.equal(
|
||||
entrypoint.LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
);
|
||||
assert.equal(
|
||||
require('../dist').LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const { migrateLocalSqlitePath } = require('../dist/migration/migration.js');
|
||||
const {
|
||||
checkpointLocalSqliteForRestore,
|
||||
createLocalSqliteRolloutBackup,
|
||||
inspectLocalSqliteRolloutBackup,
|
||||
inspectLocalSqliteSnapshot,
|
||||
LOCAL_SQLITE_WRITE_CONTRACT_VERSION,
|
||||
openLocalSqliteChangeObserver,
|
||||
restoreLocalSqliteSnapshot,
|
||||
} = require('../dist/readiness/rolloutSafety.js');
|
||||
|
||||
function fixture(t, profile = 'edge') {
|
||||
const root = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-rollout-safety-')),
|
||||
);
|
||||
fs.chmodSync(root, 0o700);
|
||||
const backupRoot = path.join(root, 'backups');
|
||||
fs.mkdirSync(backupRoot, { mode: 0o700 });
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return {
|
||||
root,
|
||||
profile,
|
||||
databasePath: path.join(root, 'qinglong3.sqlite'),
|
||||
backupPath: path.join(backupRoot, 'rollout.sqlite'),
|
||||
};
|
||||
}
|
||||
|
||||
test('creates and exactly replays a reviewed rollout backup', async (t) => {
|
||||
const state = fixture(t);
|
||||
await migrateLocalSqlitePath(state);
|
||||
const prepared = await createLocalSqliteRolloutBackup(state);
|
||||
assert.equal(prepared.status, 'prepared');
|
||||
assert.equal(prepared.contractVersion, 43);
|
||||
assert.equal(prepared.writeContractVersion, 43);
|
||||
assert.equal(LOCAL_SQLITE_WRITE_CONTRACT_VERSION, 43);
|
||||
assert.match(prepared.sha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(prepared.bytes > 0, true);
|
||||
assert.equal(prepared.pageCount > 0, true);
|
||||
assert.equal(prepared.pageSize >= 512, true);
|
||||
assert.equal(fs.statSync(state.backupPath).mode & 0o777, 0o600);
|
||||
|
||||
const replay = await createLocalSqliteRolloutBackup(state);
|
||||
assert.deepEqual(replay, { ...prepared, status: 'existing' });
|
||||
assert.deepEqual(await inspectLocalSqliteRolloutBackup(state), replay);
|
||||
const linkedStagePath = path.join(
|
||||
path.dirname(state.backupPath),
|
||||
`.${path.basename(state.backupPath)}.ql3-backup-stage`,
|
||||
);
|
||||
fs.linkSync(state.backupPath, linkedStagePath);
|
||||
assert.equal(fs.statSync(state.backupPath).nlink, 2);
|
||||
assert.deepEqual(await createLocalSqliteRolloutBackup(state), replay);
|
||||
assert.equal(fs.existsSync(linkedStagePath), false);
|
||||
assert.equal(fs.statSync(state.backupPath).nlink, 1);
|
||||
|
||||
const source = new DatabaseSync(state.databasePath);
|
||||
source.exec('PRAGMA user_version = 7');
|
||||
source.close();
|
||||
assert.deepEqual(await inspectLocalSqliteRolloutBackup(state), replay);
|
||||
});
|
||||
|
||||
test('observes external commits across an online standalone WAL backup', async (t) => {
|
||||
const state = fixture(t, 'standalone');
|
||||
await migrateLocalSqlitePath(state);
|
||||
const observer = openLocalSqliteChangeObserver(state);
|
||||
assert.equal(observer.changed(), false);
|
||||
const writer = new DatabaseSync(state.databasePath);
|
||||
writer.exec('PRAGMA user_version = 9');
|
||||
writer.close();
|
||||
assert.equal(observer.changed(), true);
|
||||
const backup = await createLocalSqliteRolloutBackup(state);
|
||||
assert.equal(backup.status, 'prepared');
|
||||
observer.close();
|
||||
observer.close();
|
||||
assert.throws(() => observer.changed(), /observer is closed/);
|
||||
});
|
||||
|
||||
test('recovers an incomplete stage and cleans a failed backup attempt', async (t) => {
|
||||
const state = fixture(t);
|
||||
await migrateLocalSqlitePath(state);
|
||||
const stagePath = path.join(
|
||||
path.dirname(state.backupPath),
|
||||
`.${path.basename(state.backupPath)}.ql3-backup-stage`,
|
||||
);
|
||||
fs.writeFileSync(stagePath, 'incomplete', { mode: 0o600 });
|
||||
const recovered = await createLocalSqliteRolloutBackup(state);
|
||||
assert.equal(recovered.status, 'prepared');
|
||||
assert.equal(fs.existsSync(stagePath), false);
|
||||
|
||||
const failed = fixture(t);
|
||||
await migrateLocalSqlitePath(failed);
|
||||
const failedStagePath = path.join(
|
||||
path.dirname(failed.backupPath),
|
||||
`.${path.basename(failed.backupPath)}.ql3-backup-stage`,
|
||||
);
|
||||
await assert.rejects(
|
||||
createLocalSqliteRolloutBackup(failed, {
|
||||
async performBackup(_source, target) {
|
||||
fs.writeFileSync(target, 'partial', { mode: 0o600 });
|
||||
throw Object.assign(new Error('no space left'), { code: 'ENOSPC' });
|
||||
},
|
||||
}),
|
||||
/could not be created/,
|
||||
);
|
||||
assert.equal(fs.existsSync(failed.backupPath), false);
|
||||
assert.equal(fs.existsSync(failedStagePath), false);
|
||||
});
|
||||
|
||||
for (const profile of ['edge', 'standalone']) {
|
||||
test(`checkpoints and restores one exact ${profile} snapshot`, async (t) => {
|
||||
const state = fixture(t, profile);
|
||||
await migrateLocalSqlitePath(state);
|
||||
const source = await createLocalSqliteRolloutBackup(state);
|
||||
const writer = new DatabaseSync(state.databasePath);
|
||||
writer.exec('PRAGMA user_version = 19');
|
||||
writer.close();
|
||||
const current = await checkpointLocalSqliteForRestore(state);
|
||||
assert.notEqual(current.sha256, source.sha256);
|
||||
assert.deepEqual(await checkpointLocalSqliteForRestore(state), current);
|
||||
assert.equal(fs.existsSync(`${state.databasePath}-wal`), false);
|
||||
assert.equal(fs.existsSync(`${state.databasePath}-shm`), false);
|
||||
|
||||
const restoreStagePath = path.join(
|
||||
state.root,
|
||||
`.qinglong3.${profile}.restore-stage`,
|
||||
);
|
||||
const replacedDatabasePath = path.join(
|
||||
path.dirname(state.backupPath),
|
||||
`${profile}.replaced.sqlite`,
|
||||
);
|
||||
const restoreOptions = {
|
||||
databasePath: state.databasePath,
|
||||
profile,
|
||||
sourceSnapshotPath: state.backupPath,
|
||||
restoreStagePath,
|
||||
replacedDatabasePath,
|
||||
expectedCurrentSha256: current.sha256,
|
||||
expectedSourceSha256: source.sha256,
|
||||
};
|
||||
const restored = await restoreLocalSqliteSnapshot(restoreOptions);
|
||||
assert.equal(restored.status, 'restored');
|
||||
assert.equal(restored.sha256, source.sha256);
|
||||
assert.equal(fs.existsSync(restoreStagePath), false);
|
||||
assert.equal(fs.existsSync(replacedDatabasePath), false);
|
||||
assert.equal(
|
||||
(await inspectLocalSqliteSnapshot(state)).sha256,
|
||||
source.sha256,
|
||||
);
|
||||
assert.equal(
|
||||
(await restoreLocalSqliteSnapshot(restoreOptions)).status,
|
||||
'existing',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('converges the moved-current restore window and cleans ENOSPC stage', async (t) => {
|
||||
const state = fixture(t);
|
||||
await migrateLocalSqlitePath(state);
|
||||
const source = await createLocalSqliteRolloutBackup(state);
|
||||
const writer = new DatabaseSync(state.databasePath);
|
||||
writer.exec('PRAGMA user_version = 23');
|
||||
writer.close();
|
||||
const current = await checkpointLocalSqliteForRestore(state);
|
||||
const restoreStagePath = path.join(state.root, '.restore-stage');
|
||||
const replacedDatabasePath = path.join(
|
||||
path.dirname(state.backupPath),
|
||||
'replaced.sqlite',
|
||||
);
|
||||
const restoreOptions = {
|
||||
databasePath: state.databasePath,
|
||||
profile: state.profile,
|
||||
sourceSnapshotPath: state.backupPath,
|
||||
restoreStagePath,
|
||||
replacedDatabasePath,
|
||||
expectedCurrentSha256: current.sha256,
|
||||
expectedSourceSha256: source.sha256,
|
||||
};
|
||||
await assert.rejects(
|
||||
restoreLocalSqliteSnapshot(restoreOptions, {
|
||||
copySnapshot(_sourcePath, targetPath) {
|
||||
fs.writeFileSync(targetPath, 'partial', { mode: 0o600 });
|
||||
throw Object.assign(new Error('injected restore ENOSPC'), {
|
||||
code: 'ENOSPC',
|
||||
});
|
||||
},
|
||||
}),
|
||||
/restore stage could not be created/,
|
||||
);
|
||||
assert.equal(fs.existsSync(restoreStagePath), false);
|
||||
assert.equal(
|
||||
(await inspectLocalSqliteSnapshot(state)).sha256,
|
||||
current.sha256,
|
||||
);
|
||||
|
||||
fs.copyFileSync(state.backupPath, restoreStagePath);
|
||||
fs.chmodSync(restoreStagePath, 0o600);
|
||||
fs.renameSync(state.databasePath, replacedDatabasePath);
|
||||
const recovered = await restoreLocalSqliteSnapshot(restoreOptions);
|
||||
assert.equal(recovered.status, 'restored');
|
||||
assert.equal(recovered.sha256, source.sha256);
|
||||
assert.equal(fs.existsSync(replacedDatabasePath), false);
|
||||
});
|
||||
|
||||
test('rollout safety subpath excludes DDL and mutable repositories', () => {
|
||||
const script = `
|
||||
const safety = require(${JSON.stringify(
|
||||
path.resolve(__dirname, '../dist/readiness/rolloutSafety.js'),
|
||||
)});
|
||||
const loaded = Object.keys(require.cache)
|
||||
.filter((entry) =>
|
||||
/[\\/]migrations[\\/]|[\\/]migration\\.js$|runRepository\\.js$|pluginPackageInstallRepository\\.js$/.test(entry),
|
||||
);
|
||||
process.stdout.write(JSON.stringify({
|
||||
backup: typeof safety.createLocalSqliteRolloutBackup,
|
||||
checkpoint: typeof safety.checkpointLocalSqliteForRestore,
|
||||
observer: typeof safety.openLocalSqliteChangeObserver,
|
||||
restore: typeof safety.restoreLocalSqliteSnapshot,
|
||||
loaded,
|
||||
}));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
backup: 'function',
|
||||
checkpoint: 'function',
|
||||
observer: 'function',
|
||||
restore: 'function',
|
||||
loaded: [],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
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 {
|
||||
RunCancellationFenceRejectedError,
|
||||
RunCancellationNotFoundError,
|
||||
RunCancellationUnavailableError,
|
||||
} = require('@qinglong/runtime-core/run-cancellation');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../dist/authority/operationAuthority.js');
|
||||
const {
|
||||
LocalSqliteRunCancellationRepository,
|
||||
} = require('../dist/run/runCancellationRepository.js');
|
||||
const { migrateLocalSqlitePath } = require('../dist/migration/migration.js');
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
const EVENT_ID = '018f0000-0000-7000-8000-000000000001';
|
||||
|
||||
function seed(client, runId = 'run-1', status = 'running') {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES ('default', 'user', 'user-1', 1, 'active', 'operator',
|
||||
'grant-operator', 'user', 'user-1', ?)`,
|
||||
)
|
||||
.run(NOW - 1_000);
|
||||
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 (?, 'default', 'task-1', 'revision-1', 'manual', 'manual',
|
||||
'runtime', ?, 1, 0, 0, ?)`,
|
||||
)
|
||||
.run(runId, status, NOW - 500);
|
||||
}
|
||||
|
||||
function command(overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
runId: 'run-1',
|
||||
mutationId: 'mutation-1',
|
||||
eventId: EVENT_ID,
|
||||
subject: { type: 'user', id: 'user-1' },
|
||||
policyFence: { projectVersion: 1, bindingVersion: 1 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(t, status = 'running') {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-run-cancel-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const databasePath = path.join(root, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
seed(client, 'run-1', status);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
t.after(() => authority.close());
|
||||
return {
|
||||
client,
|
||||
repository: new LocalSqliteRunCancellationRepository(authority, () => NOW),
|
||||
};
|
||||
}
|
||||
|
||||
test('atomically publishes one durable cancellation intent and exact replay', async (t) => {
|
||||
const { client, repository } = await fixture(t);
|
||||
assert.deepEqual(await repository.requestUserCancellation(command()), {
|
||||
status: 'accepted',
|
||||
projectId: 'default',
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
runVersion: 2,
|
||||
eventSequence: 1,
|
||||
cancelRequestedAtMs: NOW,
|
||||
cancelReason: 'user',
|
||||
});
|
||||
assert.equal(
|
||||
(await repository.requestUserCancellation(
|
||||
command({ eventId: '018f0000-0000-7000-8000-000000000002' }),
|
||||
)).status,
|
||||
'already_requested',
|
||||
);
|
||||
assert.deepEqual(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT "type", "dedupe_key" AS "dedupeKey", "actor_type" AS "actorType"
|
||||
FROM "RunEvents" WHERE "run_id" = 'run-1'`,
|
||||
)
|
||||
.all()
|
||||
.map((row) => ({ ...row })),
|
||||
[
|
||||
{
|
||||
type: 'run.cancel_requested',
|
||||
dedupeKey: 'user-cancel:mutation-1',
|
||||
actorType: 'user',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('returns terminal and masks missing or cross-Project Runs', async (t) => {
|
||||
const { repository } = await fixture(t, 'succeeded');
|
||||
assert.deepEqual(await repository.requestUserCancellation(command()), {
|
||||
status: 'already_terminal',
|
||||
projectId: 'default',
|
||||
runId: 'run-1',
|
||||
runStatus: 'succeeded',
|
||||
runVersion: 1,
|
||||
eventSequence: 0,
|
||||
});
|
||||
await assert.rejects(
|
||||
repository.requestUserCancellation(command({ runId: 'missing' })),
|
||||
RunCancellationNotFoundError,
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.requestUserCancellation(command({ projectId: 'other' })),
|
||||
RunCancellationNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
test('revalidates the latest RoleBinding inside the mutation transaction', async (t) => {
|
||||
const { client, repository } = await fixture(t);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES ('default', 'user', 'user-1', 2, 'revoked', NULL,
|
||||
'revoke-operator', 'user', 'user-1', ?)`,
|
||||
)
|
||||
.run(NOW - 100);
|
||||
await assert.rejects(
|
||||
repository.requestUserCancellation(command()),
|
||||
(error) =>
|
||||
error instanceof RunCancellationFenceRejectedError &&
|
||||
error.reason === 'authorization_changed',
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(`SELECT "cancel_requested_at_ms" AS value FROM "Runs" WHERE "id" = 'run-1'`)
|
||||
.get().value,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('rolls back the Run mutation when Event persistence fails', async (t) => {
|
||||
const { client, repository } = await fixture(t);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "RunEvents" (
|
||||
"id", "run_id", "sequence", "type", "dedupe_key", "actor_type",
|
||||
"payload", "created_at_ms"
|
||||
) VALUES (?, 'run-1', 1, 'run.started', 'existing', 'system', '{}', ?)`,
|
||||
)
|
||||
.run(EVENT_ID, NOW - 1);
|
||||
await assert.rejects(
|
||||
repository.requestUserCancellation(command()),
|
||||
RunCancellationUnavailableError,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...client
|
||||
.prepare(
|
||||
`SELECT "version", "event_sequence" AS "eventSequence",
|
||||
"cancel_requested_at_ms" AS "cancelRequestedAtMs"
|
||||
FROM "Runs" WHERE "id" = 'run-1'`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ version: 1, eventSequence: 0, cancelRequestedAtMs: null },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
registerRunRepositoryContract,
|
||||
} = require('../../../test/contracts/runRepositoryContract.cjs');
|
||||
const {
|
||||
DuplicateIdempotencyKeyError,
|
||||
DuplicateRunAttemptError,
|
||||
DuplicateRunEventError,
|
||||
MAX_CANCELLATION_RECOVERY_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAYLOAD_BYTES,
|
||||
RunEventPayloadTooLargeError,
|
||||
} = require('@qinglong/runtime-core');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
|
||||
registerRunRepositoryContract({
|
||||
name: 'Node SQLite local Profile binding',
|
||||
defaultExecutionOwner: 'runtime',
|
||||
contract: {
|
||||
DuplicateIdempotencyKeyError,
|
||||
DuplicateRunAttemptError,
|
||||
DuplicateRunEventError,
|
||||
RunEventPayloadTooLargeError,
|
||||
MAX_CANCELLATION_RECOVERY_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAGE_SIZE,
|
||||
MAX_RUN_EVENT_PAYLOAD_BYTES,
|
||||
},
|
||||
async createRepository() {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-local-repository-contract-'),
|
||||
);
|
||||
const options = {
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
profile: 'edge',
|
||||
};
|
||||
await migrateLocalSqlitePath(options);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(options);
|
||||
return {
|
||||
repository: runtime.runRepository,
|
||||
async close() {
|
||||
try {
|
||||
await runtime.close();
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const { LocalSqliteRunRepository } = require('../dist');
|
||||
|
||||
const FORBIDDEN_SECURITY_METHODS = Object.freeze([
|
||||
'resolveProjectPolicy',
|
||||
'appendProjectRoleBinding',
|
||||
'record',
|
||||
'resolveLocalSecretAdministrationMutation',
|
||||
'appendAuthorizedLocalSecretEnvelope',
|
||||
'appendLocalSecretEnvelope',
|
||||
'findLocalSecretEnvelopeByMutation',
|
||||
'resolveLocalSecretEnvelopes',
|
||||
]);
|
||||
|
||||
const FORBIDDEN_RUNTIME_CAPABILITY_METHODS = Object.freeze([
|
||||
'inspectCandidates',
|
||||
'listLocalDispatchCandidates',
|
||||
'listLocalExecutionControlCandidates',
|
||||
'listLocalActiveExecutions',
|
||||
'resolveLocalTaskExecutionRevision',
|
||||
'resolveLocalExecutionContextRecipe',
|
||||
'appendLocalExecutionContextRecipe',
|
||||
'appendLocalTaskExecutionRevision',
|
||||
'register',
|
||||
'markQuarantined',
|
||||
'resolve',
|
||||
'listCandidates',
|
||||
]);
|
||||
|
||||
test('Run facade excludes Policy, Audit and Secret authorities', () => {
|
||||
for (const method of FORBIDDEN_SECURITY_METHODS) {
|
||||
assert.equal(
|
||||
Object.hasOwn(LocalSqliteRunRepository.prototype, method),
|
||||
false,
|
||||
`${method} must remain owned by the Security authority`,
|
||||
);
|
||||
}
|
||||
|
||||
const declaration = fs.readFileSync(
|
||||
path.join(__dirname, '../dist/run/runRepository.d.ts'),
|
||||
'utf8',
|
||||
);
|
||||
for (const method of FORBIDDEN_SECURITY_METHODS) {
|
||||
assert.doesNotMatch(declaration, new RegExp(`\\b${method}\\b`, 'u'));
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
declaration,
|
||||
/local-secret|project-policy|security-audit|SecurityAuthorityStore/u,
|
||||
);
|
||||
|
||||
const runtime = fs.readFileSync(
|
||||
path.join(__dirname, '../dist/run/runRepository.js'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
runtime,
|
||||
/securityAuthorityStore|local-secret|project-policy|security-audit/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('Run repository excludes Dispatch, Control, Recovery and Receipt capabilities', () => {
|
||||
for (const method of FORBIDDEN_RUNTIME_CAPABILITY_METHODS) {
|
||||
assert.equal(
|
||||
Object.hasOwn(LocalSqliteRunRepository.prototype, method),
|
||||
false,
|
||||
`${method} must remain on its least-authority runtime capability`,
|
||||
);
|
||||
}
|
||||
|
||||
const declaration = fs.readFileSync(
|
||||
path.join(__dirname, '../dist/run/runRepository.d.ts'),
|
||||
'utf8',
|
||||
);
|
||||
for (const method of FORBIDDEN_RUNTIME_CAPABILITY_METHODS) {
|
||||
assert.doesNotMatch(declaration, new RegExp(`\\b${method}\\b`, 'u'));
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
declaration,
|
||||
/LocalDispatchStore|LocalExecutionControlSource|LocalRunStartupRecoverySource|LocalCompletionReceiptJournal/u,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
resolveLocalScheduleDecision,
|
||||
} = require('@qinglong/runtime-core/local-scheduler');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
|
||||
function nextMinute(schedule, afterMs) {
|
||||
if (schedule.expression !== '* * * * *' || schedule.timezone !== 'UTC') {
|
||||
throw new Error('unsupported test schedule');
|
||||
}
|
||||
return Math.floor(afterMs / 60_000 + 1) * 60_000;
|
||||
}
|
||||
|
||||
function id(value) {
|
||||
return `019f7400-0000-4000-8000-${String(value).padStart(12, '0')}`;
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-schedule-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => runtime.close());
|
||||
const task = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision({
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7410-0000-7000-8000-000000000001',
|
||||
name: 'Scheduled task',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: ['scheduled'] },
|
||||
},
|
||||
},
|
||||
labels: {},
|
||||
enabled: true,
|
||||
occurredAtMs: 1,
|
||||
})
|
||||
).definition;
|
||||
await runtime.triggers.appendTriggerRevision({
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7420-0000-7000-8000-000000000001',
|
||||
taskId: task.taskId,
|
||||
taskRevision: task.revision,
|
||||
taskContentDigest: task.contentDigest,
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: '* * * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
occurredAtMs: 1,
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
|
||||
test('atomically advances one due Trigger into a queued Run aggregate', async (t) => {
|
||||
const runtime = await fixture(t);
|
||||
const page = await runtime.schedules.listLocalScheduleCandidates({
|
||||
observedAtMs: 61_000,
|
||||
limit: 4,
|
||||
});
|
||||
assert.equal(page.candidates.length, 1);
|
||||
assert.equal(page.truncated, false);
|
||||
assert.equal(page.candidates[0].nextFireAtMs, null);
|
||||
const decision = resolveLocalScheduleDecision(
|
||||
page.candidates[0],
|
||||
61_000,
|
||||
5_000,
|
||||
nextMinute,
|
||||
);
|
||||
const admitted = await runtime.schedules.commitLocalScheduleDecision({
|
||||
decision,
|
||||
runId: id(1),
|
||||
attemptId: id(2),
|
||||
createdEventId: id(3),
|
||||
queuedEventId: id(4),
|
||||
});
|
||||
assert.deepEqual(admitted, {
|
||||
status: 'admitted',
|
||||
disposition: 'admit',
|
||||
runId: id(1),
|
||||
attemptId: id(2),
|
||||
});
|
||||
const run = await runtime.runRepository.findRunById(id(1));
|
||||
assert.equal(run.status, 'queued');
|
||||
assert.equal(run.executionOwner, 'runtime');
|
||||
assert.equal(run.executionOrigin, 'scheduled_system');
|
||||
assert.equal(run.triggerId, 'trigger-1');
|
||||
assert.equal(run.scheduledForMs, 60_000);
|
||||
assert.match(run.taskRevision, /^qltd:v1:1:[a-f0-9]{64}$/);
|
||||
assert.equal(
|
||||
(await runtime.runRepository.findAttemptById(id(2))).status,
|
||||
'claimed',
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await runtime.runRepository.listEvents(id(1))).map((event) => event.type),
|
||||
['run.created', 'run.queued'],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await runtime.schedules.listLocalScheduleCandidates({
|
||||
observedAtMs: 61_000,
|
||||
limit: 4,
|
||||
})
|
||||
).candidates.length,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await runtime.schedules.commitLocalScheduleDecision({
|
||||
decision,
|
||||
runId: id(5),
|
||||
attemptId: id(6),
|
||||
createdEventId: id(7),
|
||||
queuedEventId: id(8),
|
||||
}),
|
||||
{ status: 'raced' },
|
||||
);
|
||||
});
|
||||
|
||||
test('fences a stale Task head before schedule discovery and final Run commit', async (t) => {
|
||||
const runtime = await fixture(t);
|
||||
const page = await runtime.schedules.listLocalScheduleCandidates({
|
||||
observedAtMs: 61_000,
|
||||
limit: 4,
|
||||
});
|
||||
const decision = resolveLocalScheduleDecision(
|
||||
page.candidates[0],
|
||||
61_000,
|
||||
5_000,
|
||||
nextMinute,
|
||||
);
|
||||
const task = await runtime.taskDefinitions.findCurrentTaskDefinition(
|
||||
'default',
|
||||
'task-1',
|
||||
);
|
||||
const disabled = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision({
|
||||
projectId: task.projectId,
|
||||
taskId: task.taskId,
|
||||
expectedRevision: task.revision,
|
||||
mutationId: '019f7410-0000-7000-8000-000000000002',
|
||||
name: task.name,
|
||||
kind: task.kind,
|
||||
spec: task.spec,
|
||||
labels: task.labels,
|
||||
enabled: false,
|
||||
occurredAtMs: 2,
|
||||
})
|
||||
).definition;
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await runtime.schedules.listLocalScheduleCandidates({
|
||||
observedAtMs: 61_000,
|
||||
limit: 4,
|
||||
})
|
||||
).candidates.length,
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await runtime.schedules.commitLocalScheduleDecision({
|
||||
decision,
|
||||
runId: id(21),
|
||||
attemptId: id(22),
|
||||
createdEventId: id(23),
|
||||
queuedEventId: id(24),
|
||||
}),
|
||||
{ status: 'raced' },
|
||||
);
|
||||
assert.equal(await runtime.runRepository.findRunById(id(21)), null);
|
||||
|
||||
await assert.rejects(
|
||||
runtime.triggers.appendTriggerRevision({
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
expectedRevision: 1,
|
||||
mutationId: '019f7420-0000-7000-8000-000000000002',
|
||||
taskId: task.taskId,
|
||||
taskRevision: task.revision,
|
||||
taskContentDigest: task.contentDigest,
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: '* * * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
occurredAtMs: 3,
|
||||
}),
|
||||
{ code: 'TRIGGER_CONFLICT' },
|
||||
);
|
||||
|
||||
const enabled = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision({
|
||||
projectId: disabled.projectId,
|
||||
taskId: disabled.taskId,
|
||||
expectedRevision: disabled.revision,
|
||||
mutationId: '019f7410-0000-7000-8000-000000000003',
|
||||
name: disabled.name,
|
||||
kind: disabled.kind,
|
||||
spec: disabled.spec,
|
||||
labels: disabled.labels,
|
||||
enabled: true,
|
||||
occurredAtMs: 4,
|
||||
})
|
||||
).definition;
|
||||
await runtime.triggers.appendTriggerRevision({
|
||||
projectId: 'default',
|
||||
triggerId: 'trigger-1',
|
||||
expectedRevision: 1,
|
||||
mutationId: '019f7420-0000-7000-8000-000000000003',
|
||||
taskId: enabled.taskId,
|
||||
taskRevision: enabled.revision,
|
||||
taskContentDigest: enabled.contentDigest,
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: '* * * * *',
|
||||
timezone: 'UTC',
|
||||
misfirePolicy: 'skip',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
occurredAtMs: 5,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await runtime.schedules.listLocalScheduleCandidates({
|
||||
observedAtMs: 61_000,
|
||||
limit: 4,
|
||||
})
|
||||
).candidates.length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
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 { getTableName } = require('drizzle-orm');
|
||||
const { getTableConfig } = require('drizzle-orm/sqlite-core');
|
||||
const { migrateLocalSqlitePath } = require('../dist/migration/migration');
|
||||
const { localSqliteSchema } = require('../dist/storage/schema');
|
||||
|
||||
function sorted(values) {
|
||||
return [...values].sort((left, right) =>
|
||||
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
||||
);
|
||||
}
|
||||
|
||||
function tableSql(client, tableName) {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?`,
|
||||
)
|
||||
.get(tableName);
|
||||
assert.equal(typeof row?.sql, 'string');
|
||||
return row.sql;
|
||||
}
|
||||
|
||||
function catalogChecks(sql) {
|
||||
return [...sql.matchAll(/CONSTRAINT\s+([A-Za-z0-9_]+)\s+CHECK\b/gi)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
}
|
||||
|
||||
function catalogForeignKeys(client, tableName) {
|
||||
const grouped = new Map();
|
||||
for (const entry of client
|
||||
.prepare(`PRAGMA foreign_key_list("${tableName}")`)
|
||||
.all()) {
|
||||
const current = grouped.get(entry.id) ?? {
|
||||
columns: [],
|
||||
foreignTable: entry.table,
|
||||
foreignColumns: [],
|
||||
onDelete: entry.on_delete,
|
||||
onUpdate: entry.on_update,
|
||||
};
|
||||
current.columns[entry.seq] = entry.from;
|
||||
current.foreignColumns[entry.seq] = entry.to;
|
||||
grouped.set(entry.id, current);
|
||||
}
|
||||
return [...grouped.values()];
|
||||
}
|
||||
|
||||
function drizzleForeignKeys(config) {
|
||||
return config.foreignKeys.map((foreignKey) => {
|
||||
const reference = foreignKey.reference();
|
||||
return {
|
||||
columns: reference.columns.map(({ name }) => name),
|
||||
foreignTable: getTableName(reference.foreignTable),
|
||||
foreignColumns: reference.foreignColumns.map(({ name }) => name),
|
||||
onDelete: (foreignKey.onDelete ?? 'no action').toUpperCase(),
|
||||
onUpdate: (foreignKey.onUpdate ?? 'no action').toUpperCase(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test('typed SQLite schema matches every reviewed table, column, index, check and foreign key', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-schema-lockstep-'));
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
|
||||
const client = new DatabaseSync(databasePath, { readOnly: true });
|
||||
try {
|
||||
const actualTables = client
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_schema
|
||||
WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`,
|
||||
)
|
||||
.all()
|
||||
.map(({ name }) => name);
|
||||
const drizzleTables = Object.values(localSqliteSchema).map(getTableConfig);
|
||||
assert.deepEqual(
|
||||
actualTables,
|
||||
drizzleTables.map(({ name }) => name).sort(),
|
||||
);
|
||||
|
||||
for (const config of drizzleTables) {
|
||||
const columns = client
|
||||
.prepare(`PRAGMA table_info("${config.name}")`)
|
||||
.all()
|
||||
.map(({ name }) => name);
|
||||
assert.deepEqual(
|
||||
columns,
|
||||
config.columns.map(({ name }) => name),
|
||||
`${config.name} columns`,
|
||||
);
|
||||
|
||||
const indexes = client
|
||||
.prepare(`PRAGMA index_list("${config.name}")`)
|
||||
.all()
|
||||
.map(({ name }) => name)
|
||||
.filter((name) => !name.startsWith('sqlite_autoindex_'));
|
||||
assert.deepEqual(
|
||||
indexes.sort(),
|
||||
config.indexes.map(({ config: value }) => value.name).sort(),
|
||||
`${config.name} indexes`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
catalogChecks(tableSql(client, config.name)).sort(),
|
||||
config.checks.map(({ name }) => name).sort(),
|
||||
`${config.name} checks`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
sorted(catalogForeignKeys(client, config.name)),
|
||||
sorted(drizzleForeignKeys(config)),
|
||||
`${config.name} foreign keys`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
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 {
|
||||
LocalSqliteSecurityAuditQueryRepository,
|
||||
} = require('@qinglong/local-sqlite/security-audit-query');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('@qinglong/local-sqlite/operation-authority');
|
||||
const {
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError,
|
||||
} = require('@qinglong/runtime-core/local-security-audit-query');
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-security-audit-query-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
const now = 10_000;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects" (
|
||||
"id", "name", "slug", "status", "version",
|
||||
"created_at_ms", "updated_at_ms"
|
||||
) VALUES ('project-alpha', 'Project Alpha', 'project-alpha',
|
||||
'active', 1, ?, ?)`,
|
||||
)
|
||||
.run(now - 200, now - 200);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'owner-user', 1, 'active', 'owner', ?,
|
||||
'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run('96000000-0000-4000-8000-000000000001', now - 100);
|
||||
const insertAudit = client.prepare(
|
||||
`INSERT INTO "QingLong3SecurityAuditEvents" (
|
||||
"event_id", "request_id", "operation_id", "project_id",
|
||||
"subject_type", "subject_id", "authentication_id", "outcome",
|
||||
"reasons_json", "fence_project_version", "fence_binding_version",
|
||||
"occurred_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const [eventId, occurredAtMs, outcome, subjectId] of [
|
||||
['97000000-0000-4000-8000-000000000003', 9_003, 'denied', 'planner'],
|
||||
['97000000-0000-4000-8000-000000000002', 9_002, 'denied', 'planner'],
|
||||
['97000000-0000-4000-8000-000000000001', 9_001, 'denied', 'planner'],
|
||||
['97000000-0000-4000-8000-000000000000', 9_000, 'allowed', 'planner'],
|
||||
]) {
|
||||
insertAudit.run(
|
||||
eventId,
|
||||
`request-${occurredAtMs}`,
|
||||
'tool.invoke',
|
||||
'project-alpha',
|
||||
'agent',
|
||||
subjectId,
|
||||
`private-auth-${occurredAtMs}`,
|
||||
outcome,
|
||||
'["policy_result"]',
|
||||
1,
|
||||
1,
|
||||
occurredAtMs,
|
||||
);
|
||||
}
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
t.after(() => authority.close());
|
||||
return {
|
||||
client,
|
||||
repository: new LocalSqliteSecurityAuditQueryRepository(
|
||||
authority,
|
||||
() => {},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(overrides = {}) {
|
||||
return {
|
||||
authorityProjectId: 'default',
|
||||
actor: { type: 'user', id: 'owner-user' },
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function queryAudit(eventId) {
|
||||
return {
|
||||
eventId,
|
||||
requestId: `audit-query-${eventId.at(-1)}`,
|
||||
operationId: 'security.audit.list',
|
||||
projectId: 'default',
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_security_audit:test',
|
||||
outcome: 'allowed',
|
||||
reasons: ['instance_authority_security_audit_query'],
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
occurredAtMs: 10_000 + Number(eventId.at(-1)),
|
||||
};
|
||||
}
|
||||
|
||||
test('filters and keyset-pages a pre-audit snapshot with an exact has-more cursor', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const filter = {
|
||||
projectId: 'project-alpha',
|
||||
subject: { type: 'agent', id: 'planner' },
|
||||
outcome: 'denied',
|
||||
};
|
||||
const first = await value.repository.listAuthorized({
|
||||
query: { limit: 2, filter },
|
||||
authorization: authorization(),
|
||||
audit: queryAudit('98000000-0000-4000-8000-000000000001'),
|
||||
});
|
||||
assert.deepEqual(
|
||||
first.records.map((record) => record.eventId),
|
||||
[
|
||||
'97000000-0000-4000-8000-000000000003',
|
||||
'97000000-0000-4000-8000-000000000002',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(first.nextCursor, {
|
||||
occurredAtMs: 9_002,
|
||||
eventId: '97000000-0000-4000-8000-000000000002',
|
||||
});
|
||||
assert.equal(
|
||||
first.records.some(
|
||||
(record) => record.eventId === '98000000-0000-4000-8000-000000000001',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const second = await value.repository.listAuthorized({
|
||||
query: { limit: 2, before: first.nextCursor, filter },
|
||||
authorization: authorization(),
|
||||
audit: queryAudit('98000000-0000-4000-8000-000000000002'),
|
||||
});
|
||||
assert.deepEqual(
|
||||
second.records.map((record) => record.eventId),
|
||||
['97000000-0000-4000-8000-000000000001'],
|
||||
);
|
||||
assert.equal(second.nextCursor, null);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT count(*) AS "count"
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'security.audit.list'
|
||||
AND "outcome" = 'allowed'`,
|
||||
)
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a foreign instance authority before reading or auditing rows', async (t) => {
|
||||
const value = await fixture(t);
|
||||
await assert.rejects(
|
||||
value.repository.listAuthorized({
|
||||
query: { limit: 1, filter: {} },
|
||||
authorization: authorization({
|
||||
authorityProjectId: 'project-alpha',
|
||||
}),
|
||||
audit: {
|
||||
...queryAudit('99000000-0000-4000-8000-000000000001'),
|
||||
projectId: 'project-alpha',
|
||||
},
|
||||
}),
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT count(*) AS "count"
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ?`,
|
||||
)
|
||||
.get('99000000-0000-4000-8000-000000000001').count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
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 { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('@qinglong/local-sqlite/operation-authority');
|
||||
const {
|
||||
LocalSqliteSecurityAuditRetentionRepository,
|
||||
} = require('@qinglong/local-sqlite/security-audit-retention');
|
||||
const {
|
||||
LocalSecurityAuditCompactionMutationConflictError,
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
|
||||
MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
localSecurityAuditCompactionPayload,
|
||||
} = require('@qinglong/runtime-core/local-security-audit-retention');
|
||||
|
||||
const NOW = 4_000_000_000;
|
||||
const CUTOFF = NOW - MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS;
|
||||
|
||||
function audit(eventId, occurredAtMs, outcome, operationId = 'tool.invoke') {
|
||||
return {
|
||||
eventId,
|
||||
requestId: `request-${eventId}`,
|
||||
operationId,
|
||||
projectId: 'default',
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_security_audit:test',
|
||||
outcome,
|
||||
reasons: ['test_reason'],
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
occurredAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function insertAudit(client, value) {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3SecurityAuditEvents" (
|
||||
"event_id", "request_id", "operation_id", "project_id",
|
||||
"subject_type", "subject_id", "authentication_id", "outcome",
|
||||
"reasons_json", "fence_project_version", "fence_binding_version",
|
||||
"occurred_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
value.eventId,
|
||||
value.requestId,
|
||||
value.operationId,
|
||||
value.projectId,
|
||||
value.subject.type,
|
||||
value.subject.id,
|
||||
value.authenticationId,
|
||||
value.outcome,
|
||||
JSON.stringify(value.reasons),
|
||||
value.fence.projectVersion,
|
||||
value.fence.bindingVersion,
|
||||
value.occurredAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
async function fixture(t, profile = 'edge') {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-security-audit-retention-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile });
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES (
|
||||
'default', 'user', 'owner-user', 1, 'active', 'owner', ?,
|
||||
'user', 'owner-user', ?
|
||||
)`,
|
||||
)
|
||||
.run('b1000000-0000-4000-8000-000000000001', NOW - 1_000);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
t.after(() => authority.close());
|
||||
return {
|
||||
client,
|
||||
repository: new LocalSqliteSecurityAuditRetentionRepository(
|
||||
authority,
|
||||
() => {},
|
||||
profile === 'edge' ? 64 : 512,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(overrides = {}) {
|
||||
return {
|
||||
authorityProjectId: 'default',
|
||||
actor: { type: 'user', id: 'owner-user' },
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function command(mutationId, overrides = {}) {
|
||||
return {
|
||||
mutationId,
|
||||
requestId: `compact-${mutationId}`,
|
||||
retentionMs: MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
eligibleBeforeMs: CUTOFF,
|
||||
limit: 64,
|
||||
authorization: authorization(),
|
||||
audit: {
|
||||
...audit(mutationId, NOW, 'allowed', 'security.audit.compact'),
|
||||
requestId: `compact-${mutationId}`,
|
||||
reasons: ['instance_authority_security_audit_compaction'],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function existingIds(client) {
|
||||
return client
|
||||
.prepare(
|
||||
`SELECT "event_id" AS "eventId"
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
ORDER BY "event_id"`,
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.eventId);
|
||||
}
|
||||
|
||||
test('deletes only unreferenced expired diagnostic audit and keeps an immutable receipt', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const denied = audit(
|
||||
'b2000000-0000-4000-8000-000000000001',
|
||||
CUTOFF - 4,
|
||||
'denied',
|
||||
);
|
||||
const diagnostic = audit(
|
||||
'b2000000-0000-4000-8000-000000000002',
|
||||
CUTOFF - 3,
|
||||
'allowed',
|
||||
'security.audit.list',
|
||||
);
|
||||
const allowedMutation = audit(
|
||||
'b2000000-0000-4000-8000-000000000003',
|
||||
CUTOFF - 2,
|
||||
'allowed',
|
||||
'policy.project.create',
|
||||
);
|
||||
const referenced = audit(
|
||||
'b2000000-0000-4000-8000-000000000004',
|
||||
CUTOFF - 1,
|
||||
'denied',
|
||||
);
|
||||
const recent = audit(
|
||||
'b2000000-0000-4000-8000-000000000005',
|
||||
CUTOFF,
|
||||
'denied',
|
||||
);
|
||||
for (const record of [
|
||||
denied,
|
||||
diagnostic,
|
||||
allowedMutation,
|
||||
referenced,
|
||||
recent,
|
||||
]) {
|
||||
insertAudit(value.client, record);
|
||||
}
|
||||
value.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LegacyAdoptions" (
|
||||
"mutation_id", "decision_id", "project_id", "profile",
|
||||
"plan_digest", "inventory_digest", "decision_digest",
|
||||
"receipt_digest", "authorization_file_digest",
|
||||
"publication_digest", "row_count", "adopted_task_count",
|
||||
"adopted_trigger_count", "skipped_count", "audit_event_id",
|
||||
"created_at_ms"
|
||||
) VALUES (?, ?, 'default', 'edge', ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
referenced.eventId,
|
||||
'b2000000-0000-7000-8000-000000000004',
|
||||
...Array(6).fill('a'.repeat(64)),
|
||||
referenced.eventId,
|
||||
referenced.occurredAtMs,
|
||||
);
|
||||
|
||||
const mutationId = 'b3000000-0000-4000-8000-000000000001';
|
||||
const result = await value.repository.compactAuthorized(command(mutationId));
|
||||
assert.equal(result.status, 'inserted');
|
||||
assert.equal(result.record.deletedCount, 2);
|
||||
assert.deepEqual(result.record.first, {
|
||||
occurredAtMs: denied.occurredAtMs,
|
||||
eventId: denied.eventId,
|
||||
});
|
||||
assert.deepEqual(result.record.last, {
|
||||
occurredAtMs: diagnostic.occurredAtMs,
|
||||
eventId: diagnostic.eventId,
|
||||
});
|
||||
assert.deepEqual(
|
||||
{
|
||||
recordsDigest: result.record.recordsDigest,
|
||||
payloadBytes: result.record.deletedPayloadBytes,
|
||||
},
|
||||
localSecurityAuditCompactionPayload([denied, diagnostic]),
|
||||
);
|
||||
assert.deepEqual(existingIds(value.client), [
|
||||
allowedMutation.eventId,
|
||||
referenced.eventId,
|
||||
recent.eventId,
|
||||
mutationId,
|
||||
]);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT count(*) AS "count"
|
||||
FROM "QingLong3SecurityAuditCompactions"
|
||||
WHERE "mutation_id" = ? AND "audit_event_id" = ?`,
|
||||
)
|
||||
.get(mutationId, mutationId).count,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('exactly replays a batch and rejects semantic drift', async (t) => {
|
||||
const value = await fixture(t);
|
||||
insertAudit(
|
||||
value.client,
|
||||
audit('b4000000-0000-4000-8000-000000000001', CUTOFF - 1, 'denied'),
|
||||
);
|
||||
const mutationId = 'b5000000-0000-4000-8000-000000000001';
|
||||
const input = command(mutationId);
|
||||
const inserted = await value.repository.compactAuthorized(input);
|
||||
const replay = await value.repository.compactAuthorized(input);
|
||||
assert.equal(inserted.status, 'inserted');
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.deepEqual(replay.record, inserted.record);
|
||||
await assert.rejects(
|
||||
value.repository.compactAuthorized({
|
||||
...input,
|
||||
requestId: 'compact-drift',
|
||||
audit: { ...input.audit, requestId: 'compact-drift' },
|
||||
}),
|
||||
LocalSecurityAuditCompactionMutationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('honors the hard batch cap and advances only through fresh mutations', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const firstCandidate = audit(
|
||||
'b8000000-0000-4000-8000-000000000001',
|
||||
CUTOFF - 2,
|
||||
'denied',
|
||||
);
|
||||
const secondCandidate = audit(
|
||||
'b8000000-0000-4000-8000-000000000002',
|
||||
CUTOFF - 1,
|
||||
'denied',
|
||||
);
|
||||
insertAudit(value.client, firstCandidate);
|
||||
insertAudit(value.client, secondCandidate);
|
||||
|
||||
const first = await value.repository.compactAuthorized(
|
||||
command('b9000000-0000-4000-8000-000000000001', { limit: 1 }),
|
||||
);
|
||||
assert.equal(first.record.deletedCount, 1);
|
||||
assert.deepEqual(existingIds(value.client), [
|
||||
secondCandidate.eventId,
|
||||
first.record.mutationId,
|
||||
]);
|
||||
|
||||
const second = await value.repository.compactAuthorized(
|
||||
command('b9000000-0000-4000-8000-000000000002', { limit: 1 }),
|
||||
);
|
||||
assert.equal(second.record.deletedCount, 1);
|
||||
assert.deepEqual(existingIds(value.client), [
|
||||
first.record.mutationId,
|
||||
second.record.mutationId,
|
||||
]);
|
||||
|
||||
const empty = await value.repository.compactAuthorized(
|
||||
command('b9000000-0000-4000-8000-000000000003', { limit: 1 }),
|
||||
);
|
||||
assert.equal(empty.record.deletedCount, 0);
|
||||
assert.equal(empty.record.deletedPayloadBytes, 0);
|
||||
assert.equal(empty.record.first, null);
|
||||
assert.equal(empty.record.last, null);
|
||||
});
|
||||
|
||||
test('rejects a foreign authority without deleting or writing a receipt', async (t) => {
|
||||
const value = await fixture(t);
|
||||
const candidate = audit(
|
||||
'b6000000-0000-4000-8000-000000000001',
|
||||
CUTOFF - 1,
|
||||
'denied',
|
||||
);
|
||||
insertAudit(value.client, candidate);
|
||||
const input = command('b7000000-0000-4000-8000-000000000001');
|
||||
await assert.rejects(
|
||||
value.repository.compactAuthorized({
|
||||
...input,
|
||||
authorization: authorization({ authorityProjectId: 'foreign' }),
|
||||
audit: { ...input.audit, projectId: 'foreign' },
|
||||
}),
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
|
||||
);
|
||||
assert.deepEqual(existingIds(value.client), [candidate.eventId]);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT count(*) AS "count"
|
||||
FROM "QingLong3SecurityAuditCompactions"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createSqlitePersistencePrimitives,
|
||||
isSqliteDriverError,
|
||||
sqliteDriverErrorCode,
|
||||
sqliteDriverErrorMessage,
|
||||
sqliteDriverErrorNumber,
|
||||
} = require('../dist/storage/sqlitePersistence');
|
||||
const runPersistence = require('../dist/run/runPersistence');
|
||||
const securityPersistence = require('../dist/security/securityPersistence');
|
||||
|
||||
class AdapterError extends Error {}
|
||||
|
||||
function primitives() {
|
||||
return createSqlitePersistencePrimitives({
|
||||
invalidRowValue: (property) => new AdapterError(`invalid:${property}`),
|
||||
invalidJson: (property) => new AdapterError(`json:${property}`),
|
||||
unsupportedRowValue: (property) =>
|
||||
new AdapterError(`unsupported:${property}`),
|
||||
duplicateIdentityRows: () => new AdapterError('duplicate'),
|
||||
mapDriverError: (error) => new AdapterError('driver', { cause: error }),
|
||||
});
|
||||
}
|
||||
|
||||
test('domain-neutral row primitives preserve scalar, JSON and blob semantics', () => {
|
||||
const persistence = primitives();
|
||||
const source = Uint8Array.from([1, 2, 3]);
|
||||
const row = {
|
||||
text: 'value',
|
||||
empty: '',
|
||||
absent: null,
|
||||
integer: 42,
|
||||
unsafe: Number.MAX_SAFE_INTEGER + 1,
|
||||
enabled: 1,
|
||||
disabled: 0,
|
||||
json: '{"ok":true}',
|
||||
brokenJson: '{',
|
||||
blob: source,
|
||||
state: 'ready',
|
||||
};
|
||||
|
||||
assert.equal(persistence.requiredString(row, 'text'), 'value');
|
||||
assert.equal(persistence.optionalString(row, 'empty'), '');
|
||||
assert.equal(persistence.optionalString(row, 'absent'), undefined);
|
||||
assert.equal(persistence.requiredInteger(row, 'integer'), 42);
|
||||
assert.equal(persistence.optionalInteger(row, 'absent'), undefined);
|
||||
assert.equal(persistence.requiredBoolean(row, 'enabled'), true);
|
||||
assert.equal(persistence.requiredBoolean(row, 'disabled'), false);
|
||||
assert.deepEqual(persistence.requiredJson(row, 'json'), { ok: true });
|
||||
assert.equal(
|
||||
persistence.requiredEnum(row, 'state', ['ready', 'done']),
|
||||
'ready',
|
||||
);
|
||||
const blob = persistence.requiredBlob(row, 'blob');
|
||||
assert.deepEqual(blob, Buffer.from([1, 2, 3]));
|
||||
source[0] = 9;
|
||||
assert.deepEqual(blob, Buffer.from([1, 2, 3]));
|
||||
|
||||
assert.throws(
|
||||
() => persistence.requiredString(row, 'empty'),
|
||||
/invalid:empty/,
|
||||
);
|
||||
assert.throws(
|
||||
() => persistence.requiredInteger(row, 'unsafe'),
|
||||
/invalid:unsafe/,
|
||||
);
|
||||
assert.throws(
|
||||
() => persistence.requiredJson(row, 'brokenJson'),
|
||||
/json:brokenJson/,
|
||||
);
|
||||
assert.throws(
|
||||
() => persistence.requiredEnum(row, 'state', ['done']),
|
||||
/unsupported:state/,
|
||||
);
|
||||
});
|
||||
|
||||
test('domain-neutral query primitives delegate all errors to the boundary contract', () => {
|
||||
const persistence = primitives();
|
||||
const expected = [{ id: 'one' }];
|
||||
const client = {
|
||||
prepare(sql) {
|
||||
assert.equal(sql, 'SELECT ?');
|
||||
return {
|
||||
all(...values) {
|
||||
assert.deepEqual(values, ['one']);
|
||||
return expected;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
persistence.queryRows(client, 'SELECT ?', ['one']),
|
||||
expected,
|
||||
);
|
||||
assert.equal(persistence.singleRow(expected), expected[0]);
|
||||
assert.equal(persistence.singleRow([]), null);
|
||||
assert.throws(
|
||||
() => persistence.singleRow([expected[0], { id: 'two' }]),
|
||||
/duplicate/,
|
||||
);
|
||||
|
||||
const driverFailure = new Error('sqlite failure');
|
||||
const failingClient = {
|
||||
prepare() {
|
||||
throw driverFailure;
|
||||
},
|
||||
};
|
||||
assert.throws(
|
||||
() => persistence.queryRows(failingClient, 'SELECT 1'),
|
||||
(error) =>
|
||||
error instanceof AdapterError &&
|
||||
error.message === 'driver' &&
|
||||
error.cause === driverFailure,
|
||||
);
|
||||
});
|
||||
|
||||
test('SQLite driver observation remains domain neutral', () => {
|
||||
const error = Object.assign(new Error('busy'), {
|
||||
code: 'ERR_SQLITE_ERROR',
|
||||
errcode: 5,
|
||||
});
|
||||
assert.equal(sqliteDriverErrorCode(error), 'ERR_SQLITE_ERROR');
|
||||
assert.equal(sqliteDriverErrorNumber(error), 5);
|
||||
assert.equal(sqliteDriverErrorMessage(error), 'busy');
|
||||
assert.equal(isSqliteDriverError(error), true);
|
||||
assert.equal(isSqliteDriverError(new Error('other')), false);
|
||||
assert.equal(sqliteDriverErrorMessage('other'), '');
|
||||
});
|
||||
|
||||
test('Run and Security adapters freeze the existing corruption contract', () => {
|
||||
for (const persistence of [runPersistence, securityPersistence]) {
|
||||
assert.throws(
|
||||
() => persistence.requiredString({ value: '' }, 'value'),
|
||||
(error) =>
|
||||
error.name === 'RunRepositoryConstraintError' &&
|
||||
error.message === 'Local SQLite Run row has an invalid value',
|
||||
);
|
||||
assert.throws(
|
||||
() => persistence.requiredBlob({ value: 'not-a-blob' }, 'value'),
|
||||
(error) =>
|
||||
error.name === 'RunRepositoryConstraintError' &&
|
||||
error.message === 'Local SQLite row has an invalid value',
|
||||
);
|
||||
assert.throws(
|
||||
() => persistence.singleRow([{ id: 'one' }, { id: 'two' }]),
|
||||
(error) =>
|
||||
error.name === 'RunRepositoryConstraintError' &&
|
||||
error.message ===
|
||||
'Local SQLite Run repository returned duplicate identity rows',
|
||||
);
|
||||
|
||||
const constraint = persistence.mapSqliteError({
|
||||
code: 'ERR_SQLITE_CONSTRAINT',
|
||||
});
|
||||
assert.equal(constraint.name, 'RunRepositoryConstraintError');
|
||||
assert.equal(
|
||||
constraint.message,
|
||||
'Local SQLite Run repository constraint violation',
|
||||
);
|
||||
assert.equal(
|
||||
persistence.mapSqliteError({ errcode: 5 }).name,
|
||||
'RunRepositoryBusyError',
|
||||
);
|
||||
assert.equal(
|
||||
persistence.mapSqliteError(new Error('other')).name,
|
||||
'RunRepositoryOperationError',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
MAX_LOCAL_RUN_STARTUP_RECOVERY_CANDIDATES,
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
|
||||
async function database(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-local-startup-recovery-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const databasePath = path.join(directory, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const opened = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => opened.close());
|
||||
return opened;
|
||||
}
|
||||
|
||||
function run(id, status, executionOwner = 'runtime') {
|
||||
return {
|
||||
id,
|
||||
projectId: 'default',
|
||||
taskId: `task-${id}`,
|
||||
taskRevision: `revision-${id}`,
|
||||
triggerType: 'manual',
|
||||
executionOrigin: 'manual',
|
||||
executionOwner,
|
||||
status,
|
||||
version: 0,
|
||||
eventSequence: 0,
|
||||
priority: 0,
|
||||
createdAtMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function attempt(id, runId, status = 'running') {
|
||||
return {
|
||||
id,
|
||||
runId,
|
||||
attempt: 1,
|
||||
status,
|
||||
executorType: 'local_process',
|
||||
callbackSequence: 0,
|
||||
createdAtMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
test('finds only runtime-owned dispatching or running Runs', async (t) => {
|
||||
const opened = await database(t);
|
||||
await opened.runRepository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run('run-1', 'running'));
|
||||
await transaction.insertAttempt(attempt('attempt-1', 'run-1'));
|
||||
await transaction.insertRun(run('run-2', 'dispatching'));
|
||||
await transaction.insertRun(run('run-3', 'succeeded'));
|
||||
await transaction.insertRun(run('run-4', 'running', 'legacy'));
|
||||
});
|
||||
|
||||
const page = await opened.startupRecovery.inspectCandidates();
|
||||
assert.deepEqual(page, {
|
||||
candidates: [
|
||||
{
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
activeAttemptCount: 1,
|
||||
},
|
||||
{
|
||||
runId: 'run-2',
|
||||
runStatus: 'dispatching',
|
||||
activeAttemptCount: 0,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns a bounded deterministic page and explicit truncation', async (t) => {
|
||||
const opened = await database(t);
|
||||
await opened.runRepository.transaction(async (transaction) => {
|
||||
await transaction.insertRun(run('run-3', 'running'));
|
||||
await transaction.insertRun(run('run-1', 'running'));
|
||||
await transaction.insertRun(run('run-2', 'running'));
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await opened.startupRecovery.inspectCandidates({ limit: 2 }),
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
runId: 'run-1',
|
||||
runStatus: 'running',
|
||||
activeAttemptCount: 0,
|
||||
},
|
||||
{
|
||||
runId: 'run-2',
|
||||
runStatus: 'running',
|
||||
activeAttemptCount: 0,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects invalid limits and shares repository close fencing', async (t) => {
|
||||
const opened = await database(t);
|
||||
await assert.rejects(
|
||||
opened.startupRecovery.inspectCandidates({ limit: 0 }),
|
||||
/limit must be between 1/,
|
||||
);
|
||||
await assert.rejects(
|
||||
opened.startupRecovery.inspectCandidates({
|
||||
limit: MAX_LOCAL_RUN_STARTUP_RECOVERY_CANDIDATES + 1,
|
||||
}),
|
||||
/limit must be between 1/,
|
||||
);
|
||||
await opened.close();
|
||||
await assert.rejects(
|
||||
opened.startupRecovery.inspectCandidates(),
|
||||
(error) =>
|
||||
error &&
|
||||
error.name === 'RunRepositoryOperationError' &&
|
||||
error.cause instanceof Error &&
|
||||
error.cause.message === 'Local SQLite Run repository is closed',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
'use strict';
|
||||
|
||||
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 {
|
||||
TaskStartFenceRejectedError,
|
||||
TaskStartNotFoundError,
|
||||
TaskStartUnavailableError,
|
||||
} = require('@qinglong/runtime-core/task-start');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
const IDS = [
|
||||
'019f7300-0000-7000-8000-000000000101',
|
||||
'019f7300-0000-7000-8000-000000000102',
|
||||
'019f7300-0000-7000-8000-000000000103',
|
||||
'019f7300-0000-7000-8000-000000000104',
|
||||
];
|
||||
|
||||
function definition(index = 1, overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: `task-${index}`,
|
||||
expectedRevision: null,
|
||||
mutationId: `019f7300-0000-7000-8000-${String(index).padStart(12, '0')}`,
|
||||
name: `Task ${index}`,
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: { kind: 'argv', file: '/bin/echo', args: [String(index)] },
|
||||
},
|
||||
},
|
||||
labels: {},
|
||||
enabled: true,
|
||||
occurredAtMs: 100 + index,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function command(record, overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: record.taskId,
|
||||
mutationId: '019f7300-0000-7000-8000-000000000100',
|
||||
expectedRevision: record.revision,
|
||||
expectedContentDigest: record.contentDigest,
|
||||
runId: IDS[0],
|
||||
attemptId: IDS[1],
|
||||
createdEventId: IDS[2],
|
||||
queuedEventId: IDS[3],
|
||||
subject: { type: 'user', id: 'user-1' },
|
||||
policyFence: { projectVersion: 1, bindingVersion: 1 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-task-start-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const databasePath = path.join(root, 'qinglong3.sqlite');
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.prepare(`
|
||||
INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES ('default', 'user', 'user-1', 1, 'active', 'operator',
|
||||
'grant-operator', 'user', 'user-1', ?)
|
||||
`).run(NOW - 1_000);
|
||||
client.close();
|
||||
const runtime = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => runtime.close());
|
||||
const repository = await runtime.taskStartRepository();
|
||||
return { databasePath, runtime, repository };
|
||||
}
|
||||
|
||||
test('atomically creates queued Run, claimed Attempt and two Events, then replays', async (t) => {
|
||||
const { databasePath, runtime, repository } = await fixture(t);
|
||||
const record = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(definition())
|
||||
).definition;
|
||||
const accepted = await repository.startTask(command(record));
|
||||
assert.deepEqual(accepted, {
|
||||
status: 'accepted',
|
||||
projectId: 'default',
|
||||
taskId: 'task-1',
|
||||
taskRevision: 1,
|
||||
taskContentDigest: record.contentDigest,
|
||||
runId: IDS[0],
|
||||
attemptId: IDS[1],
|
||||
runStatus: 'queued',
|
||||
runVersion: 2,
|
||||
eventSequence: 2,
|
||||
executorType: 'local_process',
|
||||
executionRevisionDigest: accepted.executionRevisionDigest,
|
||||
createdAtMs: accepted.createdAtMs,
|
||||
});
|
||||
assert.match(accepted.executionRevisionDigest, /^[0-9a-f]{64}$/);
|
||||
|
||||
const replay = await repository.startTask(command(record, {
|
||||
runId: '019f7300-0000-7000-8000-000000000201',
|
||||
attemptId: '019f7300-0000-7000-8000-000000000202',
|
||||
createdEventId: '019f7300-0000-7000-8000-000000000203',
|
||||
queuedEventId: '019f7300-0000-7000-8000-000000000204',
|
||||
}));
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.runId, IDS[0]);
|
||||
assert.equal(replay.attemptId, IDS[1]);
|
||||
|
||||
const client = new DatabaseSync(databasePath, { readOnly: true });
|
||||
t.after(() => client.close());
|
||||
assert.deepEqual({ ...client.prepare(`
|
||||
SELECT "status", "version", "event_sequence" AS "eventSequence",
|
||||
"execution_origin" AS "executionOrigin",
|
||||
"trigger_type" AS "triggerType"
|
||||
FROM "Runs" WHERE "id" = ?
|
||||
`).get(IDS[0]) }, {
|
||||
status: 'queued',
|
||||
version: 2,
|
||||
eventSequence: 2,
|
||||
executionOrigin: 'manual',
|
||||
triggerType: 'task_start',
|
||||
});
|
||||
assert.equal(client.prepare(
|
||||
`SELECT COUNT(*) AS count FROM "RunEvents" WHERE "run_id" = ?`,
|
||||
).get(IDS[0]).count, 2);
|
||||
});
|
||||
|
||||
test('serializes concurrent retries to one durable Run', async (t) => {
|
||||
const { runtime, repository } = await fixture(t);
|
||||
const record = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(definition())
|
||||
).definition;
|
||||
const [left, right] = await Promise.all([
|
||||
repository.startTask(command(record)),
|
||||
repository.startTask(command(record, {
|
||||
runId: '019f7300-0000-7000-8000-000000000301',
|
||||
attemptId: '019f7300-0000-7000-8000-000000000302',
|
||||
createdEventId: '019f7300-0000-7000-8000-000000000303',
|
||||
queuedEventId: '019f7300-0000-7000-8000-000000000304',
|
||||
})),
|
||||
]);
|
||||
assert.deepEqual([left.status, right.status].sort(), ['accepted', 'existing']);
|
||||
assert.equal(left.runId, right.runId);
|
||||
});
|
||||
|
||||
test('rejects missing, changed, disabled and conflicting Task fences', async (t) => {
|
||||
const { runtime, repository } = await fixture(t);
|
||||
const record = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(definition())
|
||||
).definition;
|
||||
await assert.rejects(
|
||||
repository.startTask(command({ ...record, taskId: 'missing' })),
|
||||
TaskStartNotFoundError,
|
||||
);
|
||||
await assert.rejects(
|
||||
repository.startTask(command(record, { expectedRevision: 2 })),
|
||||
(error) =>
|
||||
error instanceof TaskStartFenceRejectedError &&
|
||||
error.reason === 'definition_changed',
|
||||
);
|
||||
const disabled = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(
|
||||
definition(2, { enabled: false }),
|
||||
)
|
||||
).definition;
|
||||
await assert.rejects(
|
||||
repository.startTask(command(disabled, {
|
||||
taskId: disabled.taskId,
|
||||
mutationId: '019f7300-0000-7000-8000-000000000400',
|
||||
})),
|
||||
(error) =>
|
||||
error instanceof TaskStartFenceRejectedError &&
|
||||
error.reason === 'task_disabled',
|
||||
);
|
||||
|
||||
await repository.startTask(command(record));
|
||||
await assert.rejects(
|
||||
repository.startTask(command(record, {
|
||||
expectedContentDigest: 'f'.repeat(64),
|
||||
runId: '019f7300-0000-7000-8000-000000000401',
|
||||
attemptId: '019f7300-0000-7000-8000-000000000402',
|
||||
createdEventId: '019f7300-0000-7000-8000-000000000403',
|
||||
queuedEventId: '019f7300-0000-7000-8000-000000000404',
|
||||
})),
|
||||
(error) =>
|
||||
error instanceof TaskStartFenceRejectedError &&
|
||||
error.reason === 'mutation_conflict',
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed after authorization revocation or execution revision loss', async (t) => {
|
||||
const { databasePath, runtime, repository } = await fixture(t);
|
||||
const first = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(definition())
|
||||
).definition;
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client.prepare(`
|
||||
INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
"project_id", "subject_type", "subject_id", "version", "state",
|
||||
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||
"created_at_ms"
|
||||
) VALUES ('default', 'user', 'user-1', 2, 'revoked', NULL,
|
||||
'revoke-operator', 'user', 'user-1', ?)
|
||||
`).run(NOW - 100);
|
||||
await assert.rejects(
|
||||
repository.startTask(command(first)),
|
||||
(error) =>
|
||||
error instanceof TaskStartFenceRejectedError &&
|
||||
error.reason === 'authorization_changed',
|
||||
);
|
||||
client.prepare(
|
||||
`DELETE FROM "QingLong3ProjectRoleBindings" WHERE "version" = 2`,
|
||||
).run();
|
||||
const second = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(definition(2))
|
||||
).definition;
|
||||
client.prepare(`
|
||||
DELETE FROM "QingLong3LocalTaskExecutionRevisions"
|
||||
WHERE "project_id" = ? AND "task_id" = ?
|
||||
`).run('default', second.taskId);
|
||||
client.close();
|
||||
await assert.rejects(
|
||||
repository.startTask(command(second, {
|
||||
taskId: second.taskId,
|
||||
mutationId: '019f7300-0000-7000-8000-000000000500',
|
||||
})),
|
||||
TaskStartUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ToolExecutionEvidenceConflictError,
|
||||
ToolExecutionEvidenceUnavailableError,
|
||||
createToolExecutionEvidenceBundle,
|
||||
} = require('@qinglong/runtime-core/tool-execution-evidence');
|
||||
const {
|
||||
createStepRunMutation,
|
||||
} = 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 {
|
||||
LocalSqliteToolExecutionEvidenceRepository,
|
||||
} = require('../dist/tool-execution/toolExecutionEvidenceRepository');
|
||||
|
||||
const DIGEST_A = 'a'.repeat(64);
|
||||
const DIGEST_B = 'b'.repeat(64);
|
||||
const DIGEST_C = 'c'.repeat(64);
|
||||
const DIGEST_D = 'd'.repeat(64);
|
||||
const DIGEST_E = 'e'.repeat(64);
|
||||
|
||||
async function harness() {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects" (
|
||||
id, name, slug, status, version, created_at_ms, updated_at_ms
|
||||
) VALUES ('project-001', 'Project', 'project-001', 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run();
|
||||
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 (
|
||||
'run-001', 'project-001', 'task-001', 'revision-001', 'manual',
|
||||
'manual', 'runtime', 'running', 0, 0, 0, 1
|
||||
)`,
|
||||
)
|
||||
.run();
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
return {
|
||||
client,
|
||||
authority,
|
||||
stepRuns: new LocalSqliteStepRunRepository(authority),
|
||||
evidence: new LocalSqliteToolExecutionEvidenceRepository(authority),
|
||||
close: () => authority.close(),
|
||||
};
|
||||
}
|
||||
|
||||
function stepMutation(index, expectedRunVersion) {
|
||||
const suffix = String(index).padStart(3, '0');
|
||||
return createStepRunMutation(
|
||||
{
|
||||
id: `step-run-${suffix}`,
|
||||
runId: 'run-001',
|
||||
stepKey: `workflow.tool-${suffix}`,
|
||||
kind: 'tool',
|
||||
definitionRef: `tool:demo.tool-${suffix}@1.0.0`,
|
||||
definitionDigest: DIGEST_A,
|
||||
required: true,
|
||||
initialStatus: 'ready',
|
||||
mutationId: `step-create-${suffix}`,
|
||||
createdAtMs: 900 + index,
|
||||
},
|
||||
{
|
||||
expectedRunVersion,
|
||||
expectedRunEventSequence: expectedRunVersion,
|
||||
eventId: `50000000-0000-4000-8000-${suffix.padStart(12, '0')}`,
|
||||
dedupeKey: `step-create:step-run-${suffix}`,
|
||||
actor: { type: 'agent', id: 'agent-001' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function evidence(index, overrides = {}) {
|
||||
const suffix = String(index).padStart(3, '0');
|
||||
const createdAtMs = 1_000 + index;
|
||||
return createToolExecutionEvidenceBundle({
|
||||
traceId: index.toString(16).padStart(32, '0'),
|
||||
spanId: (index + 16).toString(16).padStart(16, '0'),
|
||||
projectId: 'project-001',
|
||||
runId: 'run-001',
|
||||
stepRunId: `step-run-${suffix}`,
|
||||
invocationPlanDigest: DIGEST_A,
|
||||
bindingDigest: DIGEST_B,
|
||||
adapterDigest: DIGEST_C,
|
||||
redactionContractDigest: DIGEST_D,
|
||||
auditContractDigest: DIGEST_E,
|
||||
audit: {
|
||||
eventId: `60000000-0000-4000-8000-${suffix.padStart(12, '0')}`,
|
||||
requestId: `tool-request-${suffix}`,
|
||||
operationId: 'tool.invoke.start',
|
||||
projectId: 'project-001',
|
||||
subject: { type: 'agent', id: 'agent-001' },
|
||||
authenticationId: 'auth-agent-001',
|
||||
outcome: 'allowed',
|
||||
reasons: ['tool_execution_start'],
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
occurredAtMs: createdAtMs,
|
||||
},
|
||||
createdAtMs,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('atomically prepares and exactly replays durable Trace and Audit evidence', async (t) => {
|
||||
const value = await harness();
|
||||
t.after(() => value.close());
|
||||
await value.stepRuns.apply(stepMutation(1, 0));
|
||||
const bundle = evidence(1);
|
||||
|
||||
assert.deepEqual(await value.evidence.prepare(bundle), {
|
||||
status: 'created',
|
||||
bundle,
|
||||
});
|
||||
assert.deepEqual(await value.evidence.prepare(bundle), {
|
||||
status: 'existing',
|
||||
bundle,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await value.evidence.findByTrace(
|
||||
bundle.trace.traceId,
|
||||
bundle.trace.spanId,
|
||||
),
|
||||
bundle,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await value.evidence.findByAuditEventId(bundle.audit.eventId),
|
||||
bundle,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
...value.client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM "ToolExecutionTraceAnchors") AS traces,
|
||||
(SELECT COUNT(*) FROM "ToolExecutionAuditReceipts") AS receipts,
|
||||
(SELECT COUNT(*) FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE operation_id = 'tool.invoke.start') AS audits`,
|
||||
)
|
||||
.get(),
|
||||
},
|
||||
{ traces: 1, receipts: 1, audits: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects reused Trace or Audit identity with different content', async (t) => {
|
||||
const value = await harness();
|
||||
t.after(() => value.close());
|
||||
await value.stepRuns.apply(stepMutation(1, 0));
|
||||
const first = evidence(1);
|
||||
await value.evidence.prepare(first);
|
||||
|
||||
const reusedTrace = evidence(1, {
|
||||
audit: {
|
||||
...first.audit,
|
||||
eventId: '60000000-0000-4000-8000-000000000099',
|
||||
requestId: 'tool-request-reused',
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
value.evidence.prepare(reusedTrace),
|
||||
ToolExecutionEvidenceConflictError,
|
||||
);
|
||||
|
||||
const reusedAudit = evidence(1, {
|
||||
traceId: 'f'.repeat(32),
|
||||
spanId: 'e'.repeat(16),
|
||||
audit: first.audit,
|
||||
});
|
||||
await assert.rejects(
|
||||
value.evidence.prepare(reusedAudit),
|
||||
ToolExecutionEvidenceConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('requires one ready Tool StepRun in the same Project and rolls back audit', async (t) => {
|
||||
const value = await harness();
|
||||
t.after(() => value.close());
|
||||
|
||||
await assert.rejects(
|
||||
value.evidence.prepare(evidence(1)),
|
||||
ToolExecutionEvidenceConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE operation_id = 'tool.invoke.start'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('lists evidence with stable bounded keyset pagination', async (t) => {
|
||||
const value = await harness();
|
||||
t.after(() => value.close());
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
await value.stepRuns.apply(stepMutation(index, index - 1));
|
||||
await value.evidence.prepare(evidence(index));
|
||||
}
|
||||
const first = await value.evidence.listByRun({
|
||||
runId: 'run-001',
|
||||
limit: 2,
|
||||
});
|
||||
assert.equal(first.bundles.length, 2);
|
||||
assert.equal(first.truncated, true);
|
||||
assert.deepEqual(first.next, {
|
||||
createdAtMs: first.bundles[1].trace.createdAtMs,
|
||||
traceId: first.bundles[1].trace.traceId,
|
||||
spanId: first.bundles[1].trace.spanId,
|
||||
});
|
||||
const second = await value.evidence.listByRun({
|
||||
runId: 'run-001',
|
||||
limit: 2,
|
||||
after: first.next,
|
||||
});
|
||||
assert.equal(second.bundles.length, 1);
|
||||
assert.equal(second.truncated, false);
|
||||
assert.equal(second.next, undefined);
|
||||
});
|
||||
|
||||
test('fails closed when durable JSON no longer matches mirrored columns', async (t) => {
|
||||
const value = await harness();
|
||||
t.after(() => value.close());
|
||||
await value.stepRuns.apply(stepMutation(1, 0));
|
||||
const bundle = evidence(1);
|
||||
await value.evidence.prepare(bundle);
|
||||
value.client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
value.client
|
||||
.prepare(
|
||||
`UPDATE "ToolExecutionTraceAnchors"
|
||||
SET trace_json = json_set(trace_json, '$.projectId', 'project-other')`,
|
||||
)
|
||||
.run();
|
||||
await assert.rejects(
|
||||
value.evidence.findByTrace(bundle.trace.traceId, bundle.trace.spanId),
|
||||
ToolExecutionEvidenceUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes evidence authority only through its explicit subpath', () => {
|
||||
const root = require('@qinglong/local-sqlite');
|
||||
const runtime = require('@qinglong/local-sqlite/runtime');
|
||||
const authority = require('@qinglong/local-sqlite/tool-execution-evidence');
|
||||
assert.equal(root.LocalSqliteToolExecutionEvidenceRepository, undefined);
|
||||
assert.equal(runtime.LocalSqliteToolExecutionEvidenceRepository, undefined);
|
||||
assert.equal(
|
||||
typeof authority.LocalSqliteToolExecutionEvidenceRepository,
|
||||
'function',
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ToolInvocationArtifactConflictError,
|
||||
ToolInvocationArtifactUnavailableError,
|
||||
createToolInvocationInputArtifact,
|
||||
createToolInvocationPreviewArtifact,
|
||||
} = require('@qinglong/runtime-core/tool-invocation-artifact');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('../dist/authority/operationAuthority');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const {
|
||||
LocalSqliteToolInvocationArtifactRepository,
|
||||
} = require('../dist/tool-execution/toolInvocationArtifactRepository');
|
||||
|
||||
const KEY = Buffer.alloc(32, 7);
|
||||
const NONCE = Buffer.alloc(12, 9);
|
||||
const INVOCATION_ACTION_DIGEST = 'a'.repeat(64);
|
||||
const ACTION_DIGEST = 'b'.repeat(64);
|
||||
const REDACTION_DIGEST = 'c'.repeat(64);
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function artifacts(index = 1, overrides = {}) {
|
||||
const input = {
|
||||
runId: `run-${String(index).padStart(3, '0')}`,
|
||||
token: 'secret-value',
|
||||
};
|
||||
const common = {
|
||||
projectId: 'project-001',
|
||||
actionRef: `tool-plan:${input.runId}`,
|
||||
sealedAtMs: 1_000 + index,
|
||||
...overrides.common,
|
||||
};
|
||||
const inputArtifact = createToolInvocationInputArtifact(
|
||||
{
|
||||
artifactId: `artifact-input-${index}`,
|
||||
requestedBy: { type: 'user', id: 'usr-owner' },
|
||||
tool: { name: 'demo.compare', version: '1.0.0' },
|
||||
input,
|
||||
inputDigest: digest(input),
|
||||
invocationActionDigest: INVOCATION_ACTION_DIGEST,
|
||||
keyId: 'tool-key-test',
|
||||
key: KEY,
|
||||
...common,
|
||||
...overrides.input,
|
||||
},
|
||||
() => NONCE,
|
||||
);
|
||||
const previewArtifact = createToolInvocationPreviewArtifact({
|
||||
artifactId: `artifact-preview-${index}`,
|
||||
actionDigest: ACTION_DIGEST,
|
||||
redactionContractDigest: REDACTION_DIGEST,
|
||||
preview: {
|
||||
title: 'Compare Run',
|
||||
summary: 'Reads one bounded Run projection',
|
||||
fields: [
|
||||
{ kind: 'identifier', label: 'Run', value: input.runId },
|
||||
{ kind: 'redacted', label: 'Credential', value: null },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
...common,
|
||||
...overrides.preview,
|
||||
});
|
||||
return { inputArtifact, previewArtifact };
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects" (
|
||||
id, name, slug, status, version, created_at_ms, updated_at_ms
|
||||
) VALUES ('project-001', 'Project', 'project-001', 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run();
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
return {
|
||||
client,
|
||||
repository: new LocalSqliteToolInvocationArtifactRepository(authority),
|
||||
close: () => authority.close(),
|
||||
};
|
||||
}
|
||||
|
||||
test('atomically inserts and exactly replays one Artifact pair', async (t) => {
|
||||
const current = await harness();
|
||||
t.after(current.close);
|
||||
const pair = artifacts();
|
||||
assert.deepEqual(
|
||||
await current.repository.put(pair.inputArtifact, pair.previewArtifact),
|
||||
{ status: 'inserted' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await current.repository.put(pair.inputArtifact, pair.previewArtifact),
|
||||
{ status: 'existing' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await current.repository.findInput(pair.inputArtifact.artifactId),
|
||||
pair.inputArtifact,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await current.repository.findPreview(pair.previewArtifact.artifactId),
|
||||
pair.previewArtifact,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(
|
||||
current.client
|
||||
.prepare(
|
||||
`SELECT artifact_json FROM "ToolInvocationInputArtifacts"`,
|
||||
)
|
||||
.get(),
|
||||
).includes('secret-value'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects pair drift and rolls back both rows', async (t) => {
|
||||
const current = await harness();
|
||||
t.after(current.close);
|
||||
const pair = artifacts();
|
||||
const detached = artifacts(2, {
|
||||
common: {
|
||||
projectId: 'project-001',
|
||||
actionRef: 'tool-plan:detached',
|
||||
sealedAtMs: 1_002,
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
current.repository.put(pair.inputArtifact, detached.previewArtifact),
|
||||
ToolInvocationArtifactConflictError,
|
||||
);
|
||||
assert.equal(
|
||||
current.client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "ToolInvocationInputArtifacts"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
current.client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count FROM "ToolInvocationPreviewArtifacts"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when a stored projection is corrupted', async (t) => {
|
||||
const current = await harness();
|
||||
t.after(current.close);
|
||||
const pair = artifacts();
|
||||
await current.repository.put(pair.inputArtifact, pair.previewArtifact);
|
||||
current.client.exec('PRAGMA ignore_check_constraints = ON');
|
||||
current.client
|
||||
.prepare(
|
||||
`UPDATE "ToolInvocationInputArtifacts"
|
||||
SET input_digest = ?
|
||||
WHERE artifact_id = ?`,
|
||||
)
|
||||
.run('d'.repeat(64), pair.inputArtifact.artifactId);
|
||||
current.client.exec('PRAGMA ignore_check_constraints = OFF');
|
||||
await assert.rejects(
|
||||
current.repository.findInput(pair.inputArtifact.artifactId),
|
||||
ToolInvocationArtifactUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes only the explicit adapter subpath', () => {
|
||||
const root = require('../dist');
|
||||
const subpath = require('@qinglong/local-sqlite/tool-invocation-artifact');
|
||||
assert.equal(
|
||||
root.LocalSqliteToolInvocationArtifactRepository,
|
||||
LocalSqliteToolInvocationArtifactRepository,
|
||||
);
|
||||
assert.equal(
|
||||
subpath.LocalSqliteToolInvocationArtifactRepository,
|
||||
LocalSqliteToolInvocationArtifactRepository,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
CRASH_POINTS,
|
||||
setupScenario,
|
||||
verifyScenario,
|
||||
} = require('./fixtures/toolResultCrashMatrixFixture.cjs');
|
||||
|
||||
const FIXTURE_PATH = path.join(
|
||||
__dirname,
|
||||
'fixtures',
|
||||
'toolResultCrashMatrixFixture.cjs',
|
||||
);
|
||||
|
||||
test(
|
||||
'survives the Tool Result completion and key lifecycle crash matrix',
|
||||
{ timeout: 180_000 },
|
||||
async (context) => {
|
||||
const reports = [];
|
||||
for (const profile of ['edge', 'standalone']) {
|
||||
for (const [pointName, point] of Object.entries(CRASH_POINTS)) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `ql3-tool-result-${profile}-`),
|
||||
);
|
||||
context.after(() => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const databasePath = path.join(directory, 'runtime.sqlite');
|
||||
const statePath = path.join(directory, 'state.json');
|
||||
const markerPath = path.join(directory, 'crash-marker.json');
|
||||
await setupScenario({
|
||||
databasePath,
|
||||
statePath,
|
||||
profile,
|
||||
operation: point.operation,
|
||||
});
|
||||
const crashed = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
FIXTURE_PATH,
|
||||
'crash',
|
||||
databasePath,
|
||||
statePath,
|
||||
markerPath,
|
||||
pointName,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
crashed.error,
|
||||
undefined,
|
||||
`${profile}/${pointName}: ${crashed.error?.message}`,
|
||||
);
|
||||
assert.equal(
|
||||
crashed.signal,
|
||||
'SIGKILL',
|
||||
`${profile}/${pointName}: status=${crashed.status}, stderr=${crashed.stderr}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(fs.readFileSync(markerPath, 'utf8')),
|
||||
{
|
||||
schema: 'qinglong/sqlite-tool-result-crash-marker@v1',
|
||||
point: pointName,
|
||||
pid: JSON.parse(
|
||||
fs.readFileSync(markerPath, 'utf8'),
|
||||
).pid,
|
||||
},
|
||||
);
|
||||
reports.push(
|
||||
await verifyScenario({
|
||||
databasePath,
|
||||
statePath,
|
||||
pointName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.equal(reports.length, 20);
|
||||
assert.equal(
|
||||
reports.filter((report) => report.crashBeforeCommit).length,
|
||||
12,
|
||||
);
|
||||
assert.equal(
|
||||
reports.filter((report) => report.durableAfterCrash).length,
|
||||
8,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...new Set(reports.map((report) => report.journalMode))].sort(),
|
||||
['delete', 'wal'],
|
||||
);
|
||||
assert.ok(
|
||||
reports.every((report) => report.integrityCheck === 'ok'),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,124 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
ToolResultKeyCatalogConflictError,
|
||||
ToolResultKeyCatalogUnavailableError,
|
||||
createToolResultKeyCatalogBootstrapCommand,
|
||||
createToolResultKeyRotationCommand,
|
||||
toolResultKeyMaterialProof,
|
||||
} = require('@qinglong/runtime-core/tool-result-key-catalog');
|
||||
const {
|
||||
LocalSqliteToolResultKeyCatalogRepository,
|
||||
} = require('@qinglong/local-sqlite/tool-result-key-catalog');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const { LocalSqliteOperationAuthority } = require('../dist/authority/operationAuthority');
|
||||
|
||||
async function harness() {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
return {
|
||||
client,
|
||||
repository: new LocalSqliteToolResultKeyCatalogRepository(authority),
|
||||
close: () => authority.close(),
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrapCommand() {
|
||||
return createToolResultKeyCatalogBootstrapCommand({
|
||||
keyId: 'tool-result-key-001',
|
||||
materialProof: toolResultKeyMaterialProof(
|
||||
'tool-result-key-001',
|
||||
Buffer.alloc(32, 1),
|
||||
),
|
||||
mutationId: 'tool-result-key-bootstrap-001',
|
||||
});
|
||||
}
|
||||
|
||||
test('appends, exactly replays and reads the latest SQLite key generation', async () => {
|
||||
const current = await harness();
|
||||
try {
|
||||
assert.equal(await current.repository.findCurrent(), null);
|
||||
const bootstrap = bootstrapCommand();
|
||||
const first = await current.repository.append(bootstrap);
|
||||
assert.equal(first.status, 'created');
|
||||
assert.equal(first.catalog.generation, 1);
|
||||
assert.deepEqual(await current.repository.append(bootstrap), {
|
||||
status: 'existing',
|
||||
catalog: first.catalog,
|
||||
});
|
||||
|
||||
const rotation = createToolResultKeyRotationCommand(first.catalog, {
|
||||
keyId: 'tool-result-key-002',
|
||||
materialProof: toolResultKeyMaterialProof(
|
||||
'tool-result-key-002',
|
||||
Buffer.alloc(32, 2),
|
||||
),
|
||||
mutationId: 'tool-result-key-rotate-002',
|
||||
});
|
||||
const second = await current.repository.append(rotation);
|
||||
assert.equal(second.status, 'created');
|
||||
assert.equal(second.catalog.generation, 2);
|
||||
assert.deepEqual(await current.repository.findCurrent(), second.catalog);
|
||||
assert.equal(
|
||||
current.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "ToolResultKeyCatalogGenerations"`,
|
||||
)
|
||||
.get().count,
|
||||
2,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
current.repository.append(
|
||||
createToolResultKeyRotationCommand(first.catalog, {
|
||||
keyId: 'tool-result-key-stale',
|
||||
materialProof: toolResultKeyMaterialProof(
|
||||
'tool-result-key-stale',
|
||||
Buffer.alloc(32, 3),
|
||||
),
|
||||
mutationId: 'tool-result-key-stale-003',
|
||||
}),
|
||||
),
|
||||
ToolResultKeyCatalogConflictError,
|
||||
);
|
||||
} finally {
|
||||
await current.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when the durable SQLite catalog projection drifts', async () => {
|
||||
const current = await harness();
|
||||
try {
|
||||
await current.repository.append(bootstrapCommand());
|
||||
current.client
|
||||
.prepare(
|
||||
`UPDATE "ToolResultKeyCatalogGenerations"
|
||||
SET command_digest = ?
|
||||
WHERE generation = 1`,
|
||||
)
|
||||
.run('c'.repeat(64));
|
||||
await assert.rejects(
|
||||
current.repository.findCurrent(),
|
||||
ToolResultKeyCatalogUnavailableError,
|
||||
);
|
||||
} finally {
|
||||
await current.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('publishes SQLite catalog mutation only through its explicit subpath', () => {
|
||||
const root = require('@qinglong/local-sqlite');
|
||||
const runtime = require('@qinglong/local-sqlite/runtime');
|
||||
const authority = require('@qinglong/local-sqlite/tool-result-key-catalog');
|
||||
assert.equal(root.LocalSqliteToolResultKeyCatalogRepository, undefined);
|
||||
assert.equal(runtime.LocalSqliteToolResultKeyCatalogRepository, undefined);
|
||||
assert.equal(
|
||||
typeof authority.LocalSqliteToolResultKeyCatalogRepository,
|
||||
'function',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BUILTIN_RUN_READ_TOOL,
|
||||
BUILTIN_RUN_READ_TOOL_DEFINITION,
|
||||
} = require('@qinglong/runtime-core/builtin-run-read-tool');
|
||||
const {
|
||||
TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
} = require('@qinglong/runtime-core/trusted-tool-execution');
|
||||
const {
|
||||
TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA,
|
||||
createToolExecutionResultArtifact,
|
||||
normalizeToolExecutionResultKeyBinding,
|
||||
} = require('@qinglong/runtime-core/tool-execution-completion');
|
||||
const {
|
||||
createToolResultKeyCatalogBootstrapCommand,
|
||||
createToolResultKeyRetirementCommand,
|
||||
createToolResultKeyRotationCommand,
|
||||
requireActiveToolResultKey,
|
||||
toolResultKeyCatalogFence,
|
||||
toolResultKeyMaterialProof,
|
||||
} = require('@qinglong/runtime-core/tool-result-key-catalog');
|
||||
const {
|
||||
ToolExecutionResultRekeyConflictError,
|
||||
createToolExecutionResultRekeyCommand,
|
||||
createToolResultKeyRetirementReceiptCommand,
|
||||
} = require('@qinglong/runtime-core/tool-result-rekey');
|
||||
const {
|
||||
ToolDefinitionRegistry,
|
||||
} = require('@qinglong/runtime-core/tool-registry');
|
||||
const {
|
||||
LocalSqliteToolResultKeyCatalogRepository,
|
||||
} = require('@qinglong/local-sqlite/tool-result-key-catalog');
|
||||
const {
|
||||
LocalSqliteToolResultRekeyRepository,
|
||||
} = require('@qinglong/local-sqlite/tool-result-rekey');
|
||||
const { migrateLocalSqliteDatabase } = require('../dist/migration/migration');
|
||||
const { LocalSqliteOperationAuthority } = require('../dist/authority/operationAuthority');
|
||||
|
||||
const KEY_A = Buffer.alloc(32, 1);
|
||||
const KEY_B = Buffer.alloc(32, 2);
|
||||
const OUTPUT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-output-digest@v1\0',
|
||||
);
|
||||
const RESULT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-result-digest@v1\0',
|
||||
);
|
||||
const BINDING_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-result-key-binding-digest@v1\0',
|
||||
);
|
||||
|
||||
function hash(domain, value) {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function output() {
|
||||
return {
|
||||
createdAtMs: 1_000,
|
||||
eventSequence: 3,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
found: true,
|
||||
id: 'run-rekey-local-001',
|
||||
priority: 10,
|
||||
queuedAtMs: 1_100,
|
||||
startedAtMs: 1_200,
|
||||
status: 'succeeded',
|
||||
taskId: 'task-rekey-local-001',
|
||||
taskRevision: 'task-rekey-local-001@1',
|
||||
version: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function registry() {
|
||||
return new ToolDefinitionRegistry([BUILTIN_RUN_READ_TOOL_DEFINITION]);
|
||||
}
|
||||
|
||||
function sourceArtifact() {
|
||||
const value = output();
|
||||
const unsigned = {
|
||||
schema: TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
startId: 'tool-start-rekey-local-001',
|
||||
barrierDigest: 'a'.repeat(64),
|
||||
adapterDigest: 'b'.repeat(64),
|
||||
output: value,
|
||||
outputDigest: hash(OUTPUT_DIGEST_DOMAIN, value),
|
||||
completedAtMs: 1_500,
|
||||
};
|
||||
const result = {
|
||||
...unsigned,
|
||||
resultDigest: hash(RESULT_DIGEST_DOMAIN, unsigned),
|
||||
};
|
||||
return createToolExecutionResultArtifact(
|
||||
{
|
||||
artifactId: 'artifact-result-rekey-local-001',
|
||||
projectId: 'project-rekey-local-001',
|
||||
runId: 'run-host-rekey-local-001',
|
||||
stepRunId: 'step-run-rekey-local-001',
|
||||
tool: BUILTIN_RUN_READ_TOOL,
|
||||
executionResult: result,
|
||||
keyId: 'result-key-a',
|
||||
key: KEY_A,
|
||||
},
|
||||
registry(),
|
||||
() => Buffer.alloc(12, 4),
|
||||
);
|
||||
}
|
||||
|
||||
function sourceBinding(artifact, catalog) {
|
||||
const unsigned = {
|
||||
schema: TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA,
|
||||
startId: artifact.startId,
|
||||
artifactId: artifact.artifactId,
|
||||
artifactDigest: artifact.artifactDigest,
|
||||
catalogGeneration: catalog.generation,
|
||||
catalogDigest: catalog.catalogDigest,
|
||||
keyId: artifact.keyId,
|
||||
materialProof: toolResultKeyMaterialProof(artifact.keyId, KEY_A),
|
||||
};
|
||||
return normalizeToolExecutionResultKeyBinding({
|
||||
...unsigned,
|
||||
bindingDigest: hash(BINDING_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
return {
|
||||
client,
|
||||
catalog: new LocalSqliteToolResultKeyCatalogRepository(authority),
|
||||
rekey: new LocalSqliteToolResultRekeyRepository(authority),
|
||||
close: () => authority.close(),
|
||||
};
|
||||
}
|
||||
|
||||
function insertBinding(client, binding) {
|
||||
client.exec('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "ToolExecutionResultKeyBindings" (
|
||||
start_id, artifact_id, artifact_digest, catalog_authority,
|
||||
catalog_generation, catalog_digest, key_id, material_proof,
|
||||
binding_digest
|
||||
) VALUES (?, ?, ?, 'trusted-tool-results', ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
binding.startId,
|
||||
binding.artifactId,
|
||||
binding.artifactDigest,
|
||||
binding.catalogGeneration,
|
||||
binding.catalogDigest,
|
||||
binding.keyId,
|
||||
binding.materialProof,
|
||||
binding.bindingDigest,
|
||||
);
|
||||
} finally {
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
|
||||
test('appends one SQLite rekey head and creates coverage-derived retirement evidence', async () => {
|
||||
const current = await harness();
|
||||
try {
|
||||
const first = await current.catalog.append(
|
||||
createToolResultKeyCatalogBootstrapCommand({
|
||||
keyId: 'result-key-a',
|
||||
materialProof: toolResultKeyMaterialProof('result-key-a', KEY_A),
|
||||
mutationId: 'result-key-bootstrap-local-a',
|
||||
}),
|
||||
);
|
||||
const artifact = sourceArtifact();
|
||||
const binding = sourceBinding(artifact, first.catalog);
|
||||
insertBinding(current.client, binding);
|
||||
const second = await current.catalog.append(
|
||||
createToolResultKeyRotationCommand(first.catalog, {
|
||||
keyId: 'result-key-b',
|
||||
materialProof: toolResultKeyMaterialProof('result-key-b', KEY_B),
|
||||
mutationId: 'result-key-rotate-local-b',
|
||||
}),
|
||||
);
|
||||
const receiptCommand = createToolResultKeyRetirementReceiptCommand({
|
||||
expectedCatalogGeneration: second.catalog.generation,
|
||||
expectedCatalogDigest: second.catalog.catalogDigest,
|
||||
keyId: 'result-key-a',
|
||||
mutationId: 'result-key-retirement-local-a',
|
||||
});
|
||||
await assert.rejects(
|
||||
current.rekey.create(receiptCommand),
|
||||
ToolExecutionResultRekeyConflictError,
|
||||
);
|
||||
await assert.rejects(
|
||||
current.catalog.append(
|
||||
createToolResultKeyRetirementCommand(second.catalog, {
|
||||
keyId: 'result-key-a',
|
||||
retirementReceiptDigest: 'f'.repeat(64),
|
||||
mutationId: 'result-key-retire-local-forged',
|
||||
}),
|
||||
),
|
||||
/conflicts with durable state/,
|
||||
);
|
||||
|
||||
const overlayCommand = createToolExecutionResultRekeyCommand({
|
||||
artifact,
|
||||
binding,
|
||||
previousOverlay: null,
|
||||
overlayId: 'result-rekey-overlay-local-001',
|
||||
mutationId: 'result-rekey-mutation-local-001',
|
||||
targetCatalogFence: toolResultKeyCatalogFence(
|
||||
second.catalog,
|
||||
requireActiveToolResultKey(second.catalog),
|
||||
),
|
||||
targetKey: KEY_B,
|
||||
output: output(),
|
||||
rekeyedAtMs: 1_700,
|
||||
registry: registry(),
|
||||
nonceFactory: () => Buffer.alloc(12, 5),
|
||||
});
|
||||
const appended = await current.rekey.append(overlayCommand);
|
||||
assert.equal(appended.status, 'created');
|
||||
assert.deepEqual(await current.rekey.append(overlayCommand), {
|
||||
status: 'existing',
|
||||
overlay: appended.overlay,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await current.rekey.findHeadByArtifactId(artifact.artifactId),
|
||||
appended.overlay,
|
||||
);
|
||||
|
||||
const receipt = await current.rekey.create(receiptCommand);
|
||||
assert.equal(receipt.status, 'created');
|
||||
assert.equal(receipt.receipt.bindingCount, 1);
|
||||
assert.equal(receipt.receipt.overlayHeadCount, 1);
|
||||
assert.deepEqual(await current.rekey.create(receiptCommand), {
|
||||
status: 'existing',
|
||||
receipt: receipt.receipt,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await current.rekey.findByDigest(receipt.receipt.receiptDigest),
|
||||
receipt.receipt,
|
||||
);
|
||||
const retired = await current.catalog.append(
|
||||
createToolResultKeyRetirementCommand(second.catalog, {
|
||||
keyId: 'result-key-a',
|
||||
retirementReceiptDigest: receipt.receipt.receiptDigest,
|
||||
mutationId: 'result-key-retire-local-a',
|
||||
}),
|
||||
);
|
||||
assert.equal(retired.status, 'created');
|
||||
assert.equal(
|
||||
retired.catalog.keys.find((entry) => entry.keyId === 'result-key-a')
|
||||
.state,
|
||||
'retired',
|
||||
);
|
||||
} finally {
|
||||
await current.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps SQLite rekey authority behind its explicit subpath', () => {
|
||||
const root = require('../dist');
|
||||
const authority = require('@qinglong/local-sqlite/tool-result-rekey');
|
||||
assert.equal(root.LocalSqliteToolResultRekeyRepository, undefined);
|
||||
assert.equal(
|
||||
authority.LocalSqliteToolResultRekeyRepository,
|
||||
LocalSqliteToolResultRekeyRepository,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { mkdtemp, rm } = require('node:fs/promises');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
BUILTIN_RUN_READ_TOOL,
|
||||
BUILTIN_RUN_READ_TOOL_DEFINITION,
|
||||
} = require('@qinglong/runtime-core/builtin-run-read-tool');
|
||||
const {
|
||||
TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
} = require('@qinglong/runtime-core/trusted-tool-execution');
|
||||
const {
|
||||
TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA,
|
||||
createToolExecutionResultArtifact,
|
||||
normalizeToolExecutionResultKeyBinding,
|
||||
} = require('@qinglong/runtime-core/tool-execution-completion');
|
||||
const {
|
||||
createToolResultKeyCatalogBootstrapCommand,
|
||||
createToolResultKeyRetirementCommand,
|
||||
createToolResultKeyRotationCommand,
|
||||
requireActiveToolResultKey,
|
||||
toolResultKeyCatalogFence,
|
||||
toolResultKeyMaterialProof,
|
||||
} = require('@qinglong/runtime-core/tool-result-key-catalog');
|
||||
const {
|
||||
createToolExecutionResultRekeyCommand,
|
||||
createToolResultKeyRetirementReceiptCommand,
|
||||
} = require('@qinglong/runtime-core/tool-result-rekey');
|
||||
const {
|
||||
ToolDefinitionRegistry,
|
||||
} = require('@qinglong/runtime-core/tool-registry');
|
||||
const {
|
||||
LocalSqliteToolResultKeyCatalogRepository,
|
||||
} = require('@qinglong/local-sqlite/tool-result-key-catalog');
|
||||
const {
|
||||
LocalSqliteToolResultRekeyRepository,
|
||||
} = require('@qinglong/local-sqlite/tool-result-rekey');
|
||||
const { openLocalSqliteClient } = require('../dist/storage/config');
|
||||
const { migrateLocalSqlitePath } = require('../dist/migration/migration');
|
||||
const { LocalSqliteOperationAuthority } = require('../dist/authority/operationAuthority');
|
||||
|
||||
const COVERAGE_COUNT = 129;
|
||||
const CONCURRENT_REPLAYS = 8;
|
||||
const KEY_A = Buffer.alloc(32, 1);
|
||||
const KEY_B = Buffer.alloc(32, 2);
|
||||
const OUTPUT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-output-digest@v1\0',
|
||||
);
|
||||
const RESULT_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/trusted-tool-execution-result-digest@v1\0',
|
||||
);
|
||||
const BINDING_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/tool-execution-result-key-binding-digest@v1\0',
|
||||
);
|
||||
|
||||
function hash(domain, value) {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function output(index) {
|
||||
const suffix = String(index).padStart(3, '0');
|
||||
return {
|
||||
createdAtMs: 1_000 + index,
|
||||
eventSequence: 3,
|
||||
executionOrigin: 'manual',
|
||||
executionOwner: 'runtime',
|
||||
found: true,
|
||||
id: `run-coverage-pressure-${suffix}`,
|
||||
priority: 10,
|
||||
queuedAtMs: 1_100 + index,
|
||||
startedAtMs: 1_200 + index,
|
||||
status: 'succeeded',
|
||||
taskId: `task-coverage-pressure-${suffix}`,
|
||||
taskRevision: `task-coverage-pressure-${suffix}@1`,
|
||||
version: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function registry() {
|
||||
return new ToolDefinitionRegistry([BUILTIN_RUN_READ_TOOL_DEFINITION]);
|
||||
}
|
||||
|
||||
function nonce(index, marker) {
|
||||
const value = Buffer.alloc(12, marker);
|
||||
value.writeUInt32BE(index, 8);
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceArtifact(index, definitions) {
|
||||
const suffix = String(index).padStart(3, '0');
|
||||
const value = output(index);
|
||||
const unsigned = {
|
||||
schema: TRUSTED_TOOL_EXECUTION_RESULT_SCHEMA,
|
||||
startId: `tool-start-coverage-pressure-${suffix}`,
|
||||
barrierDigest: 'a'.repeat(64),
|
||||
adapterDigest: 'b'.repeat(64),
|
||||
output: value,
|
||||
outputDigest: hash(OUTPUT_DIGEST_DOMAIN, value),
|
||||
completedAtMs: 1_500 + index,
|
||||
};
|
||||
const result = {
|
||||
...unsigned,
|
||||
resultDigest: hash(RESULT_DIGEST_DOMAIN, unsigned),
|
||||
};
|
||||
return createToolExecutionResultArtifact(
|
||||
{
|
||||
artifactId: `artifact-coverage-pressure-${suffix}`,
|
||||
projectId: 'project-coverage-pressure',
|
||||
runId: `run-host-coverage-pressure-${suffix}`,
|
||||
stepRunId: `step-run-coverage-pressure-${suffix}`,
|
||||
tool: BUILTIN_RUN_READ_TOOL,
|
||||
executionResult: result,
|
||||
keyId: 'result-key-a',
|
||||
key: KEY_A,
|
||||
},
|
||||
definitions,
|
||||
() => nonce(index, 4),
|
||||
);
|
||||
}
|
||||
|
||||
function sourceBinding(artifact, catalog) {
|
||||
const unsigned = {
|
||||
schema: TOOL_EXECUTION_RESULT_KEY_BINDING_SCHEMA,
|
||||
startId: artifact.startId,
|
||||
artifactId: artifact.artifactId,
|
||||
artifactDigest: artifact.artifactDigest,
|
||||
catalogGeneration: catalog.generation,
|
||||
catalogDigest: catalog.catalogDigest,
|
||||
keyId: artifact.keyId,
|
||||
materialProof: toolResultKeyMaterialProof(artifact.keyId, KEY_A),
|
||||
};
|
||||
return normalizeToolExecutionResultKeyBinding({
|
||||
...unsigned,
|
||||
bindingDigest: hash(BINDING_DIGEST_DOMAIN, unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function insertBindings(client, bindings) {
|
||||
client.exec('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
const insert = client.prepare(
|
||||
`INSERT INTO "ToolExecutionResultKeyBindings" (
|
||||
start_id, artifact_id, artifact_digest, catalog_authority,
|
||||
catalog_generation, catalog_digest, key_id, material_proof,
|
||||
binding_digest
|
||||
) VALUES (?, ?, ?, 'trusted-tool-results', ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const binding of bindings) {
|
||||
insert.run(
|
||||
binding.startId,
|
||||
binding.artifactId,
|
||||
binding.artifactDigest,
|
||||
binding.catalogGeneration,
|
||||
binding.catalogDigest,
|
||||
binding.keyId,
|
||||
binding.materialProof,
|
||||
binding.bindingDigest,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
|
||||
async function exerciseProfile(profile) {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), `ql3-result-coverage-${profile}-`),
|
||||
);
|
||||
const databasePath = join(directory, 'coverage.sqlite');
|
||||
let authority;
|
||||
try {
|
||||
await migrateLocalSqlitePath({ databasePath, profile });
|
||||
const client = openLocalSqliteClient(
|
||||
{ databasePath, profile, busyTimeoutMs: 5_000 },
|
||||
false,
|
||||
);
|
||||
authority = new LocalSqliteOperationAuthority(client);
|
||||
const catalogs =
|
||||
new LocalSqliteToolResultKeyCatalogRepository(authority);
|
||||
const rekeys = new LocalSqliteToolResultRekeyRepository(authority);
|
||||
const first = await catalogs.append(
|
||||
createToolResultKeyCatalogBootstrapCommand({
|
||||
keyId: 'result-key-a',
|
||||
materialProof: toolResultKeyMaterialProof('result-key-a', KEY_A),
|
||||
mutationId: `coverage-pressure-${profile}-bootstrap-a`,
|
||||
}),
|
||||
);
|
||||
const definitions = registry();
|
||||
const artifacts = Array.from(
|
||||
{ length: COVERAGE_COUNT },
|
||||
(_, index) => sourceArtifact(index, definitions),
|
||||
);
|
||||
const bindings = artifacts.map((artifact) =>
|
||||
sourceBinding(artifact, first.catalog),
|
||||
);
|
||||
insertBindings(client, bindings);
|
||||
const second = await catalogs.append(
|
||||
createToolResultKeyRotationCommand(first.catalog, {
|
||||
keyId: 'result-key-b',
|
||||
materialProof: toolResultKeyMaterialProof('result-key-b', KEY_B),
|
||||
mutationId: `coverage-pressure-${profile}-rotate-b`,
|
||||
}),
|
||||
);
|
||||
const fence = toolResultKeyCatalogFence(
|
||||
second.catalog,
|
||||
requireActiveToolResultKey(second.catalog),
|
||||
);
|
||||
const commands = artifacts.map((artifact, index) =>
|
||||
createToolExecutionResultRekeyCommand({
|
||||
artifact,
|
||||
binding: bindings[index],
|
||||
previousOverlay: null,
|
||||
overlayId: `coverage-pressure-${profile}-overlay-${String(
|
||||
index,
|
||||
).padStart(3, '0')}`,
|
||||
mutationId: `coverage-pressure-${profile}-rekey-${String(
|
||||
index,
|
||||
).padStart(3, '0')}`,
|
||||
targetCatalogFence: fence,
|
||||
targetKey: KEY_B,
|
||||
output: output(index),
|
||||
rekeyedAtMs: 2_000 + index,
|
||||
registry: definitions,
|
||||
nonceFactory: () => nonce(index, 5),
|
||||
}),
|
||||
);
|
||||
const appended = await Promise.all(
|
||||
commands.map((command) => rekeys.append(command)),
|
||||
);
|
||||
assert.equal(
|
||||
appended.filter((result) => result.status === 'created').length,
|
||||
COVERAGE_COUNT,
|
||||
);
|
||||
const replayed = await Promise.all(
|
||||
Array.from({ length: CONCURRENT_REPLAYS }, () =>
|
||||
rekeys.append(commands[0]),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
replayed.every((result) => result.status === 'existing'),
|
||||
true,
|
||||
);
|
||||
|
||||
const receiptCommand = createToolResultKeyRetirementReceiptCommand({
|
||||
expectedCatalogGeneration: second.catalog.generation,
|
||||
expectedCatalogDigest: second.catalog.catalogDigest,
|
||||
keyId: 'result-key-a',
|
||||
mutationId: `coverage-pressure-${profile}-receipt`,
|
||||
});
|
||||
const receipts = await Promise.all(
|
||||
Array.from({ length: CONCURRENT_REPLAYS }, () =>
|
||||
rekeys.create(receiptCommand),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
receipts.filter((result) => result.status === 'created').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
receipts.filter((result) => result.status === 'existing').length,
|
||||
CONCURRENT_REPLAYS - 1,
|
||||
);
|
||||
const receipt = receipts[0].receipt;
|
||||
assert.equal(receipt.bindingCount, COVERAGE_COUNT);
|
||||
assert.equal(receipt.overlayHeadCount, COVERAGE_COUNT);
|
||||
assert.equal(receipt.uncoveredBindingCount, 0);
|
||||
assert.equal(receipt.uncoveredOverlayHeadCount, 0);
|
||||
assert.equal(
|
||||
receipts.every(
|
||||
(result) =>
|
||||
result.receipt.receiptDigest === receipt.receiptDigest &&
|
||||
result.receipt.coverageDigest === receipt.coverageDigest,
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const retireCommand = createToolResultKeyRetirementCommand(
|
||||
second.catalog,
|
||||
{
|
||||
keyId: 'result-key-a',
|
||||
retirementReceiptDigest: receipt.receiptDigest,
|
||||
mutationId: `coverage-pressure-${profile}-retire-a`,
|
||||
},
|
||||
);
|
||||
const retired = await Promise.all(
|
||||
Array.from({ length: CONCURRENT_REPLAYS }, () =>
|
||||
catalogs.append(retireCommand),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
retired.filter((result) => result.status === 'created').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
retired.filter((result) => result.status === 'existing').length,
|
||||
CONCURRENT_REPLAYS - 1,
|
||||
);
|
||||
assert.equal(
|
||||
retired.every(
|
||||
(result) =>
|
||||
result.catalog.catalogDigest === retired[0].catalog.catalogDigest &&
|
||||
result.catalog.keys.find((entry) => entry.keyId === 'result-key-a')
|
||||
.state === 'retired',
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const facts = client
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT count(*) FROM "ToolExecutionResultKeyBindings")
|
||||
AS bindings,
|
||||
(SELECT count(*) FROM "ToolExecutionResultRekeyHeads")
|
||||
AS heads,
|
||||
(SELECT count(*) FROM "ToolResultKeyRetirementReceipts")
|
||||
AS receipts`,
|
||||
)
|
||||
.get();
|
||||
assert.equal(facts.bindings, COVERAGE_COUNT);
|
||||
assert.equal(facts.heads, COVERAGE_COUNT);
|
||||
assert.equal(facts.receipts, 1);
|
||||
assert.equal(
|
||||
client.prepare('PRAGMA integrity_check').get().integrity_check,
|
||||
'ok',
|
||||
);
|
||||
} finally {
|
||||
if (authority) await authority.close();
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of ['edge', 'standalone']) {
|
||||
test(`converges ${profile} 129-row rekey and retirement pressure exactly once`, async () => {
|
||||
await exerciseProfile(profile);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
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 {
|
||||
TriggerConflictError,
|
||||
TriggerUnavailableError,
|
||||
UnsupportedTriggerSpecError,
|
||||
createTriggerSpecSemanticRegistry,
|
||||
} = require('@qinglong/runtime-core/trigger');
|
||||
const {
|
||||
migrateLocalSqlitePath,
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-trigger-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return path.join(directory, 'qinglong3.sqlite');
|
||||
}
|
||||
|
||||
function taskCommand(index, overrides = {}) {
|
||||
const suffix = String(index).padStart(12, '0');
|
||||
return {
|
||||
projectId: 'default',
|
||||
taskId: `task-${String(index).padStart(5, '0')}`,
|
||||
expectedRevision: null,
|
||||
mutationId: `019f7310-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: 'trigger-test' },
|
||||
enabled: true,
|
||||
occurredAtMs: 100 + index,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function triggerCommand(index, task, overrides = {}) {
|
||||
const suffix = String(index).padStart(12, '0');
|
||||
return {
|
||||
projectId: task.projectId,
|
||||
triggerId: `trigger-${String(index).padStart(5, '0')}`,
|
||||
expectedRevision: null,
|
||||
mutationId: `019f7320-0000-7000-8000-${suffix}`,
|
||||
taskId: task.taskId,
|
||||
taskRevision: task.revision,
|
||||
taskContentDigest: task.contentDigest,
|
||||
spec: {
|
||||
schema: 'qinglong/cron@v1',
|
||||
config: {
|
||||
expression: '*/5 * * * *',
|
||||
timezone: 'Etc/UTC',
|
||||
misfirePolicy: 'skip',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
occurredAtMs: 200 + index,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function createRuntime(t, options = {}) {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(
|
||||
{ databasePath, profile: 'edge' },
|
||||
options,
|
||||
);
|
||||
t.after(() => runtime.close());
|
||||
return { databasePath, runtime };
|
||||
}
|
||||
|
||||
test('creates, replays and versions a Trigger pinned to immutable task revisions', async (t) => {
|
||||
const { runtime } = await createRuntime(t);
|
||||
const firstTask = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
|
||||
).definition;
|
||||
const firstInput = triggerCommand(1, firstTask);
|
||||
const created = await runtime.triggers.appendTriggerRevision(firstInput);
|
||||
assert.equal(created.status, 'created');
|
||||
assert.equal(created.trigger.revision, 1);
|
||||
assert.deepEqual({ ...created.trigger.spec.config }, {
|
||||
expression: '*/5 * * * *',
|
||||
misfirePolicy: 'skip',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
assert.equal(
|
||||
(await runtime.triggers.appendTriggerRevision(firstInput)).status,
|
||||
'existing',
|
||||
);
|
||||
|
||||
const secondTask = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(
|
||||
taskCommand(1, {
|
||||
expectedRevision: 1,
|
||||
mutationId: '019f7310-0000-7000-8000-000000010001',
|
||||
name: 'Task 1 revision 2',
|
||||
occurredAtMs: 300,
|
||||
}),
|
||||
)
|
||||
).definition;
|
||||
const updated = await runtime.triggers.appendTriggerRevision(
|
||||
triggerCommand(1, secondTask, {
|
||||
expectedRevision: 1,
|
||||
mutationId: '019f7320-0000-7000-8000-000000010001',
|
||||
occurredAtMs: 400,
|
||||
}),
|
||||
);
|
||||
assert.equal(updated.status, 'updated');
|
||||
assert.equal(updated.trigger.taskRevision, 2);
|
||||
assert.equal(updated.trigger.createdAtMs, created.trigger.createdAtMs);
|
||||
assert.equal(
|
||||
(
|
||||
await runtime.triggers.findTriggerRevision(
|
||||
'default',
|
||||
firstInput.triggerId,
|
||||
1,
|
||||
)
|
||||
).taskRevision,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await runtime.triggers.findCurrentTrigger(
|
||||
'default',
|
||||
firstInput.triggerId,
|
||||
)
|
||||
).taskRevision,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects digest drift, disabled targets, stale CAS and task rebinding', async (t) => {
|
||||
const { runtime } = await createRuntime(t);
|
||||
const task1 = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
|
||||
).definition;
|
||||
await assert.rejects(
|
||||
runtime.triggers.appendTriggerRevision(
|
||||
triggerCommand(1, task1, { taskContentDigest: 'b'.repeat(64) }),
|
||||
),
|
||||
TriggerConflictError,
|
||||
);
|
||||
|
||||
const disabledTask = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(
|
||||
taskCommand(2, { enabled: false }),
|
||||
)
|
||||
).definition;
|
||||
await assert.rejects(
|
||||
runtime.triggers.appendTriggerRevision(triggerCommand(2, disabledTask)),
|
||||
TriggerConflictError,
|
||||
);
|
||||
const disabledTrigger = await runtime.triggers.appendTriggerRevision(
|
||||
triggerCommand(2, disabledTask, {
|
||||
enabled: false,
|
||||
mutationId: '019f7320-0000-7000-8000-000000020001',
|
||||
}),
|
||||
);
|
||||
assert.equal(disabledTrigger.trigger.enabled, false);
|
||||
|
||||
const first = await runtime.triggers.appendTriggerRevision(
|
||||
triggerCommand(1, task1),
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.triggers.appendTriggerRevision(
|
||||
triggerCommand(1, task1, {
|
||||
expectedRevision: null,
|
||||
mutationId: '019f7320-0000-7000-8000-000000010010',
|
||||
}),
|
||||
),
|
||||
TriggerConflictError,
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.triggers.appendTriggerRevision(
|
||||
triggerCommand(1, disabledTask, {
|
||||
enabled: false,
|
||||
expectedRevision: first.trigger.revision,
|
||||
mutationId: '019f7320-0000-7000-8000-000000010011',
|
||||
occurredAtMs: 500,
|
||||
}),
|
||||
),
|
||||
TriggerConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('paginates current Trigger heads and serializes competing revisions', async (t) => {
|
||||
const { runtime } = await createRuntime(t);
|
||||
const task = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
|
||||
).definition;
|
||||
for (const index of [1, 2, 3]) {
|
||||
await runtime.triggers.appendTriggerRevision(triggerCommand(index, task));
|
||||
}
|
||||
const first = await runtime.triggers.listTriggers({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
});
|
||||
assert.equal(first.triggers.length, 2);
|
||||
assert.equal(first.truncated, true);
|
||||
const second = await runtime.triggers.listTriggers({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
after: first.next,
|
||||
});
|
||||
assert.equal(second.triggers.length, 1);
|
||||
assert.equal(second.truncated, false);
|
||||
|
||||
const base = first.triggers[0];
|
||||
const results = await Promise.allSettled([
|
||||
runtime.triggers.appendTriggerRevision(
|
||||
triggerCommand(1, task, {
|
||||
expectedRevision: base.revision,
|
||||
mutationId: '019f7320-0000-7000-8000-000000010101',
|
||||
occurredAtMs: 600,
|
||||
}),
|
||||
),
|
||||
runtime.triggers.appendTriggerRevision(
|
||||
triggerCommand(1, task, {
|
||||
expectedRevision: base.revision,
|
||||
mutationId: '019f7320-0000-7000-8000-000000010102',
|
||||
occurredAtMs: 601,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
assert.equal(results.filter(({ status }) => status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter(({ status }) => status === 'rejected').length, 1);
|
||||
});
|
||||
|
||||
test('fails closed when a durable Trigger or pinned task digest is corrupt', async (t) => {
|
||||
const { databasePath, runtime } = await createRuntime(t);
|
||||
const task = (
|
||||
await runtime.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
|
||||
).definition;
|
||||
const input = triggerCommand(1, task);
|
||||
const trigger = (
|
||||
await runtime.triggers.appendTriggerRevision(input)
|
||||
).trigger;
|
||||
await runtime.close();
|
||||
|
||||
const client = new DatabaseSync(databasePath);
|
||||
client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3TriggerRevisions" SET "content_digest" = ?
|
||||
WHERE "project_id" = ? AND "trigger_id" = ? AND "revision" = ?`,
|
||||
)
|
||||
.run('b'.repeat(64), trigger.projectId, trigger.triggerId, trigger.revision);
|
||||
client.close();
|
||||
const reader = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => reader.close());
|
||||
await assert.rejects(
|
||||
reader.triggers.findCurrentTrigger(trigger.projectId, trigger.triggerId),
|
||||
TriggerUnavailableError,
|
||||
);
|
||||
await reader.close();
|
||||
|
||||
const taskClient = new DatabaseSync(databasePath);
|
||||
taskClient
|
||||
.prepare(
|
||||
`UPDATE "QingLong3TriggerRevisions" SET "content_digest" = ?
|
||||
WHERE "project_id" = ? AND "trigger_id" = ? AND "revision" = ?`,
|
||||
)
|
||||
.run(
|
||||
trigger.contentDigest,
|
||||
trigger.projectId,
|
||||
trigger.triggerId,
|
||||
trigger.revision,
|
||||
);
|
||||
taskClient
|
||||
.prepare(
|
||||
`UPDATE "QingLong3TaskDefinitionRevisions" SET "content_digest" = ?
|
||||
WHERE "project_id" = ? AND "task_id" = ? AND "revision" = ?`,
|
||||
)
|
||||
.run('b'.repeat(64), task.projectId, task.taskId, task.revision);
|
||||
taskClient.close();
|
||||
const replayReader = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => replayReader.close());
|
||||
await assert.rejects(
|
||||
replayReader.triggers.appendTriggerRevision(input),
|
||||
TriggerUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('reads historical extension specs without loading their write semantics', async (t) => {
|
||||
const databasePath = fixture(t);
|
||||
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
|
||||
const registry = createTriggerSpecSemanticRegistry([
|
||||
{
|
||||
schema: 'example/event@v1',
|
||||
normalizeConfig(config) {
|
||||
return Object.freeze({ topic: String(config.topic).toLowerCase() });
|
||||
},
|
||||
},
|
||||
]);
|
||||
const writer = await openLocalSqliteRuntimeDatabase(
|
||||
{ databasePath, profile: 'edge' },
|
||||
{ triggerSpecSemanticRegistry: registry },
|
||||
);
|
||||
const task = (
|
||||
await writer.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
|
||||
).definition;
|
||||
const input = triggerCommand(1, task, {
|
||||
spec: { schema: 'example/event@v1', config: { topic: 'BUILD' } },
|
||||
});
|
||||
await writer.triggers.appendTriggerRevision(input);
|
||||
await writer.close();
|
||||
|
||||
const reader = await openLocalSqliteRuntimeDatabase({
|
||||
databasePath,
|
||||
profile: 'edge',
|
||||
});
|
||||
t.after(() => reader.close());
|
||||
assert.equal(
|
||||
(
|
||||
await reader.triggers.findCurrentTrigger(
|
||||
input.projectId,
|
||||
input.triggerId,
|
||||
)
|
||||
).spec.config.topic,
|
||||
'build',
|
||||
);
|
||||
await assert.rejects(
|
||||
async () => reader.triggers.appendTriggerRevision(input),
|
||||
UnsupportedTriggerSpecError,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user