@fitlab-ai/agent-infra 0.9.15 → 0.10.0
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/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +6 -2
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +43 -3
- package/dist/lib/fs/durable-write.js +2 -0
- package/dist/lib/internal/cli-route-inventory.js +1 -1
- package/dist/lib/internal/manual-validation.js +17 -8
- package/dist/lib/internal/task-artifact.js +1 -1
- package/dist/lib/internal/task-lifecycle.js +2 -2
- package/dist/lib/internal/task-operation-registry.js +0 -1
- package/dist/lib/internal/task-review.js +4 -4
- package/dist/lib/platform/issue-comments.js +16 -10
- package/dist/lib/platform/issues.js +1 -1
- package/dist/lib/platform/pr-change-report.js +5 -4
- package/dist/lib/platform/pr-summary.js +64 -32
- package/dist/lib/platform/pull-requests.js +3 -3
- package/dist/lib/platform/task-identities.js +2 -9
- package/dist/lib/platform/verification-sync.js +6 -2
- package/dist/lib/pr-review/evidence-grading.js +4 -8
- package/dist/lib/sandbox/commands/rm.js +21 -2
- package/dist/lib/sandbox/control/server.js +29 -9
- package/dist/lib/sandbox/control/state.js +7 -1
- package/dist/lib/sandbox/control/workflow-executor.js +4 -14
- package/dist/lib/sandbox/index.js +1 -2
- package/dist/lib/sandbox/removal.js +116 -1011
- package/dist/lib/sandbox/workspace-identity.js +17 -1
- package/dist/lib/task/artifact-command.js +6 -28
- package/dist/lib/task/artifact-operations.js +16 -230
- package/dist/lib/task/artifact-recovery.js +430 -0
- package/dist/lib/task/artifact-repair-intent.js +73 -39
- package/dist/lib/task/commit-operation.js +31 -4
- package/dist/lib/task/control-authority.js +38 -7
- package/dist/lib/task/control-recovery.js +14 -2
- package/dist/lib/task/create-service.js +1 -8
- package/dist/lib/task/delegation-receipts.js +64 -3
- package/dist/lib/task/events.js +164 -22
- package/dist/lib/task/human-override.js +5 -8
- package/dist/lib/task/intermediate-cleanup.js +66 -8
- package/dist/lib/task/lifecycle-recovery.js +549 -0
- package/dist/lib/task/lifecycle.js +12 -3
- package/dist/lib/task/local-artifact-finalization.js +148 -123
- package/dist/lib/task/manual-validation-receipt.js +5 -1
- package/dist/lib/task/orchestration.js +52 -2
- package/dist/lib/task/pr-delivery-fact.js +12 -56
- package/dist/lib/task/recovery-warning.js +19 -0
- package/dist/lib/task/review-command.js +1 -0
- package/dist/lib/task/review-finalization.js +104 -77
- package/dist/lib/task/short-id.js +15 -7
- package/dist/lib/task/task-execution-lock.js +1 -1
- package/dist/lib/task/verification-engine.js +3 -14
- package/dist/lib/task/workflow-command.js +0 -1
- package/dist/lib/version.js +1 -11
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +7 -2
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +55 -3
- package/lib/fs/durable-write.ts +1 -0
- package/lib/internal/cli-route-inventory.ts +1 -1
- package/lib/internal/manual-validation.ts +17 -8
- package/lib/internal/task-artifact.ts +1 -1
- package/lib/internal/task-lifecycle.ts +4 -3
- package/lib/internal/task-operation-registry.ts +0 -1
- package/lib/internal/task-review.ts +4 -4
- package/lib/platform/issue-comments.ts +18 -10
- package/lib/platform/issues.ts +1 -1
- package/lib/platform/pr-change-report.ts +7 -5
- package/lib/platform/pr-summary.ts +64 -32
- package/lib/platform/pull-requests.ts +3 -3
- package/lib/platform/task-identities.ts +3 -14
- package/lib/platform/verification-sync.ts +6 -2
- package/lib/pr-review/evidence-grading.ts +4 -9
- package/lib/sandbox/commands/rm.ts +34 -2
- package/lib/sandbox/control/protocol.ts +5 -0
- package/lib/sandbox/control/server.ts +30 -10
- package/lib/sandbox/control/state.ts +9 -1
- package/lib/sandbox/control/workflow-executor.ts +4 -14
- package/lib/sandbox/index.ts +1 -2
- package/lib/sandbox/removal.ts +126 -1128
- package/lib/sandbox/workspace-identity.ts +19 -3
- package/lib/task/artifact-command.ts +8 -27
- package/lib/task/artifact-operations.ts +21 -251
- package/lib/task/artifact-recovery.ts +519 -0
- package/lib/task/artifact-repair-intent.ts +99 -51
- package/lib/task/commit-operation.ts +35 -4
- package/lib/task/control-authority.ts +47 -14
- package/lib/task/control-recovery.ts +13 -1
- package/lib/task/create-service.ts +1 -10
- package/lib/task/delegation-receipts.ts +73 -2
- package/lib/task/events.ts +187 -23
- package/lib/task/human-override.ts +5 -8
- package/lib/task/intermediate-cleanup.ts +50 -12
- package/lib/task/lifecycle-recovery.ts +691 -0
- package/lib/task/lifecycle.ts +13 -4
- package/lib/task/local-artifact-finalization.ts +176 -132
- package/lib/task/manual-validation-receipt.ts +5 -1
- package/lib/task/orchestration.ts +65 -0
- package/lib/task/pr-delivery-fact.ts +15 -62
- package/lib/task/recovery-warning.ts +21 -0
- package/lib/task/review-command.ts +1 -0
- package/lib/task/review-finalization.ts +106 -87
- package/lib/task/short-id.ts +27 -5
- package/lib/task/task-execution-lock.ts +1 -1
- package/lib/task/verification-engine.ts +3 -17
- package/lib/task/verification-types.ts +0 -2
- package/lib/task/workflow-command.ts +0 -1
- package/lib/version.ts +1 -15
- package/package.json +1 -1
- package/templates/.agents/rules/create-issue.en.md +1 -1
- package/templates/.agents/rules/create-issue.zh-CN.md +1 -1
- package/templates/.agents/rules/issue-sync.en.md +4 -2
- package/templates/.agents/rules/issue-sync.zh-CN.md +4 -3
- package/templates/.agents/rules/local-artifact-repair.en.md +46 -39
- package/templates/.agents/rules/local-artifact-repair.zh-CN.md +43 -36
- package/templates/.agents/skills/analyze-task/SKILL.en.md +6 -6
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +6 -6
- package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/block-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/block-task/config/verify.json +1 -1
- package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +7 -7
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +7 -7
- package/templates/.agents/skills/code-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +1 -1
- package/templates/.agents/skills/commit/reference/issue-metadata-sync.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 -2
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
- package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
- package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +2 -2
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/create-pr/config/verify.json +1 -1
- package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +2 -2
- package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +2 -2
- package/templates/.agents/skills/create-task/SKILL.en.md +5 -5
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
- package/templates/.agents/skills/create-task/config/verify.json +1 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +8 -8
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +8 -8
- package/templates/.agents/skills/import-issue/config/verify.json +2 -2
- package/templates/.agents/skills/plan-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
- package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/restore-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/restore-task/config/verify.json +1 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -7
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +6 -6
- package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-code/reference/report-template.en.md +12 -12
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +12 -12
- package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -7
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -7
- package/templates/.agents/skills/review-pr/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-pr/reference/evidence-grading.en.md +3 -3
- package/templates/.agents/skills/review-pr/reference/evidence-grading.zh-CN.md +3 -3
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +1 -1
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +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/templates/task.en.md +1 -1
- package/templates/.agents/templates/task.zh-CN.md +1 -1
- package/templates/.claude/commands/import-issue.en.md +1 -1
- package/templates/.claude/commands/import-issue.zh-CN.md +1 -1
- package/templates/.claude/commands/restore-task.en.md +1 -1
- package/templates/.claude/commands/restore-task.zh-CN.md +1 -1
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { writeDurableFile } from '../fs/durable-write.js';
|
|
5
|
+
import { readStableFileSync } from '../host-control/secure-fs.js';
|
|
6
|
+
import { parseArtifactName } from './artifact-name.js';
|
|
7
|
+
import { canonicalSemanticDigest, sha256Content } from './artifact-operations.js';
|
|
8
|
+
import { artifactRecoveryRoot, readArtifactRecoveryIntent, writeArtifactRecoveryIntent } from './artifact-repair-intent.js';
|
|
9
|
+
import { withTaskExecutionLock } from './task-execution-lock.js';
|
|
10
|
+
const MAX_ARTIFACT_BYTES = 1024 * 1024;
|
|
11
|
+
export class ArtifactRecoveryError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(`${code}: ${message}`);
|
|
15
|
+
this.name = 'ArtifactRecoveryError';
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function fail(code, message) {
|
|
20
|
+
throw new ArtifactRecoveryError(code, message);
|
|
21
|
+
}
|
|
22
|
+
function runLocked(context, owner, callback, lockAlreadyHeld = false) {
|
|
23
|
+
return lockAlreadyHeld ? callback() : withTaskExecutionLock(context.repoRoot, context.taskId, owner, callback);
|
|
24
|
+
}
|
|
25
|
+
function assertTaskId(taskId) {
|
|
26
|
+
if (!/^TASK-\d{8}-\d{6}$/u.test(taskId))
|
|
27
|
+
fail('ARTIFACT_RECOVERY_IDENTITY_INVALID', 'task id is invalid');
|
|
28
|
+
}
|
|
29
|
+
function assertDigest(value, name) {
|
|
30
|
+
if (!/^[a-f0-9]{64}$/u.test(value))
|
|
31
|
+
fail('ARTIFACT_RECOVERY_DIGEST_INVALID', `${name} must be a lowercase SHA-256 digest`);
|
|
32
|
+
}
|
|
33
|
+
function assertRecoveryId(value) {
|
|
34
|
+
if (!/^[a-f0-9-]{16,64}$/u.test(value))
|
|
35
|
+
fail('ARTIFACT_RECOVERY_IDENTITY_INVALID', 'recovery id is invalid');
|
|
36
|
+
}
|
|
37
|
+
function assertTuple(tuple) {
|
|
38
|
+
assertTaskId(tuple.taskId);
|
|
39
|
+
if (path.basename(tuple.artifact) !== tuple.artifact || !tuple.artifact.endsWith('.md')) {
|
|
40
|
+
fail('ARTIFACT_RECOVERY_TARGET_INVALID', 'artifact must be a canonical top-level Markdown file');
|
|
41
|
+
}
|
|
42
|
+
const parsed = parseArtifactName(tuple.artifact);
|
|
43
|
+
if (!parsed || parsed.family !== tuple.family || parsed.round !== tuple.round) {
|
|
44
|
+
fail('ARTIFACT_RECOVERY_IDENTITY_INVALID', 'artifact tuple does not match its canonical family and round');
|
|
45
|
+
}
|
|
46
|
+
if (!tuple.requestId || /[\r\n]/u.test(tuple.requestId))
|
|
47
|
+
fail('ARTIFACT_RECOVERY_IDENTITY_INVALID', 'request id is invalid');
|
|
48
|
+
if (!Number.isSafeInteger(tuple.round) || tuple.round < 1)
|
|
49
|
+
fail('ARTIFACT_RECOVERY_IDENTITY_INVALID', 'round is invalid');
|
|
50
|
+
}
|
|
51
|
+
function assertDirectory(directory, label) {
|
|
52
|
+
let stat;
|
|
53
|
+
try {
|
|
54
|
+
stat = fs.lstatSync(directory);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', `${label} is unavailable: ${String(error)}`);
|
|
58
|
+
}
|
|
59
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
60
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', `${label} must be a real directory`);
|
|
61
|
+
}
|
|
62
|
+
function assertRegular(file, label) {
|
|
63
|
+
let stat;
|
|
64
|
+
try {
|
|
65
|
+
stat = fs.lstatSync(file);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', `${label} is unavailable: ${String(error)}`);
|
|
69
|
+
}
|
|
70
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
71
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', `${label} must be a regular file`);
|
|
72
|
+
return stat;
|
|
73
|
+
}
|
|
74
|
+
function ensureOwnedDirectoryTree(rootInput, targetInput) {
|
|
75
|
+
const root = path.resolve(rootInput);
|
|
76
|
+
const target = path.resolve(targetInput);
|
|
77
|
+
const relative = path.relative(root, target);
|
|
78
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
79
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', 'recovery staging must remain inside the repository');
|
|
80
|
+
}
|
|
81
|
+
assertDirectory(root, 'repository root');
|
|
82
|
+
let current = root;
|
|
83
|
+
for (const segment of relative.split(path.sep)) {
|
|
84
|
+
current = path.join(current, segment);
|
|
85
|
+
let stat;
|
|
86
|
+
try {
|
|
87
|
+
stat = fs.lstatSync(current);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code !== 'ENOENT') {
|
|
91
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', `recovery directory is unavailable: ${String(error)}`);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
fs.mkdirSync(current, { mode: 0o700 });
|
|
95
|
+
}
|
|
96
|
+
catch (mkdirError) {
|
|
97
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', `recovery directory could not be created: ${String(mkdirError)}`);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
stat = fs.lstatSync(current);
|
|
101
|
+
}
|
|
102
|
+
catch (lstatError) {
|
|
103
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', `recovery directory could not be verified: ${String(lstatError)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
107
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', 'recovery directory must be a real directory');
|
|
108
|
+
if (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
109
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', 'recovery directory is not owned by the current user');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function assertRecoveryRoot(context) {
|
|
114
|
+
ensureOwnedDirectoryTree(context.repoRoot, path.dirname(context.stagingPath));
|
|
115
|
+
}
|
|
116
|
+
function unlinkIfPresent(file) {
|
|
117
|
+
try {
|
|
118
|
+
fs.unlinkSync(file);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code !== 'ENOENT')
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function writeBytes(target, bytes) {
|
|
126
|
+
if (bytes.length > MAX_ARTIFACT_BYTES)
|
|
127
|
+
fail('ARTIFACT_RECOVERY_SIZE_LIMIT', 'candidate exceeds the bounded artifact size limit');
|
|
128
|
+
writeDurableFile(target, bytes.toString('utf8'), { mode: 0o600, replace: true });
|
|
129
|
+
}
|
|
130
|
+
function contextPaths(tuple, options, recoveryId) {
|
|
131
|
+
const taskDir = path.resolve(options.taskDir);
|
|
132
|
+
const root = artifactRecoveryRoot(taskDir, recoveryId);
|
|
133
|
+
return {
|
|
134
|
+
...tuple,
|
|
135
|
+
repoRoot: options.repoRoot,
|
|
136
|
+
taskDir,
|
|
137
|
+
recoveryId,
|
|
138
|
+
stagingId: recoveryId,
|
|
139
|
+
formalPath: path.join(taskDir, tuple.artifact),
|
|
140
|
+
stagingPath: path.join(root, 'candidate.md'),
|
|
141
|
+
baselinePath: path.join(root, 'baseline.md'),
|
|
142
|
+
finalPath: path.join(root, 'final.md'),
|
|
143
|
+
baselineSha256: '',
|
|
144
|
+
baselineSemanticDigest: ''
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function readIntentForContext(context) {
|
|
148
|
+
const intent = readArtifactRecoveryIntent(context.repoRoot, context.taskId, context.family, context.artifact);
|
|
149
|
+
if (!intent)
|
|
150
|
+
fail('ARTIFACT_RECOVERY_INTENT_MISSING', 'recovery journal is missing');
|
|
151
|
+
if (intent.recoveryOperationId !== context.recoveryId
|
|
152
|
+
|| intent.stagingId !== context.stagingId
|
|
153
|
+
|| intent.round !== context.round
|
|
154
|
+
|| intent.requestId !== context.requestId) {
|
|
155
|
+
fail('ARTIFACT_RECOVERY_IDENTITY_INVALID', 'recovery context does not match the journal');
|
|
156
|
+
}
|
|
157
|
+
return intent;
|
|
158
|
+
}
|
|
159
|
+
function contextWithIntent(context, intent) {
|
|
160
|
+
return {
|
|
161
|
+
...context,
|
|
162
|
+
phase: intent.phase,
|
|
163
|
+
authorityDigest: intent.authorityDigest,
|
|
164
|
+
baselineSha256: intent.baselineSha256,
|
|
165
|
+
baselineSemanticDigest: intent.baselineSemanticDigest
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function createIntent(context, now) {
|
|
169
|
+
return {
|
|
170
|
+
version: 3,
|
|
171
|
+
taskId: context.taskId,
|
|
172
|
+
family: context.family,
|
|
173
|
+
artifact: context.artifact,
|
|
174
|
+
round: context.round,
|
|
175
|
+
state: 'awaiting-recovery',
|
|
176
|
+
baselineSha256: context.baselineSha256,
|
|
177
|
+
baselineSemanticDigest: context.baselineSemanticDigest,
|
|
178
|
+
stagingId: context.stagingId,
|
|
179
|
+
candidateSha256: context.baselineSha256,
|
|
180
|
+
finalArtifactSha256: null,
|
|
181
|
+
finalSemanticDigest: null,
|
|
182
|
+
recoveryOperationId: context.recoveryId,
|
|
183
|
+
phase: context.phase ?? null,
|
|
184
|
+
authorityDigest: context.authorityDigest ?? null,
|
|
185
|
+
requestId: context.requestId,
|
|
186
|
+
errorCode: null,
|
|
187
|
+
errorMessage: null,
|
|
188
|
+
createdAt: now,
|
|
189
|
+
updatedAt: now
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export function beginArtifactRecovery(tuple, baselineBytes, options) {
|
|
193
|
+
assertTuple(tuple);
|
|
194
|
+
assertDirectory(options.taskDir, 'task directory');
|
|
195
|
+
const context = contextPaths(tuple, options, options.recoveryId ?? randomUUID());
|
|
196
|
+
assertRecoveryId(context.recoveryId);
|
|
197
|
+
const targetStat = assertRegular(context.formalPath, 'formal artifact');
|
|
198
|
+
const rootParent = path.dirname(context.stagingPath);
|
|
199
|
+
ensureOwnedDirectoryTree(options.repoRoot, rootParent);
|
|
200
|
+
const stagingStat = fs.lstatSync(rootParent);
|
|
201
|
+
if (stagingStat.dev !== targetStat.dev)
|
|
202
|
+
fail('ARTIFACT_RECOVERY_PATH_INVALID', 'recovery staging must share the formal artifact file system');
|
|
203
|
+
const baselineSha256 = sha256Content(baselineBytes.toString('utf8'));
|
|
204
|
+
const prepared = { ...context, baselineSha256, baselineSemanticDigest: canonicalSemanticDigest(baselineBytes.toString('utf8')) };
|
|
205
|
+
return runLocked(prepared, 'task-artifact.recovery.begin', () => {
|
|
206
|
+
assertRecoveryRoot(prepared);
|
|
207
|
+
const current = readStableFileSync(prepared.formalPath, { maxBytes: MAX_ARTIFACT_BYTES }).bytes;
|
|
208
|
+
if (sha256Content(current.toString('utf8')) !== baselineSha256 || !current.equals(baselineBytes)) {
|
|
209
|
+
fail('ARTIFACT_RECOVERY_BASELINE_MISMATCH', 'formal artifact does not match the recovery baseline');
|
|
210
|
+
}
|
|
211
|
+
const existing = readArtifactRecoveryIntent(prepared.repoRoot, prepared.taskId, prepared.family, prepared.artifact);
|
|
212
|
+
if (existing && existing.state !== 'aborted' && existing.state !== 'consumed') {
|
|
213
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
|
|
214
|
+
}
|
|
215
|
+
writeBytes(prepared.baselinePath, baselineBytes);
|
|
216
|
+
writeBytes(prepared.stagingPath, baselineBytes);
|
|
217
|
+
writeArtifactRecoveryIntent(prepared.repoRoot, createIntent(prepared, Date.now()), { expected: existing });
|
|
218
|
+
return prepared;
|
|
219
|
+
}, options.lockAlreadyHeld);
|
|
220
|
+
}
|
|
221
|
+
export function recordArtifactRecoveryPassed(tuple, options) {
|
|
222
|
+
assertTuple(tuple);
|
|
223
|
+
assertDirectory(options.taskDir, 'task directory');
|
|
224
|
+
const context = contextPaths(tuple, options, options.recoveryId ?? randomUUID());
|
|
225
|
+
assertRecoveryId(context.recoveryId);
|
|
226
|
+
assertRegular(context.formalPath, 'formal artifact');
|
|
227
|
+
return runLocked(context, 'task-artifact.recovery.record-passed', () => {
|
|
228
|
+
const current = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
229
|
+
const prepared = {
|
|
230
|
+
...context,
|
|
231
|
+
baselineSha256: current.sha256,
|
|
232
|
+
baselineSemanticDigest: canonicalSemanticDigest(current.bytes.toString('utf8'))
|
|
233
|
+
};
|
|
234
|
+
if ((options.expectedFinalSha256 !== undefined) !== (options.expectedFinalSemanticDigest !== undefined)) {
|
|
235
|
+
fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'fast-path provenance requires both finalizer digests');
|
|
236
|
+
}
|
|
237
|
+
if (options.expectedFinalSha256 !== undefined && options.expectedFinalSemanticDigest !== undefined
|
|
238
|
+
&& (current.sha256 !== options.expectedFinalSha256 || prepared.baselineSemanticDigest !== options.expectedFinalSemanticDigest)) {
|
|
239
|
+
fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'formal artifact does not match the finalizer digests');
|
|
240
|
+
}
|
|
241
|
+
const existing = readArtifactRecoveryIntent(prepared.repoRoot, prepared.taskId, prepared.family, prepared.artifact);
|
|
242
|
+
if (existing && (existing.state === 'passed' || existing.state === 'consumed')
|
|
243
|
+
&& existing.finalArtifactSha256 === prepared.baselineSha256
|
|
244
|
+
&& existing.finalSemanticDigest === prepared.baselineSemanticDigest) {
|
|
245
|
+
return recoveryContextFromIntent(prepared.repoRoot, prepared.taskDir, existing);
|
|
246
|
+
}
|
|
247
|
+
if (existing && existing.state !== 'aborted') {
|
|
248
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
|
|
249
|
+
}
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
const next = {
|
|
252
|
+
...createIntent(prepared, now),
|
|
253
|
+
state: 'passed',
|
|
254
|
+
candidateSha256: prepared.baselineSha256,
|
|
255
|
+
finalArtifactSha256: prepared.baselineSha256,
|
|
256
|
+
finalSemanticDigest: prepared.baselineSemanticDigest
|
|
257
|
+
};
|
|
258
|
+
writeArtifactRecoveryIntent(prepared.repoRoot, next, { expected: existing });
|
|
259
|
+
return prepared;
|
|
260
|
+
}, options.lockAlreadyHeld);
|
|
261
|
+
}
|
|
262
|
+
export function stageArtifactCandidate(context, candidateBytes, options = {}) {
|
|
263
|
+
return runLocked(context, 'task-artifact.recovery.stage', () => {
|
|
264
|
+
assertRecoveryRoot(context);
|
|
265
|
+
const intent = readIntentForContext(context);
|
|
266
|
+
if (intent.state !== 'awaiting-recovery')
|
|
267
|
+
fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot stage a candidate from '${intent.state}'`);
|
|
268
|
+
const candidateSha256 = sha256Content(candidateBytes.toString('utf8'));
|
|
269
|
+
const semanticDigest = canonicalSemanticDigest(candidateBytes.toString('utf8'));
|
|
270
|
+
writeBytes(context.stagingPath, candidateBytes);
|
|
271
|
+
const next = { ...intent, candidateSha256, updatedAt: Date.now() };
|
|
272
|
+
writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
|
|
273
|
+
return { recoveryId: context.recoveryId, candidateSha256, semanticDigest, path: context.stagingPath };
|
|
274
|
+
}, options.lockAlreadyHeld);
|
|
275
|
+
}
|
|
276
|
+
export function prepareArtifactRecoveryCommit(context, finalSha256, finalSemanticDigest, options = {}) {
|
|
277
|
+
assertDigest(finalSha256, 'final artifact digest');
|
|
278
|
+
assertDigest(finalSemanticDigest, 'final semantic digest');
|
|
279
|
+
return runLocked(context, 'task-artifact.recovery.prepare', () => {
|
|
280
|
+
const intent = readIntentForContext(context);
|
|
281
|
+
if (intent.state !== 'awaiting-recovery')
|
|
282
|
+
fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a commit from '${intent.state}'`);
|
|
283
|
+
assertRecoveryRoot(context);
|
|
284
|
+
const staged = readStableFileSync(context.stagingPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
285
|
+
if (staged.sha256 !== finalSha256 || canonicalSemanticDigest(staged.bytes.toString('utf8')) !== finalSemanticDigest) {
|
|
286
|
+
fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'staged candidate does not match the finalizer digest');
|
|
287
|
+
}
|
|
288
|
+
writeBytes(context.finalPath, staged.bytes);
|
|
289
|
+
fs.chmodSync(context.finalPath, 0o400);
|
|
290
|
+
const sealed = readStableFileSync(context.finalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
291
|
+
if (sealed.sha256 !== finalSha256 || canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== finalSemanticDigest) {
|
|
292
|
+
fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'sealed candidate does not match the finalizer digest');
|
|
293
|
+
}
|
|
294
|
+
const next = {
|
|
295
|
+
...intent,
|
|
296
|
+
state: 'finalize-ready',
|
|
297
|
+
candidateSha256: staged.sha256,
|
|
298
|
+
finalArtifactSha256: finalSha256,
|
|
299
|
+
finalSemanticDigest,
|
|
300
|
+
errorCode: null,
|
|
301
|
+
errorMessage: null,
|
|
302
|
+
updatedAt: Date.now()
|
|
303
|
+
};
|
|
304
|
+
writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
|
|
305
|
+
return next;
|
|
306
|
+
}, options.lockAlreadyHeld);
|
|
307
|
+
}
|
|
308
|
+
function markCommitStarted(context, intent) {
|
|
309
|
+
if (intent.state === 'commit-started')
|
|
310
|
+
return intent;
|
|
311
|
+
if (intent.state !== 'finalize-ready')
|
|
312
|
+
fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot commit from '${intent.state}'`);
|
|
313
|
+
const next = { ...intent, state: 'commit-started', updatedAt: Date.now() };
|
|
314
|
+
writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
|
|
315
|
+
return next;
|
|
316
|
+
}
|
|
317
|
+
export function commitArtifactRecovery(context, options = {}) {
|
|
318
|
+
return runLocked(context, 'task-artifact.recovery.commit', () => {
|
|
319
|
+
let intent = readIntentForContext(context);
|
|
320
|
+
if (intent.state === 'passed' || intent.state === 'consumed')
|
|
321
|
+
return intent;
|
|
322
|
+
assertRecoveryRoot(context);
|
|
323
|
+
intent = markCommitStarted(context, intent);
|
|
324
|
+
const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
325
|
+
if (target.sha256 === intent.finalArtifactSha256) {
|
|
326
|
+
const passed = { ...intent, state: 'passed', updatedAt: Date.now() };
|
|
327
|
+
writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
|
|
328
|
+
return passed;
|
|
329
|
+
}
|
|
330
|
+
if (target.sha256 !== intent.baselineSha256)
|
|
331
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact changed outside the recovery baseline');
|
|
332
|
+
const sealed = readStableFileSync(context.finalPath, {
|
|
333
|
+
maxBytes: MAX_ARTIFACT_BYTES,
|
|
334
|
+
expectedSha256: intent.finalArtifactSha256
|
|
335
|
+
});
|
|
336
|
+
if (canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== intent.finalSemanticDigest) {
|
|
337
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'sealed artifact does not match the staged final semantic digest');
|
|
338
|
+
}
|
|
339
|
+
// Publish the bytes that were validated above. The sealed path is only a
|
|
340
|
+
// recovery input; renaming it directly would reopen a same-uid path swap
|
|
341
|
+
// between validation and publication.
|
|
342
|
+
const publishPath = path.join(path.dirname(context.finalPath), 'publish.md');
|
|
343
|
+
unlinkIfPresent(publishPath);
|
|
344
|
+
writeBytes(publishPath, sealed.bytes);
|
|
345
|
+
fs.chmodSync(publishPath, 0o400);
|
|
346
|
+
const publish = readStableFileSync(publishPath, {
|
|
347
|
+
maxBytes: MAX_ARTIFACT_BYTES,
|
|
348
|
+
expectedSha256: intent.finalArtifactSha256
|
|
349
|
+
});
|
|
350
|
+
if (canonicalSemanticDigest(publish.bytes.toString('utf8')) !== intent.finalSemanticDigest) {
|
|
351
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'publish artifact does not match the staged final semantic digest');
|
|
352
|
+
}
|
|
353
|
+
fs.renameSync(publishPath, context.formalPath);
|
|
354
|
+
const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
355
|
+
if (published.sha256 !== intent.finalArtifactSha256)
|
|
356
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact does not match the staged final digest');
|
|
357
|
+
const passed = { ...intent, state: 'passed', updatedAt: Date.now() };
|
|
358
|
+
writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
|
|
359
|
+
return passed;
|
|
360
|
+
}, options.lockAlreadyHeld);
|
|
361
|
+
}
|
|
362
|
+
export function abortArtifactRecovery(context, code, message, options = {}) {
|
|
363
|
+
return runLocked(context, 'task-artifact.recovery.abort', () => {
|
|
364
|
+
const intent = readIntentForContext(context);
|
|
365
|
+
assertRegular(context.baselinePath, 'baseline backup');
|
|
366
|
+
const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
367
|
+
if (target.sha256 !== intent.baselineSha256)
|
|
368
|
+
fs.renameSync(context.baselinePath, context.formalPath);
|
|
369
|
+
const restored = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
370
|
+
if (restored.sha256 !== intent.baselineSha256)
|
|
371
|
+
fail('ARTIFACT_RECOVERY_INDETERMINATE', 'baseline restore could not be verified');
|
|
372
|
+
const aborted = {
|
|
373
|
+
...intent,
|
|
374
|
+
state: 'aborted',
|
|
375
|
+
errorCode: code,
|
|
376
|
+
errorMessage: message,
|
|
377
|
+
updatedAt: Date.now()
|
|
378
|
+
};
|
|
379
|
+
writeArtifactRecoveryIntent(context.repoRoot, aborted, { expected: intent });
|
|
380
|
+
return aborted;
|
|
381
|
+
}, options.lockAlreadyHeld);
|
|
382
|
+
}
|
|
383
|
+
export function reconcileArtifactRecovery(context, options = {}) {
|
|
384
|
+
return runLocked(context, 'task-artifact.recovery.reconcile', () => {
|
|
385
|
+
const intent = readIntentForContext(context);
|
|
386
|
+
if (intent.state !== 'commit-started')
|
|
387
|
+
return { status: intent.state, intent };
|
|
388
|
+
const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
389
|
+
if (target.sha256 === intent.finalArtifactSha256 && (options.validateFinal?.() ?? true)) {
|
|
390
|
+
const passed = { ...intent, state: 'passed', updatedAt: Date.now() };
|
|
391
|
+
writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
|
|
392
|
+
return { status: 'passed', intent: passed };
|
|
393
|
+
}
|
|
394
|
+
if (target.sha256 === intent.baselineSha256) {
|
|
395
|
+
const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true });
|
|
396
|
+
return { status: retried.state, intent: retried };
|
|
397
|
+
}
|
|
398
|
+
if (options.restoreBaseline) {
|
|
399
|
+
const aborted = abortArtifactRecovery(context, 'ARTIFACT_RECOVERY_CONTEXT_INVALID', 'formal target is not a verifiable baseline or final candidate', { lockAlreadyHeld: true });
|
|
400
|
+
return { status: 'aborted', intent: aborted };
|
|
401
|
+
}
|
|
402
|
+
return { status: 'indeterminate', intent };
|
|
403
|
+
}, options.lockAlreadyHeld);
|
|
404
|
+
}
|
|
405
|
+
export function consumeArtifactRecovery(context, options = {}) {
|
|
406
|
+
return runLocked(context, 'task-artifact.recovery.consume', () => {
|
|
407
|
+
const intent = readIntentForContext(context);
|
|
408
|
+
if (intent.state === 'consumed')
|
|
409
|
+
return intent;
|
|
410
|
+
if (intent.state !== 'passed')
|
|
411
|
+
fail('ARTIFACT_RECOVERY_STATE_INVALID', `only passed recovery can be consumed, got '${intent.state}'`);
|
|
412
|
+
const consumed = { ...intent, state: 'consumed', updatedAt: Date.now() };
|
|
413
|
+
writeArtifactRecoveryIntent(context.repoRoot, consumed, { expected: intent });
|
|
414
|
+
return consumed;
|
|
415
|
+
}, options.lockAlreadyHeld);
|
|
416
|
+
}
|
|
417
|
+
export function recoveryContextFromIntent(repoRoot, taskDir, intent) {
|
|
418
|
+
const context = contextPaths({
|
|
419
|
+
taskId: intent.taskId,
|
|
420
|
+
family: intent.family,
|
|
421
|
+
artifact: intent.artifact,
|
|
422
|
+
round: intent.round,
|
|
423
|
+
requestId: intent.requestId,
|
|
424
|
+
phase: intent.phase,
|
|
425
|
+
authorityDigest: intent.authorityDigest
|
|
426
|
+
}, { repoRoot, taskDir, recoveryId: intent.recoveryOperationId }, intent.recoveryOperationId);
|
|
427
|
+
return contextWithIntent(context, intent);
|
|
428
|
+
}
|
|
429
|
+
export { canonicalSemanticDigest, readArtifactRecoveryIntent, sha256Content };
|
|
430
|
+
//# sourceMappingURL=artifact-recovery.js.map
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
-
|
|
4
|
+
import { parseArtifactName } from './artifact-name.js';
|
|
5
|
+
export function artifactRecoveryIntentRoot(repoRoot) {
|
|
5
6
|
return path.join(repoRoot, '.agents', 'workspace', '.local-artifact-finalization-intents');
|
|
6
7
|
}
|
|
8
|
+
export function artifactRecoveryRoot(taskDir, stagingId) {
|
|
9
|
+
return path.join(taskDir, '.local-artifact-recovery', stagingId);
|
|
10
|
+
}
|
|
7
11
|
function intentPath(repoRoot, taskId, family, artifact) {
|
|
8
|
-
return path.join(
|
|
12
|
+
return path.join(artifactRecoveryIntentRoot(repoRoot), `${taskId}-${family}-${artifact}.json`);
|
|
9
13
|
}
|
|
10
14
|
function validDigest(value) {
|
|
11
15
|
return typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value);
|
|
@@ -13,35 +17,65 @@ function validDigest(value) {
|
|
|
13
17
|
function validTaskId(value) {
|
|
14
18
|
return typeof value === 'string' && /^TASK-\d{8}-\d{6}$/u.test(value);
|
|
15
19
|
}
|
|
20
|
+
function validToken(value) {
|
|
21
|
+
return typeof value === 'string' && value.length > 0 && !/[\r\n]/u.test(value);
|
|
22
|
+
}
|
|
23
|
+
function validRecoveryId(value) {
|
|
24
|
+
return typeof value === 'string' && /^[a-f0-9-]{16,64}$/u.test(value);
|
|
25
|
+
}
|
|
26
|
+
function validArtifact(value, family, round) {
|
|
27
|
+
if (typeof value !== 'string' || path.basename(value) !== value || !value.endsWith('.md'))
|
|
28
|
+
return false;
|
|
29
|
+
const parsed = parseArtifactName(value);
|
|
30
|
+
return Boolean(parsed && parsed.family === family && parsed.round === round);
|
|
31
|
+
}
|
|
16
32
|
function exactKeys(value, keys) {
|
|
17
33
|
return Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
|
18
34
|
}
|
|
19
|
-
export function
|
|
35
|
+
export function isArtifactRecoveryIntent(value) {
|
|
20
36
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
21
37
|
return false;
|
|
22
38
|
const intent = value;
|
|
23
39
|
return exactKeys(intent, [
|
|
24
|
-
'artifact', '
|
|
25
|
-
'
|
|
26
|
-
'
|
|
40
|
+
'artifact', 'authorityDigest', 'baselineSemanticDigest', 'baselineSha256', 'candidateSha256',
|
|
41
|
+
'createdAt', 'errorCode', 'errorMessage', 'family', 'finalArtifactSha256', 'finalSemanticDigest',
|
|
42
|
+
'phase', 'recoveryOperationId', 'requestId', 'round', 'stagingId', 'state', 'taskId', 'updatedAt',
|
|
43
|
+
'version'
|
|
27
44
|
])
|
|
28
|
-
&& intent.version ===
|
|
45
|
+
&& intent.version === 3
|
|
29
46
|
&& validTaskId(intent.taskId)
|
|
30
47
|
&& typeof intent.family === 'string'
|
|
31
48
|
&& ['analysis', 'review-analysis', 'plan', 'review-plan', 'code', 'review-code'].includes(intent.family)
|
|
32
|
-
&&
|
|
33
|
-
&&
|
|
34
|
-
&&
|
|
35
|
-
&& validDigest(intent.
|
|
36
|
-
&& validDigest(intent.
|
|
37
|
-
&& (intent.
|
|
49
|
+
&& Number.isSafeInteger(intent.round) && intent.round > 0
|
|
50
|
+
&& validArtifact(intent.artifact, intent.family, intent.round)
|
|
51
|
+
&& ['awaiting-recovery', 'finalize-ready', 'commit-started', 'passed', 'consumed', 'aborted'].includes(String(intent.state))
|
|
52
|
+
&& validDigest(intent.baselineSha256)
|
|
53
|
+
&& validDigest(intent.baselineSemanticDigest)
|
|
54
|
+
&& validRecoveryId(intent.stagingId)
|
|
55
|
+
&& (intent.candidateSha256 === null || validDigest(intent.candidateSha256))
|
|
56
|
+
&& (intent.finalArtifactSha256 === null || validDigest(intent.finalArtifactSha256))
|
|
57
|
+
&& (intent.finalSemanticDigest === null || validDigest(intent.finalSemanticDigest))
|
|
58
|
+
&& validRecoveryId(intent.recoveryOperationId)
|
|
59
|
+
&& intent.stagingId === intent.recoveryOperationId
|
|
38
60
|
&& (intent.phase === null || ['orchestration.prepare', 'artifact.finalize-local', 'task-event.completed'].includes(String(intent.phase)))
|
|
39
61
|
&& (intent.authorityDigest === null || validDigest(intent.authorityDigest))
|
|
40
|
-
&&
|
|
41
|
-
&&
|
|
42
|
-
&& (intent.
|
|
62
|
+
&& validToken(intent.requestId)
|
|
63
|
+
&& (intent.errorCode === null || validToken(intent.errorCode))
|
|
64
|
+
&& (intent.errorMessage === null || validToken(intent.errorMessage))
|
|
65
|
+
&& Number.isSafeInteger(intent.createdAt)
|
|
66
|
+
&& Number.isSafeInteger(intent.updatedAt)
|
|
67
|
+
&& (intent.state === 'awaiting-recovery'
|
|
68
|
+
? intent.finalArtifactSha256 === null && intent.finalSemanticDigest === null
|
|
69
|
+
: true)
|
|
70
|
+
&& (intent.state === 'finalize-ready' || intent.state === 'commit-started' || intent.state === 'passed' || intent.state === 'consumed'
|
|
71
|
+
? validDigest(intent.candidateSha256) && validDigest(intent.finalArtifactSha256) && validDigest(intent.finalSemanticDigest)
|
|
72
|
+
: true)
|
|
73
|
+
&& (intent.state === 'aborted' ? intent.errorCode !== null && intent.errorMessage !== null : true);
|
|
43
74
|
}
|
|
44
|
-
function
|
|
75
|
+
export function readArtifactRecoveryIntent(repoRoot, taskId, family, artifact) {
|
|
76
|
+
if (!validTaskId(taskId) || !validArtifact(artifact, family, parseArtifactName(artifact)?.round)) {
|
|
77
|
+
throw new Error('ARTIFACT_RECOVERY_INTENT_INVALID: recovery journal identity is invalid');
|
|
78
|
+
}
|
|
45
79
|
const target = intentPath(repoRoot, taskId, family, artifact);
|
|
46
80
|
if (!fs.existsSync(target))
|
|
47
81
|
return null;
|
|
@@ -50,19 +84,20 @@ function readArtifactRepairIntent(repoRoot, taskId, family, artifact) {
|
|
|
50
84
|
value = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
51
85
|
}
|
|
52
86
|
catch (error) {
|
|
53
|
-
throw new Error(`
|
|
87
|
+
throw new Error(`ARTIFACT_RECOVERY_INTENT_INVALID: ${String(error)}`);
|
|
54
88
|
}
|
|
55
|
-
if (!
|
|
56
|
-
throw new Error('
|
|
89
|
+
if (!isArtifactRecoveryIntent(value) || value.taskId !== taskId || value.family !== family || value.artifact !== artifact) {
|
|
90
|
+
throw new Error('ARTIFACT_RECOVERY_INTENT_INVALID: recovery journal schema is invalid');
|
|
57
91
|
}
|
|
58
92
|
return value;
|
|
59
93
|
}
|
|
60
|
-
export function
|
|
61
|
-
if (!
|
|
62
|
-
throw new Error('
|
|
63
|
-
const
|
|
94
|
+
export function writeArtifactRecoveryIntent(repoRoot, value, options = {}) {
|
|
95
|
+
if (!isArtifactRecoveryIntent(value))
|
|
96
|
+
throw new Error('ARTIFACT_RECOVERY_INTENT_INVALID: recovery journal schema is invalid');
|
|
97
|
+
const intent = value;
|
|
98
|
+
const directory = artifactRecoveryIntentRoot(repoRoot);
|
|
64
99
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
65
|
-
const target = intentPath(repoRoot,
|
|
100
|
+
const target = intentPath(repoRoot, intent.taskId, intent.family, intent.artifact);
|
|
66
101
|
const lock = `${target}.lock`;
|
|
67
102
|
let descriptor;
|
|
68
103
|
try {
|
|
@@ -70,19 +105,19 @@ export function writeArtifactRepairIntent(repoRoot, value, options = {}) {
|
|
|
70
105
|
}
|
|
71
106
|
catch (error) {
|
|
72
107
|
if (error.code === 'EEXIST') {
|
|
73
|
-
throw new Error('
|
|
108
|
+
throw new Error('ARTIFACT_RECOVERY_INTENT_CONFLICT: another journal writer owns the intent');
|
|
74
109
|
}
|
|
75
110
|
throw error;
|
|
76
111
|
}
|
|
77
112
|
try {
|
|
78
113
|
const current = fs.existsSync(target)
|
|
79
|
-
?
|
|
114
|
+
? readArtifactRecoveryIntent(repoRoot, intent.taskId, intent.family, intent.artifact)
|
|
80
115
|
: null;
|
|
81
116
|
if (!isDeepStrictEqual(current, options.expected ?? null)) {
|
|
82
|
-
throw new Error('
|
|
117
|
+
throw new Error('ARTIFACT_RECOVERY_INTENT_CONFLICT: recovery journal changed during finalization');
|
|
83
118
|
}
|
|
84
119
|
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
85
|
-
fs.writeFileSync(temporary, `${JSON.stringify(
|
|
120
|
+
fs.writeFileSync(temporary, `${JSON.stringify(intent)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
86
121
|
try {
|
|
87
122
|
fs.renameSync(temporary, target);
|
|
88
123
|
}
|
|
@@ -90,7 +125,7 @@ export function writeArtifactRepairIntent(repoRoot, value, options = {}) {
|
|
|
90
125
|
try {
|
|
91
126
|
fs.unlinkSync(temporary);
|
|
92
127
|
}
|
|
93
|
-
catch { /* preserve primary error */ }
|
|
128
|
+
catch { /* preserve the primary error */ }
|
|
94
129
|
throw error;
|
|
95
130
|
}
|
|
96
131
|
}
|
|
@@ -102,11 +137,10 @@ export function writeArtifactRepairIntent(repoRoot, value, options = {}) {
|
|
|
102
137
|
catch { /* preserve the primary result */ }
|
|
103
138
|
}
|
|
104
139
|
}
|
|
105
|
-
export function
|
|
106
|
-
if (!
|
|
107
|
-
throw new Error('
|
|
108
|
-
|
|
109
|
-
const directory = intentRoot(repoRoot);
|
|
140
|
+
export function findArtifactRecoveryIntentsByOperation(repoRoot, operationId) {
|
|
141
|
+
if (!validToken(operationId))
|
|
142
|
+
throw new Error('ARTIFACT_RECOVERY_OPERATION_INVALID: recovery operation id is invalid');
|
|
143
|
+
const directory = artifactRecoveryIntentRoot(repoRoot);
|
|
110
144
|
if (!fs.existsSync(directory))
|
|
111
145
|
return Object.freeze([]);
|
|
112
146
|
return Object.freeze(fs.readdirSync(directory)
|
|
@@ -118,13 +152,13 @@ export function findArtifactRepairIntentsByOperation(repoRoot, operationId) {
|
|
|
118
152
|
value = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
119
153
|
}
|
|
120
154
|
catch (error) {
|
|
121
|
-
throw new Error(`
|
|
155
|
+
throw new Error(`ARTIFACT_RECOVERY_INTENT_INVALID: ${String(error)}`);
|
|
122
156
|
}
|
|
123
|
-
if (!
|
|
124
|
-
throw new Error('
|
|
157
|
+
if (!isArtifactRecoveryIntent(value))
|
|
158
|
+
throw new Error('ARTIFACT_RECOVERY_INTENT_INVALID: recovery journal schema is invalid');
|
|
125
159
|
return value;
|
|
126
160
|
})
|
|
127
161
|
.filter((value) => value.recoveryOperationId === operationId));
|
|
128
162
|
}
|
|
129
|
-
export {
|
|
163
|
+
export { intentPath as artifactRecoveryIntentPath };
|
|
130
164
|
//# sourceMappingURL=artifact-repair-intent.js.map
|