@fitlab-ai/agent-infra 0.11.5 → 0.11.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/git/primary-worktree.js +58 -0
- package/dist/lib/merge.js +62 -10
- package/dist/lib/platform/github-provider.js +9 -14
- package/dist/lib/platform/github-release-notes.js +80 -11
- package/dist/lib/platform/none-provider.js +1 -1
- package/dist/lib/platform/provider-contract.d.ts +39 -16
- package/dist/lib/platform/provider-contract.js +1 -1
- package/dist/lib/platform/provider-validation.js +83 -13
- package/dist/lib/platform/pull-requests.js +29 -3
- package/dist/lib/platform/release-notes.js +7 -8
- package/dist/lib/sandbox/capture.js +1 -1
- package/dist/lib/sandbox/commands/create.js +46 -1
- package/dist/lib/sandbox/commands/enter.js +1 -1
- package/dist/lib/sandbox/commands/start.js +1 -1
- package/dist/lib/sandbox/config.js +10 -0
- package/dist/lib/sandbox/control/lifecycle.js +18 -13
- package/dist/lib/sandbox/control/server.js +6 -2
- package/dist/lib/sandbox/task-resolver.js +62 -2
- package/dist/lib/server/config.js +7 -27
- package/dist/lib/server/daemon.js +4 -3
- package/dist/lib/server/process-control.js +4 -2
- package/dist/lib/server/runner.js +4 -3
- package/dist/lib/task/artifact-lifecycle.js +42 -10
- package/dist/lib/task/artifact-receipts.js +62 -22
- package/dist/lib/task/capabilities.js +22 -4
- package/dist/lib/task/completion-facts.js +56 -0
- package/dist/lib/task/create-service.js +6 -2
- package/dist/lib/task/create.js +46 -82
- package/dist/lib/task/events.js +268 -195
- package/dist/lib/task/finalization-handoff.js +2 -1
- package/dist/lib/task/invalidation-command.js +4 -2
- package/dist/lib/task/invalidation.js +38 -9
- package/dist/lib/task/local-artifact-finalization.js +17 -3
- package/dist/lib/task/qualification-audit.js +23 -105
- package/dist/lib/task/resolve-ref.js +3 -3
- package/dist/lib/task/review-finalization.js +7 -0
- package/dist/lib/task/review-identity.js +69 -0
- package/dist/lib/task/sections.js +6 -5
- package/dist/lib/task/task-execution-lock.js +6 -3
- package/dist/lib/task/verification-engine.js +14 -63
- package/dist/lib/task/verification.js +35 -38
- package/dist/lib/task/write.js +121 -1
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/git/primary-worktree.ts +62 -0
- package/lib/merge.ts +59 -10
- package/lib/platform/github-provider.ts +11 -18
- package/lib/platform/github-release-notes.ts +89 -14
- package/lib/platform/none-provider.ts +1 -1
- package/lib/platform/provider-contract.ts +38 -12
- package/lib/platform/provider-validation.ts +83 -12
- package/lib/platform/pull-requests.ts +27 -3
- package/lib/platform/release-notes.ts +11 -12
- package/lib/sandbox/capture.ts +1 -1
- package/lib/sandbox/commands/create.ts +52 -1
- package/lib/sandbox/commands/enter.ts +1 -1
- package/lib/sandbox/commands/start.ts +1 -1
- package/lib/sandbox/config.ts +12 -0
- package/lib/sandbox/control/lifecycle.ts +22 -13
- package/lib/sandbox/control/server.ts +5 -2
- package/lib/sandbox/task-resolver.ts +51 -2
- package/lib/server/config.ts +7 -29
- package/lib/server/daemon.ts +5 -3
- package/lib/server/process-control.ts +4 -2
- package/lib/server/runner.ts +7 -4
- package/lib/server/server.schema.json +0 -4
- package/lib/task/artifact-lifecycle.ts +35 -11
- package/lib/task/artifact-receipts.ts +55 -15
- package/lib/task/capabilities.ts +17 -3
- package/lib/task/completion-facts.ts +66 -0
- package/lib/task/create-service.ts +7 -3
- package/lib/task/create.ts +44 -71
- package/lib/task/events.ts +248 -182
- package/lib/task/finalization-handoff.ts +2 -1
- package/lib/task/invalidation-command.ts +4 -2
- package/lib/task/invalidation.ts +35 -10
- package/lib/task/local-artifact-finalization.ts +13 -3
- package/lib/task/qualification-audit.ts +21 -114
- package/lib/task/resolve-ref.ts +3 -3
- package/lib/task/review-finalization.ts +7 -0
- package/lib/task/review-identity.ts +85 -0
- package/lib/task/sections.ts +6 -6
- package/lib/task/task-execution-lock.ts +7 -3
- package/lib/task/verification-engine.ts +13 -86
- package/lib/task/verification.ts +34 -38
- package/lib/task/write.ts +115 -1
- package/package.json +1 -1
- package/templates/.agents/rules/README.en.md +1 -0
- package/templates/.agents/rules/README.zh-CN.md +1 -0
- package/templates/.agents/rules/decision-qualification.en.md +4 -3
- package/templates/.agents/rules/decision-qualification.zh-CN.md +4 -3
- package/templates/.agents/rules/evidence-reporting.en.md +1 -2
- package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
- package/templates/.agents/rules/validation-output.en.md +19 -0
- package/templates/.agents/rules/validation-output.zh-CN.md +19 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
- package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
- package/templates/.agents/skills/block-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/cancel-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/code-task/reference/report-template.en.md +4 -8
- package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +4 -8
- package/templates/.agents/skills/commit/SKILL.en.md +1 -1
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +1 -1
- package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/complete-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
- package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
- package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -0
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -0
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +2 -0
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/review-analysis/SKILL.en.md +3 -1
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -8
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -8
- package/templates/.agents/skills/review-code/SKILL.en.md +13 -12
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +11 -12
- package/templates/.agents/skills/review-code/config/verify.en.json +1 -3
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -3
- package/templates/.agents/skills/review-code/reference/output-templates.en.md +23 -46
- package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +27 -48
- package/templates/.agents/skills/review-code/reference/report-template.en.md +8 -11
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +8 -11
- package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -2
- package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -2
- package/templates/.agents/skills/review-plan/SKILL.en.md +3 -1
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +5 -9
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +5 -9
- package/templates/.agents/skills/review-pr/SKILL.en.md +2 -0
- package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +1 -1
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -0
- package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
- package/templates/.agents/templates/task.en.md +2 -2
- package/templates/.agents/templates/task.zh-CN.md +2 -2
|
@@ -1298,6 +1298,10 @@ export async function quiesceSandboxControlRoot(
|
|
|
1298
1298
|
timeoutMs?: number;
|
|
1299
1299
|
timing?: SandboxControlTimingPolicy;
|
|
1300
1300
|
identityProbe?: ProcessIdentityProbe;
|
|
1301
|
+
executionProcessControl?: Readonly<{
|
|
1302
|
+
isAlive: (execution: SandboxControlExecution) => boolean;
|
|
1303
|
+
terminate: (execution: SandboxControlExecution, phase: 'graceful' | 'force') => boolean;
|
|
1304
|
+
}>;
|
|
1301
1305
|
} = {}
|
|
1302
1306
|
): Promise<'missing' | 'stale' | 'stopped'> {
|
|
1303
1307
|
const resolvedRoot = path.resolve(root);
|
|
@@ -1338,58 +1342,63 @@ export async function quiesceSandboxControlRoot(
|
|
|
1338
1342
|
if (!manifest && brokerLive) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
|
|
1339
1343
|
|
|
1340
1344
|
const platform = options.platform ?? process.platform;
|
|
1345
|
+
const processControl = options.executionProcessControl ?? {
|
|
1346
|
+
isAlive: (execution: SandboxControlExecution) => executionAlive(execution, platform, identityProbe),
|
|
1347
|
+
terminate: (execution: SandboxControlExecution, phase: 'graceful' | 'force') => phase === 'graceful'
|
|
1348
|
+
? terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe })
|
|
1349
|
+
: terminateSandboxControlExecution(execution, {
|
|
1350
|
+
platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
|
|
1351
|
+
})
|
|
1352
|
+
};
|
|
1341
1353
|
let executions = manifest ? readExecutions(manifest) : [];
|
|
1342
1354
|
const owner: OwnerIdentity | null = brokerLive ? broker : statusLive ? statusOwner : null;
|
|
1343
1355
|
if (!owner) {
|
|
1344
1356
|
if (manifest && !broker && !status) throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
|
|
1345
1357
|
for (const execution of executions) {
|
|
1346
|
-
|
|
1358
|
+
processControl.terminate(execution, 'graceful');
|
|
1347
1359
|
}
|
|
1348
1360
|
const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
|
|
1349
1361
|
while (Date.now() < softDeadlineAt) {
|
|
1350
|
-
if (!executions.some((execution) =>
|
|
1362
|
+
if (!executions.some((execution) => processControl.isAlive(execution))) break;
|
|
1351
1363
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1352
1364
|
}
|
|
1353
1365
|
if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
|
|
1354
1366
|
if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
|
|
1355
1367
|
for (const execution of executions) {
|
|
1356
|
-
if (!
|
|
1357
|
-
platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
|
|
1358
|
-
})) {
|
|
1368
|
+
if (!processControl.terminate(execution, 'force')) {
|
|
1359
1369
|
throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
|
|
1360
1370
|
}
|
|
1361
1371
|
}
|
|
1362
|
-
if (executions.some((execution) =>
|
|
1372
|
+
if (executions.some((execution) => processControl.isAlive(execution))) {
|
|
1363
1373
|
throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
|
|
1364
1374
|
}
|
|
1365
1375
|
return broker || status ? 'stale' : 'missing';
|
|
1366
1376
|
}
|
|
1367
1377
|
|
|
1368
|
-
const remaining = (): number => Math.max(0, deadlineAt - Date.now());
|
|
1369
1378
|
signalOwner(owner, platform, false);
|
|
1370
1379
|
for (const execution of executions) {
|
|
1371
|
-
|
|
1380
|
+
processControl.terminate(execution, 'graceful');
|
|
1372
1381
|
}
|
|
1373
1382
|
const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
|
|
1374
1383
|
while (Date.now() < softDeadlineAt) {
|
|
1375
|
-
if (!ownerLive(owner, identityProbe) && !executions.some((execution) =>
|
|
1384
|
+
if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) break;
|
|
1376
1385
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1377
1386
|
}
|
|
1378
|
-
if (!ownerLive(owner, identityProbe) && !executions.some((execution) =>
|
|
1387
|
+
if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) {
|
|
1379
1388
|
return 'stopped';
|
|
1380
1389
|
}
|
|
1381
1390
|
if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
|
|
1382
1391
|
if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
|
|
1383
1392
|
for (const execution of executions) {
|
|
1384
|
-
|
|
1393
|
+
processControl.terminate(execution, 'force');
|
|
1385
1394
|
}
|
|
1386
1395
|
signalOwner(owner, platform, true);
|
|
1387
1396
|
while (Date.now() < deadlineAt) {
|
|
1388
|
-
if (!ownerLive(owner, identityProbe) && !executions.some((execution) =>
|
|
1397
|
+
if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) return 'stopped';
|
|
1389
1398
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1390
1399
|
}
|
|
1391
1400
|
if (ownerLive(owner, identityProbe)) throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
|
|
1392
|
-
if (executions.some((execution) =>
|
|
1401
|
+
if (executions.some((execution) => processControl.isAlive(execution))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
|
|
1393
1402
|
return 'stopped';
|
|
1394
1403
|
}
|
|
1395
1404
|
|
|
@@ -272,12 +272,15 @@ function taskCreateDomainEvidence(
|
|
|
272
272
|
? output.task as Record<string, unknown> : null;
|
|
273
273
|
const taskId = typeof task?.id === 'string' ? task.id : null;
|
|
274
274
|
const shortId = typeof task?.shortId === 'string' ? task.shortId : null;
|
|
275
|
-
|
|
275
|
+
const state = task?.state;
|
|
276
|
+
if (!taskId || !['active', 'blocked', 'completed', 'archive'].includes(String(state))) return { consistent: false };
|
|
277
|
+
if (state === 'active' ? !shortId : output.status !== 'no-op' || shortId !== null) return { consistent: false };
|
|
276
278
|
try {
|
|
277
279
|
const resolved = resolveTaskRef(taskId, { repoRoot: manifest.repoRoot });
|
|
278
280
|
const shortIds = loadShortIdByTaskId(manifest.repoRoot);
|
|
279
281
|
return {
|
|
280
|
-
consistent: resolved.ok && resolved.taskId === taskId && resolved.state ===
|
|
282
|
+
consistent: resolved.ok && resolved.taskId === taskId && resolved.state === state
|
|
283
|
+
&& (state === 'active' ? shortIds.get(taskId) === shortId : !shortIds.has(taskId)),
|
|
281
284
|
taskId,
|
|
282
285
|
shortId
|
|
283
286
|
};
|
|
@@ -36,7 +36,9 @@ function stripQuotes(value: string): string {
|
|
|
36
36
|
|
|
37
37
|
function readTaskContent(repoRoot: string, taskId: string): { content: string; state: TaskWorkspaceState; taskMd: string } {
|
|
38
38
|
for (const state of TASK_WORKSPACE_STATES) {
|
|
39
|
-
const taskPath =
|
|
39
|
+
const taskPath = state === 'archive'
|
|
40
|
+
? path.join(repoRoot, '.agents', 'workspace', state, ...findArchiveTaskParts(repoRoot, taskId), 'local', 'task.md')
|
|
41
|
+
: path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
|
|
40
42
|
if (fs.existsSync(taskPath)) {
|
|
41
43
|
return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
|
|
42
44
|
}
|
|
@@ -45,7 +47,7 @@ function readTaskContent(repoRoot: string, taskId: string): { content: string; s
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
function taskWorkspaceCandidates(repoRoot: string, taskId: string): TaskWorkspace[] {
|
|
48
|
-
|
|
50
|
+
const hot = TASK_WORKSPACE_STATES.filter((state) => state !== 'archive').flatMap((state) => {
|
|
49
51
|
const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
|
|
50
52
|
if (!fs.existsSync(taskMd)) return [];
|
|
51
53
|
const content = fs.readFileSync(taskMd, 'utf8');
|
|
@@ -56,6 +58,32 @@ function taskWorkspaceCandidates(repoRoot: string, taskId: string): TaskWorkspac
|
|
|
56
58
|
taskMd
|
|
57
59
|
}];
|
|
58
60
|
});
|
|
61
|
+
const parts = findArchiveTaskParts(repoRoot, taskId);
|
|
62
|
+
if (parts.length === 0) return hot;
|
|
63
|
+
const taskMd = path.join(repoRoot, '.agents', 'workspace', 'archive', ...parts, 'local', 'task.md');
|
|
64
|
+
if (!fs.existsSync(taskMd)) return hot;
|
|
65
|
+
const content = fs.readFileSync(taskMd, 'utf8');
|
|
66
|
+
return [...hot, {
|
|
67
|
+
taskId,
|
|
68
|
+
branch: resolveBranchFromTaskContent(content, taskId),
|
|
69
|
+
state: 'archive',
|
|
70
|
+
taskMd
|
|
71
|
+
}];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function findArchiveTaskParts(repoRoot: string, taskId: string): string[] {
|
|
75
|
+
const archiveRoot = path.join(repoRoot, '.agents', 'workspace', 'archive');
|
|
76
|
+
if (!fs.existsSync(archiveRoot)) return [];
|
|
77
|
+
for (const year of fs.readdirSync(archiveRoot).sort().reverse()) {
|
|
78
|
+
if (!/^\d{4}$/.test(year)) continue;
|
|
79
|
+
for (const month of fs.readdirSync(path.join(archiveRoot, year)).sort().reverse()) {
|
|
80
|
+
if (!/^\d{2}$/.test(month)) continue;
|
|
81
|
+
for (const day of fs.readdirSync(path.join(archiveRoot, year, month)).sort().reverse()) {
|
|
82
|
+
if (/^\d{2}$/.test(day) && fs.existsSync(path.join(archiveRoot, year, month, day, taskId, 'local', 'task.md'))) return [year, month, day, taskId];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [];
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
function resolveBranchFromTaskContent(content: string, taskId: string): string {
|
|
@@ -102,6 +130,27 @@ export function resolveTaskWorkspace(taskId: string, repoRoot: string): TaskWork
|
|
|
102
130
|
|
|
103
131
|
export function listTaskWorkspaces(repoRoot: string, state: TaskWorkspaceState): TaskWorkspace[] {
|
|
104
132
|
const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
|
|
133
|
+
if (state === 'archive') {
|
|
134
|
+
if (!fs.existsSync(stateRoot)) return [];
|
|
135
|
+
const rows: TaskWorkspace[] = [];
|
|
136
|
+
for (const year of fs.readdirSync(stateRoot).sort()) {
|
|
137
|
+
if (!/^\d{4}$/.test(year)) continue;
|
|
138
|
+
for (const month of fs.readdirSync(path.join(stateRoot, year)).sort()) {
|
|
139
|
+
if (!/^\d{2}$/.test(month)) continue;
|
|
140
|
+
for (const day of fs.readdirSync(path.join(stateRoot, year, month)).sort()) {
|
|
141
|
+
if (!/^\d{2}$/.test(day)) continue;
|
|
142
|
+
for (const taskId of fs.readdirSync(path.join(stateRoot, year, month, day)).sort()) {
|
|
143
|
+
if (!TASK_ID_RE.test(taskId)) continue;
|
|
144
|
+
const taskMd = path.join(stateRoot, year, month, day, taskId, 'local', 'task.md');
|
|
145
|
+
if (!fs.existsSync(taskMd)) continue;
|
|
146
|
+
const content = fs.readFileSync(taskMd, 'utf8');
|
|
147
|
+
rows.push({ taskId, branch: resolveBranchFromTaskContent(content, taskId), state, taskMd });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return rows;
|
|
153
|
+
}
|
|
105
154
|
if (!fs.existsSync(stateRoot)) return [];
|
|
106
155
|
return fs.readdirSync(stateRoot, { withFileTypes: true })
|
|
107
156
|
.filter((entry) => entry.isDirectory() && TASK_ID_RE.test(entry.name))
|
package/lib/server/config.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { createHash } from 'node:crypto';
|
|
5
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { resolvePrimaryWorktreeRoot } from '../git/primary-worktree.ts';
|
|
6
6
|
|
|
7
7
|
export type ServerLogConfig = {
|
|
8
8
|
path: string;
|
|
@@ -52,17 +52,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
|
52
52
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
//
|
|
56
|
-
// directory, keyed by the .airc.json "project" AND a stable hash of the repo
|
|
57
|
-
// root path:
|
|
58
|
-
// ~/.agent-infra/logs/<project>/<repo-hash>/server.log
|
|
59
|
-
// ~/.agent-infra/run/<project>/<repo-hash>/server.pid
|
|
60
|
-
// The <project> segment groups a project's checkouts for readability; the
|
|
61
|
-
// <repo-hash> segment guarantees that two checkouts/worktrees of the same
|
|
62
|
-
// project (same "project" but different absolute path) get ISOLATED runtime
|
|
63
|
-
// dirs, so they never read/control each other's daemon. Using os.homedir() +
|
|
64
|
-
// path.join keeps this correct on Windows too (C:\Users\<name>\.agent-infra\...).
|
|
65
|
-
// An explicit log.path in server.json/.local/env still overrides the log default.
|
|
55
|
+
// Runtime state is shared by all worktrees through the primary checkout.
|
|
66
56
|
function resolveProjectKey(repoRoot: string): string {
|
|
67
57
|
try {
|
|
68
58
|
const airc = JSON.parse(
|
|
@@ -77,13 +67,8 @@ function resolveProjectKey(repoRoot: string): string {
|
|
|
77
67
|
return path.basename(repoRoot);
|
|
78
68
|
}
|
|
79
69
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return createHash('sha256').update(repoRoot).digest('hex').slice(0, 12);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function runtimePath(repoRoot: string, projectKey: string, kind: 'logs' | 'run', file: string): string {
|
|
86
|
-
return path.join(homedir(), '.agent-infra', kind, projectKey, repoKey(repoRoot), file);
|
|
70
|
+
function runtimePath(projectKey: string, kind: 'logs' | 'run', file: string): string {
|
|
71
|
+
return path.join(homedir(), '.agent-infra', kind, projectKey, file);
|
|
87
72
|
}
|
|
88
73
|
|
|
89
74
|
function detectRepoRoot(): string {
|
|
@@ -185,7 +170,7 @@ export function validateServerConfig(committed: Record<string, unknown>): Server
|
|
|
185
170
|
}
|
|
186
171
|
|
|
187
172
|
export function loadServerConfig({ rootDir }: { rootDir?: string } = {}): ServerConfig {
|
|
188
|
-
const repoRoot = rootDir ?? detectRepoRoot();
|
|
173
|
+
const repoRoot = resolvePrimaryWorktreeRoot(rootDir ?? detectRepoRoot());
|
|
189
174
|
const agentsDir = path.join(repoRoot, '.agents');
|
|
190
175
|
|
|
191
176
|
const committed = readJsonIfPresent(path.join(agentsDir, 'server.json'));
|
|
@@ -205,18 +190,11 @@ export function loadServerConfig({ rootDir }: { rootDir?: string } = {}): Server
|
|
|
205
190
|
const projectKey = resolveProjectKey(repoRoot);
|
|
206
191
|
|
|
207
192
|
const log = isPlainObject(merged.log) ? merged.log : {};
|
|
208
|
-
// No explicit log.path → default under ~/.agent-infra/logs/<project>/.
|
|
209
|
-
// Explicit relative path resolves against the repo root; absolute is used as-is.
|
|
210
|
-
const explicitPath = typeof log.path === 'string' ? log.path : null;
|
|
211
|
-
const resolvedLogPath = explicitPath === null
|
|
212
|
-
? runtimePath(repoRoot, projectKey, 'logs', 'server.log')
|
|
213
|
-
: (path.isAbsolute(explicitPath) ? explicitPath : path.join(repoRoot, explicitPath));
|
|
214
|
-
|
|
215
193
|
return {
|
|
216
194
|
repoRoot,
|
|
217
|
-
pidFile: runtimePath(
|
|
195
|
+
pidFile: runtimePath(projectKey, 'run', 'server.pid'),
|
|
218
196
|
log: {
|
|
219
|
-
path:
|
|
197
|
+
path: runtimePath(projectKey, 'logs', 'server.log'),
|
|
220
198
|
rotateAtBytes: typeof log.rotateAtBytes === 'number' ? log.rotateAtBytes : DEFAULT_ROTATE_BYTES
|
|
221
199
|
},
|
|
222
200
|
heartbeatMs: typeof merged.heartbeatMs === 'number' ? merged.heartbeatMs : DEFAULT_SERVER_CONFIG.heartbeatMs,
|
package/lib/server/daemon.ts
CHANGED
|
@@ -83,7 +83,7 @@ export function createMessageDispatcher(options: MessageDispatcherOptions): (mes
|
|
|
83
83
|
chunkChars: typeof options.config.stream?.chunkChars === 'number' ? options.config.stream.chunkChars : 4000,
|
|
84
84
|
throttleMs: typeof options.config.stream?.throttleMs === 'number' ? options.config.stream.throttleMs : 1500
|
|
85
85
|
},
|
|
86
|
-
(emit) => runAiImpl(plan.argv, { onChunk: emit }),
|
|
86
|
+
(emit) => runAiImpl(plan.argv, { onChunk: emit, cwd: options.config.repoRoot }),
|
|
87
87
|
(outbound) => replyOutbound(message, outbound)
|
|
88
88
|
);
|
|
89
89
|
}
|
|
@@ -100,15 +100,17 @@ export function createMessageDispatcher(options: MessageDispatcherOptions): (mes
|
|
|
100
100
|
// - runDaemon() awaits a shutdown promise that only resolves once a
|
|
101
101
|
// SIGINT/SIGTERM handler has finished graceful cleanup. We never unref()
|
|
102
102
|
// the only keep-alive timer (that would let the process exit immediately).
|
|
103
|
-
export async function runDaemon(): Promise<void> {
|
|
103
|
+
export async function runDaemon({ rootDir }: { rootDir?: string } = {}): Promise<void> {
|
|
104
104
|
let config;
|
|
105
105
|
try {
|
|
106
|
-
config = loadServerConfig();
|
|
106
|
+
config = loadServerConfig({ rootDir });
|
|
107
107
|
} catch (error) {
|
|
108
108
|
process.stderr.write(`${errorMessage(error)}\n`);
|
|
109
109
|
process.exit(1);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
process.chdir(config.repoRoot);
|
|
113
|
+
|
|
112
114
|
const logger = createLogger(config.log);
|
|
113
115
|
logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
|
|
114
116
|
const ownStartTime = getProcessStartTime(process.pid);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { spawn, execFileSync } from 'node:child_process';
|
|
3
4
|
import { loadServerConfig } from './config.ts';
|
|
4
5
|
import type { ServerConfig } from './config.ts';
|
|
@@ -91,7 +92,7 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
if (foreground) {
|
|
94
|
-
await runDaemon();
|
|
95
|
+
await runDaemon({ rootDir: config.repoRoot });
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
|
|
@@ -103,8 +104,9 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
|
|
|
103
104
|
// Re-spawn ourselves detached. process.execArgv is forwarded so the dev path
|
|
104
105
|
// (node --experimental-strip-types ./bin/cli.ts) and the built path
|
|
105
106
|
// (node dist/bin/cli.js) both work.
|
|
106
|
-
const child = spawn(process.execPath, [...process.execArgv, cliEntry, 'server', '__daemon'], {
|
|
107
|
+
const child = spawn(process.execPath, [...process.execArgv, path.resolve(cliEntry), 'server', '__daemon'], {
|
|
107
108
|
detached: true,
|
|
109
|
+
cwd: config.repoRoot,
|
|
108
110
|
stdio: 'ignore'
|
|
109
111
|
});
|
|
110
112
|
child.unref();
|
package/lib/server/runner.ts
CHANGED
|
@@ -10,8 +10,9 @@ export type RunnerResult = {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
export type RunnerOptions = {
|
|
13
|
-
spawn?: (file: string, args: string[], onChunk?: (chunk: string) => void | Promise<void
|
|
13
|
+
spawn?: (file: string, args: string[], onChunk?: (chunk: string) => void | Promise<void>, cwd?: string) => Promise<RunnerResult>;
|
|
14
14
|
onChunk?: (chunk: string) => void | Promise<void>;
|
|
15
|
+
cwd?: string;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
function resolveCommand(file: string): string {
|
|
@@ -40,13 +41,15 @@ function needsShell(file: string): boolean {
|
|
|
40
41
|
function spawnCapture(
|
|
41
42
|
file: string,
|
|
42
43
|
args: string[],
|
|
43
|
-
onChunk?: (chunk: string) => void | Promise<void
|
|
44
|
+
onChunk?: (chunk: string) => void | Promise<void>,
|
|
45
|
+
cwd?: string
|
|
44
46
|
): Promise<RunnerResult> {
|
|
45
47
|
return new Promise((resolve, reject) => {
|
|
46
48
|
const resolvedFile = resolveCommand(file);
|
|
47
49
|
const child = spawn(resolvedFile, args, {
|
|
48
50
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
49
|
-
shell: needsShell(resolvedFile)
|
|
51
|
+
shell: needsShell(resolvedFile),
|
|
52
|
+
...(cwd === undefined ? {} : { cwd })
|
|
50
53
|
});
|
|
51
54
|
let stdout = '';
|
|
52
55
|
let stderr = '';
|
|
@@ -91,5 +94,5 @@ function spawnCapture(
|
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
export async function runAi(args: string[], options: RunnerOptions = {}): Promise<RunnerResult> {
|
|
94
|
-
return (options.spawn ?? spawnCapture)('ai', args, options.onChunk);
|
|
97
|
+
return (options.spawn ?? spawnCapture)('ai', args, options.onChunk, options.cwd);
|
|
95
98
|
}
|
|
@@ -13,10 +13,6 @@
|
|
|
13
13
|
"type": "object",
|
|
14
14
|
"additionalProperties": false,
|
|
15
15
|
"properties": {
|
|
16
|
-
"path": {
|
|
17
|
-
"type": "string",
|
|
18
|
-
"description": "Log file path. Defaults to ~/.agent-infra/logs/<project>/<repo-hash>/server.log (outside the repo; the repo-hash isolates checkouts that share a project). Relative paths resolve against the repo root; absolute paths are used as-is."
|
|
19
|
-
},
|
|
20
16
|
"rotateAtBytes": {
|
|
21
17
|
"type": "integer",
|
|
22
18
|
"minimum": 0,
|
|
@@ -9,6 +9,8 @@ import { parseImplementationInputs, selectPendingImplementationInput } from './i
|
|
|
9
9
|
import { parseVerdict } from './review-artifacts.ts';
|
|
10
10
|
import { extractSection, findSectionHeading } from './sections.ts';
|
|
11
11
|
import { receiptForOutput, sha256File } from './artifact-receipts.ts';
|
|
12
|
+
import { hasArtifactCompletionFact, hasArtifactCompletionLog } from './completion-facts.ts';
|
|
13
|
+
import { inspectReviewIdentity } from './review-identity.ts';
|
|
12
14
|
import { isArtifactInvalidated, parseInvalidationDocument } from './invalidation.ts';
|
|
13
15
|
import type { InvalidationDocument } from './invalidation.ts';
|
|
14
16
|
import type { ArtifactFamily, ArtifactFamilySpec } from './artifact-name.ts';
|
|
@@ -228,7 +230,12 @@ function inspectArtifactDirectory(
|
|
|
228
230
|
if (!knownRounds.has(round)) diagnostics.push(diagnostic('ROUND_GAP', family, null, `${artifactName(family, round)} is missing`));
|
|
229
231
|
}
|
|
230
232
|
const latest = artifacts.at(-1) ?? null;
|
|
231
|
-
|
|
233
|
+
let hasReviewCodeReceipt = false;
|
|
234
|
+
if (family === 'review-code' && latest) {
|
|
235
|
+
try { hasReviewCodeReceipt = Boolean(receiptForOutput(fs.readFileSync(path.join(taskDir, 'task.md'), 'utf8'), latest.name)); }
|
|
236
|
+
catch { hasReviewCodeReceipt = false; }
|
|
237
|
+
}
|
|
238
|
+
const reviewedInput = family.startsWith('review-') && latest && (family !== 'review-code' || hasReviewCodeReceipt)
|
|
232
239
|
? resolveReviewedInput(taskDir, latest, family === 'review-analysis' ? 'analysis' : family === 'review-plan' ? 'plan' : 'code', diagnostics)
|
|
233
240
|
: null;
|
|
234
241
|
return {
|
|
@@ -316,14 +323,16 @@ function assertWritableInventory(inventory: ArtifactInventoryResult): ArtifactEr
|
|
|
316
323
|
}
|
|
317
324
|
|
|
318
325
|
const REQUIRED_INPUT: Partial<Record<ArtifactFamily, ArtifactFamily>> = {
|
|
319
|
-
'review-analysis': 'analysis', plan: 'analysis', 'review-plan': 'plan'
|
|
326
|
+
'review-analysis': 'analysis', plan: 'analysis', 'review-plan': 'plan'
|
|
320
327
|
};
|
|
321
|
-
const OPTIONAL_CONTEXT: Partial<Record<ArtifactFamily,
|
|
322
|
-
analysis:
|
|
323
|
-
plan:
|
|
324
|
-
'review-
|
|
325
|
-
|
|
326
|
-
'
|
|
328
|
+
const OPTIONAL_CONTEXT: Partial<Record<ArtifactFamily, readonly ArtifactFamily[]>> = {
|
|
329
|
+
analysis: ['review-analysis'],
|
|
330
|
+
plan: ['review-analysis', 'review-plan'],
|
|
331
|
+
'review-plan': ['review-analysis'],
|
|
332
|
+
code: ['review-plan'],
|
|
333
|
+
'review-code': ['code', 'plan', 'review-plan'],
|
|
334
|
+
'manual-validation': ['review-code'],
|
|
335
|
+
'validation-run': ['review-code']
|
|
327
336
|
};
|
|
328
337
|
function hasSelectionEvidence(
|
|
329
338
|
content: string,
|
|
@@ -336,6 +345,18 @@ function hasSelectionEvidence(
|
|
|
336
345
|
if (family.startsWith('review-')) {
|
|
337
346
|
const reviewedFamily = family === 'review-analysis' ? 'analysis' : family === 'review-plan' ? 'plan' : 'code';
|
|
338
347
|
const currentInput = context.inputs.find((input) => input.family === reviewedFamily);
|
|
348
|
+
if (family === 'review-code') {
|
|
349
|
+
if (!context.latest) return false;
|
|
350
|
+
try {
|
|
351
|
+
const reportContent = fs.readFileSync(context.latest.path, 'utf8');
|
|
352
|
+
const identity = inspectReviewIdentity(context.taskDir!, reportContent, options.repoRoot);
|
|
353
|
+
return identity.status !== 'matched'
|
|
354
|
+
|| !hasArtifactCompletionFact(content, context.latest.path, 'review-code.completed')
|
|
355
|
+
|| !hasArtifactCompletionLog(content, context.latest.name, 'review-code.completed');
|
|
356
|
+
} catch {
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
339
360
|
if (currentInput && context.reviewedInput?.name !== currentInput.name) return true;
|
|
340
361
|
}
|
|
341
362
|
const target = family === 'analysis' ? 'analysis' : family === 'plan' ? 'plan' : family === 'code' ? 'code' : null;
|
|
@@ -450,9 +471,9 @@ function resolveArtifactContext(taskRef: string, family: string, options: Inspec
|
|
|
450
471
|
}
|
|
451
472
|
const optional = OPTIONAL_CONTEXT[inventory.family as ArtifactFamily];
|
|
452
473
|
if (optional) {
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
inputs.push(context.latest);
|
|
474
|
+
for (const family of optional) {
|
|
475
|
+
const context = inspectTaskArtifacts(taskRef, family, options);
|
|
476
|
+
if (context.status === 'ready' && context.latest) inputs.push(context.latest);
|
|
456
477
|
}
|
|
457
478
|
}
|
|
458
479
|
if (inventory.family === 'manual-validation' || inventory.family === 'validation-run') {
|
|
@@ -473,6 +494,9 @@ function resolveCodeContext(inventory: ArtifactInventoryResult, options: Inspect
|
|
|
473
494
|
const codeMax = latestCode?.round ?? 0;
|
|
474
495
|
const reviewMax = reviewCode.latest?.round ?? 0;
|
|
475
496
|
const inputs = [source.latest];
|
|
497
|
+
const sourceReviewFamily = inputFamily === 'analysis' ? 'review-analysis' : 'review-plan';
|
|
498
|
+
const sourceReview = inspectTaskArtifacts(taskRef, sourceReviewFamily, options);
|
|
499
|
+
if (sourceReview.status === 'ready' && sourceReview.latest && sourceReview.reviewedInput?.name === source.latest.name) inputs.push(sourceReview.latest);
|
|
476
500
|
if (latestCode) {
|
|
477
501
|
try {
|
|
478
502
|
const taskContent = fs.readFileSync(path.join(inventory.taskDir!, 'task.md'), 'utf8');
|
|
@@ -7,24 +7,30 @@ import { extractSection, findSectionHeading, parseTable } from './sections.ts';
|
|
|
7
7
|
const RECEIPT_SECTION_ALIASES = ['产物生命周期收据', 'Artifact Lifecycle Receipts'] as const;
|
|
8
8
|
const RECEIPT_COLUMNS = ['event', 'output', 'input', 'input_sha256', 'completed_at'] as const;
|
|
9
9
|
const RECEIPT_EVENTS = new Set([
|
|
10
|
+
'analysis.completed',
|
|
11
|
+
'plan.completed',
|
|
10
12
|
'review-analysis.completed',
|
|
11
13
|
'review-plan.completed',
|
|
14
|
+
'code.completed',
|
|
12
15
|
'review-code.completed',
|
|
13
|
-
'code.completed'
|
|
14
16
|
]);
|
|
15
17
|
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
16
18
|
const RECEIPT_SHAPES = {
|
|
19
|
+
'analysis.completed': { output: 'analysis', input: 'review-analysis' },
|
|
20
|
+
'plan.completed': { output: 'plan', input: 'analysis' },
|
|
17
21
|
'review-analysis.completed': { output: 'review-analysis', input: 'analysis' },
|
|
18
22
|
'review-plan.completed': { output: 'review-plan', input: 'plan' },
|
|
19
|
-
'
|
|
20
|
-
'code.completed': { output: 'code', input: '
|
|
23
|
+
'code.completed': { output: 'code', input: 'plan' },
|
|
24
|
+
'review-code.completed': { output: 'review-code', input: 'code' }
|
|
21
25
|
} as const;
|
|
22
26
|
|
|
23
27
|
type ArtifactReceiptEvent =
|
|
28
|
+
| 'analysis.completed'
|
|
29
|
+
| 'plan.completed'
|
|
24
30
|
| 'review-analysis.completed'
|
|
25
31
|
| 'review-plan.completed'
|
|
26
|
-
| '
|
|
27
|
-
| 'code.completed';
|
|
32
|
+
| 'code.completed'
|
|
33
|
+
| 'review-code.completed';
|
|
28
34
|
type ArtifactReceipt = {
|
|
29
35
|
event: ArtifactReceiptEvent;
|
|
30
36
|
output: string;
|
|
@@ -65,8 +71,16 @@ function validateReceiptShape(event: ArtifactReceiptEvent, output: string, input
|
|
|
65
71
|
if (!outputIdentity || !inputIdentity) throw new ArtifactReceiptError(`receipt artifact identity is invalid: ${output} -> ${input}`);
|
|
66
72
|
const shape = RECEIPT_SHAPES[event];
|
|
67
73
|
const inputMatches = event === 'code.completed'
|
|
68
|
-
? inputIdentity.family === 'analysis' || inputIdentity.family === 'plan'
|
|
69
|
-
:
|
|
74
|
+
? inputIdentity.family === 'analysis' || inputIdentity.family === 'plan' || inputIdentity.family === 'review-analysis' || inputIdentity.family === 'review-plan' || inputIdentity.family === 'review-code'
|
|
75
|
+
: event === 'plan.completed'
|
|
76
|
+
? inputIdentity.family === 'analysis' || inputIdentity.family === 'review-analysis' || inputIdentity.family === 'review-plan'
|
|
77
|
+
: event === 'analysis.completed'
|
|
78
|
+
? inputIdentity.family === 'review-analysis'
|
|
79
|
+
: event === 'review-plan.completed'
|
|
80
|
+
? inputIdentity.family === 'plan' || inputIdentity.family === 'review-analysis'
|
|
81
|
+
: event === 'review-code.completed'
|
|
82
|
+
? inputIdentity.family === 'code' || inputIdentity.family === 'plan' || inputIdentity.family === 'review-plan'
|
|
83
|
+
: inputIdentity.family === shape.input;
|
|
70
84
|
if (outputIdentity.family !== shape.output || !inputMatches) {
|
|
71
85
|
throw new ArtifactReceiptError(`receipt event '${event}' does not match ${output} -> ${input}`);
|
|
72
86
|
}
|
|
@@ -81,7 +95,9 @@ function parseArtifactReceipts(content: string): ArtifactReceiptParseResult {
|
|
|
81
95
|
table = parseTable(content, {
|
|
82
96
|
sectionAliases: [...RECEIPT_SECTION_ALIASES],
|
|
83
97
|
columns: [...RECEIPT_COLUMNS],
|
|
84
|
-
|
|
98
|
+
// A completed artifact can have multiple lifecycle inputs. Pair
|
|
99
|
+
// uniqueness is checked below because the table parser supports one key.
|
|
100
|
+
keyColumn: null
|
|
85
101
|
});
|
|
86
102
|
} catch (error) {
|
|
87
103
|
throw new ArtifactReceiptError(error instanceof Error ? error.message : String(error));
|
|
@@ -109,15 +125,31 @@ function parseArtifactReceipts(content: string): ArtifactReceiptParseResult {
|
|
|
109
125
|
completedAt
|
|
110
126
|
};
|
|
111
127
|
});
|
|
128
|
+
const edges = new Set<string>();
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
const edge = `${row.output}\0${row.input}`;
|
|
131
|
+
if (edges.has(edge)) throw new ArtifactReceiptError(`duplicate receipt edge '${row.output}' <- '${row.input}'`);
|
|
132
|
+
edges.add(edge);
|
|
133
|
+
}
|
|
112
134
|
return { present: true, rows };
|
|
113
135
|
}
|
|
114
136
|
|
|
137
|
+
function receiptsForOutput(content: string, output: string): readonly ArtifactReceipt[] {
|
|
138
|
+
return parseArtifactReceipts(content).rows.filter((row) => row.output === output);
|
|
139
|
+
}
|
|
140
|
+
|
|
115
141
|
function receiptForOutput(content: string, output: string): ArtifactReceipt | null {
|
|
116
|
-
|
|
117
|
-
return parsed.rows.find((row) => row.output === output) ?? null;
|
|
142
|
+
return receiptsForOutput(content, output)[0] ?? null;
|
|
118
143
|
}
|
|
119
144
|
|
|
120
145
|
function upsertArtifactReceipt(content: string, receipt: ArtifactReceipt): ReceiptSectionMutation {
|
|
146
|
+
return upsertArtifactReceipts(content, [receipt]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function upsertArtifactReceipts(content: string, receiptsToAdd: readonly ArtifactReceipt[]): ReceiptSectionMutation {
|
|
150
|
+
if (receiptsToAdd.length === 0) throw new ArtifactReceiptError('at least one receipt is required');
|
|
151
|
+
const additions = new Map<string, ArtifactReceipt>();
|
|
152
|
+
for (const receipt of receiptsToAdd) {
|
|
121
153
|
if (!RECEIPT_EVENTS.has(receipt.event)) throw new ArtifactReceiptError(`unknown receipt event '${receipt.event}'`);
|
|
122
154
|
if (!receipt.output || !receipt.input) throw new ArtifactReceiptError('receipt output and input are required');
|
|
123
155
|
validateReceiptShape(receipt.event, receipt.output, receipt.input);
|
|
@@ -126,12 +158,18 @@ function upsertArtifactReceipt(content: string, receipt: ArtifactReceipt): Recei
|
|
|
126
158
|
throw new ArtifactReceiptError(`receipt completion time for '${receipt.output}' is invalid`);
|
|
127
159
|
}
|
|
128
160
|
|
|
161
|
+
const key = `${receipt.output}\0${receipt.input}`;
|
|
162
|
+
const duplicate = additions.get(key);
|
|
163
|
+
if (duplicate && JSON.stringify(duplicate) !== JSON.stringify(receipt)) throw new ArtifactReceiptError(`receipt for '${receipt.output}' from '${receipt.input}' has conflicting evidence`);
|
|
164
|
+
additions.set(key, receipt);
|
|
165
|
+
}
|
|
129
166
|
const existing = parseArtifactReceipts(content).rows;
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
throw new ArtifactReceiptError(`receipt for '${receipt.output}' already exists with different evidence`);
|
|
167
|
+
for (const receipt of additions.values()) {
|
|
168
|
+
const previous = existing.find((row) => row.output === receipt.output && row.input === receipt.input);
|
|
169
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(receipt)) throw new ArtifactReceiptError(`receipt for '${receipt.output}' from '${receipt.input}' already exists with different evidence`);
|
|
133
170
|
}
|
|
134
|
-
const rows =
|
|
171
|
+
const rows = [...existing];
|
|
172
|
+
for (const receipt of additions.values()) if (!rows.some((row) => row.output === receipt.output && row.input === receipt.input)) rows.push(receipt);
|
|
135
173
|
const heading = findSectionHeading(content, [...RECEIPT_SECTION_ALIASES]);
|
|
136
174
|
const body = [
|
|
137
175
|
`| ${RECEIPT_COLUMNS.join(' | ')} |`,
|
|
@@ -146,8 +184,10 @@ export {
|
|
|
146
184
|
RECEIPT_SECTION_ALIASES,
|
|
147
185
|
parseArtifactReceipts,
|
|
148
186
|
receiptForOutput,
|
|
187
|
+
receiptsForOutput,
|
|
149
188
|
sha256Bytes,
|
|
150
189
|
sha256File,
|
|
151
|
-
upsertArtifactReceipt
|
|
190
|
+
upsertArtifactReceipt,
|
|
191
|
+
upsertArtifactReceipts
|
|
152
192
|
};
|
|
153
193
|
export type { ArtifactReceipt, ArtifactReceiptEvent, ArtifactReceiptParseResult, ReceiptSectionMutation };
|