@fitlab-ai/agent-infra 0.10.0 → 0.11.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/README.md +2 -6
- package/README.zh-CN.md +2 -6
- package/bin/internal-cli.sh +0 -13
- package/dist/bin/internal-cli.js +11 -49
- package/dist/lib/internal/cli-route-inventory.js +3 -4
- package/dist/lib/internal/platform-comment.js +60 -2
- package/dist/lib/internal/task-artifact.js +1 -1
- package/dist/lib/internal/task-finalization.js +2 -2
- package/dist/lib/internal/task-lifecycle.js +2 -2
- package/dist/lib/internal/task-operation-registry.js +22 -12
- package/dist/lib/internal/task-orchestration.js +2 -2
- package/dist/lib/internal/task-review.js +10 -5
- package/dist/lib/platform/activity-recovery.js +159 -0
- package/dist/lib/platform/issue-comments.js +84 -32
- package/dist/lib/platform/task-comment-projection.js +70 -0
- package/dist/lib/platform/task-recovery.js +55 -0
- package/dist/lib/platform/verification-sync.js +43 -9
- package/dist/lib/sandbox/control/executor.js +4 -2
- package/dist/lib/sandbox/control/task-workflow.js +1 -1
- package/dist/lib/sandbox/control/workflow-executor.js +83 -21
- package/dist/lib/server/process-state.js +17 -5
- package/dist/lib/task/artifact-command.js +6 -3
- package/dist/lib/task/artifact-operations.js +1 -1
- package/dist/lib/task/artifact-recovery.js +114 -68
- package/dist/lib/task/artifact-repair-intent.js +25 -8
- package/dist/lib/task/control-authority.js +1 -1
- package/dist/lib/task/events.js +6 -6
- package/dist/lib/task/intermediate-cleanup.js +28 -3
- package/dist/lib/task/local-artifact-finalization.js +78 -14
- package/dist/lib/task/review-command.js +2 -2
- package/dist/lib/task/review-finalization.js +203 -10
- package/dist/lib/{host-control → task}/secure-fs.js +8 -19
- package/dist/lib/task/workflow-command.js +2 -0
- package/dist/lib/task/workflow-dispatch.js +161 -0
- package/dist/lifecycle-build-manifest.json +3 -3
- package/lib/internal/cli-route-inventory.ts +3 -4
- package/lib/internal/platform-comment.ts +53 -2
- package/lib/internal/task-artifact.ts +1 -1
- package/lib/internal/task-finalization.ts +1 -1
- package/lib/internal/task-lifecycle.ts +1 -1
- package/lib/internal/task-operation-registry.ts +22 -12
- package/lib/internal/task-orchestration.ts +1 -1
- package/lib/internal/task-review.ts +9 -5
- package/lib/platform/activity-recovery.ts +179 -0
- package/lib/platform/issue-comments.ts +98 -31
- package/lib/platform/task-comment-projection.ts +79 -0
- package/lib/platform/task-recovery.ts +54 -0
- package/lib/platform/verification-sync.ts +46 -13
- package/lib/sandbox/control/executor.ts +4 -2
- package/lib/sandbox/control/protocol.ts +1 -1
- package/lib/sandbox/control/task-workflow.ts +1 -1
- package/lib/sandbox/control/workflow-executor.ts +102 -21
- package/lib/server/process-state.ts +26 -5
- package/lib/task/artifact-command.ts +6 -4
- package/lib/task/artifact-operations.ts +1 -1
- package/lib/task/artifact-recovery.ts +120 -67
- package/lib/task/artifact-repair-intent.ts +38 -11
- package/lib/task/control-authority.ts +1 -1
- package/lib/task/events.ts +6 -6
- package/lib/task/intermediate-cleanup.ts +18 -2
- package/lib/task/local-artifact-finalization.ts +85 -11
- package/lib/task/review-command.ts +1 -1
- package/lib/task/review-finalization.ts +203 -8
- package/lib/{host-control → task}/secure-fs.ts +10 -32
- package/lib/task/workflow-command.ts +2 -0
- package/lib/task/workflow-dispatch.ts +171 -0
- package/package.json +1 -1
- package/templates/.agents/rules/issue-sync.en.md +9 -1
- package/templates/.agents/rules/issue-sync.zh-CN.md +10 -3
- package/templates/.agents/rules/local-artifact-repair.en.md +7 -6
- package/templates/.agents/rules/local-artifact-repair.zh-CN.md +10 -8
- package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/restore-task/SKILL.en.md +9 -4
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +9 -4
- package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/review-code/SKILL.en.md +3 -2
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +3 -2
- package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
- package/dist/lib/host-control/audit.js +0 -19
- package/dist/lib/host-control/client.js +0 -142
- package/dist/lib/host-control/command.js +0 -49
- package/dist/lib/host-control/path.js +0 -160
- package/dist/lib/host-control/server.js +0 -159
- package/dist/lib/host-control/service.js +0 -112
- package/dist/lib/internal/host-control.js +0 -28
- package/lib/host-control/audit.ts +0 -22
- package/lib/host-control/client.ts +0 -172
- package/lib/host-control/command.ts +0 -60
- package/lib/host-control/path.ts +0 -167
- package/lib/host-control/server.ts +0 -180
- package/lib/host-control/service.ts +0 -133
- package/lib/internal/host-control.ts +0 -28
|
@@ -2,10 +2,11 @@ import { TASK_WORKFLOW_COMMANDS } from '../../task/workflow-command.ts';
|
|
|
2
2
|
|
|
3
3
|
import { parseArtifactCommand, executeArtifactCommand } from '../../task/artifact-command.ts';
|
|
4
4
|
import { parseReviewCommand } from '../../task/review-command.ts';
|
|
5
|
-
import { prepareLocalArtifact, commitLocalArtifactProvenance } from '../../task/local-artifact-finalization.ts';
|
|
5
|
+
import { prepareLocalArtifact, preflightLocalArtifact, commitLocalArtifactProvenance } from '../../task/local-artifact-finalization.ts';
|
|
6
6
|
import { prepareReviewSummaryCandidate, commitReviewSummaryProvenance } from '../../task/review-finalization.ts';
|
|
7
|
+
import { readArtifactRecoveryIntent } from '../../task/artifact-repair-intent.ts';
|
|
7
8
|
import { withTaskExecutionLock } from '../../task/task-execution-lock.ts';
|
|
8
|
-
import {
|
|
9
|
+
import { dispatchWorkflowCommand } from '../../task/workflow-dispatch.ts';
|
|
9
10
|
import { assertSandboxTaskSource } from '../workspace-view.ts';
|
|
10
11
|
import { appendDiagnosticAudit } from './audit.ts';
|
|
11
12
|
import {
|
|
@@ -17,6 +18,17 @@ import type { LifecycleRecoveryAttestationV1 } from '../../task/control-authorit
|
|
|
17
18
|
import { applyTaskEvent } from '../../task/events.ts';
|
|
18
19
|
import { parseTaskEventRequest } from '../../internal/task-event.ts';
|
|
19
20
|
|
|
21
|
+
export type TaskWorkflowFaultWindow =
|
|
22
|
+
| 'before-call'
|
|
23
|
+
| 'before-domain-write'
|
|
24
|
+
| 'after-atomic-rename'
|
|
25
|
+
| 'before-result-return';
|
|
26
|
+
|
|
27
|
+
export type TaskWorkflowExecutionOptions = Readonly<{
|
|
28
|
+
/** Deterministic test seam; production callers leave this unset. */
|
|
29
|
+
faultWindow?: TaskWorkflowFaultWindow;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
20
32
|
function executionResult(result: Record<string, unknown>): SandboxControlExecutionResult {
|
|
21
33
|
return { exitCode: result.status === 'failed' || result.status === 'refused' ? 1 : 0, stdout: `${JSON.stringify(result)}\n`, stderr: '' };
|
|
22
34
|
}
|
|
@@ -25,10 +37,41 @@ function executionResult(result: Record<string, unknown>): SandboxControlExecuti
|
|
|
25
37
|
export async function executeTaskWorkflow(
|
|
26
38
|
manifest: SandboxControlManifest,
|
|
27
39
|
request: TaskWorkflowRequest,
|
|
28
|
-
lifecycleRecoveryAttestation: LifecycleRecoveryAttestationV1 | null = null
|
|
40
|
+
lifecycleRecoveryAttestation: LifecycleRecoveryAttestationV1 | null = null,
|
|
41
|
+
options: TaskWorkflowExecutionOptions = {}
|
|
29
42
|
): Promise<SandboxControlExecutionResult> {
|
|
30
|
-
let
|
|
43
|
+
let publicationCommitted = false;
|
|
44
|
+
let recoveryObservation: Readonly<{
|
|
45
|
+
family: 'analysis' | 'plan' | 'code' | 'review-analysis' | 'review-plan' | 'review-code';
|
|
46
|
+
artifact: string;
|
|
47
|
+
wasPassed: boolean;
|
|
48
|
+
}> | null = null;
|
|
49
|
+
const observeRecoveryPublication = (): void => {
|
|
50
|
+
if (!recoveryObservation || recoveryObservation.wasPassed) return;
|
|
51
|
+
const intent = readArtifactRecoveryIntent(
|
|
52
|
+
manifest.repoRoot,
|
|
53
|
+
request.taskId,
|
|
54
|
+
recoveryObservation.family,
|
|
55
|
+
recoveryObservation.artifact
|
|
56
|
+
);
|
|
57
|
+
publicationCommitted ||= intent?.state === 'preflight-ready'
|
|
58
|
+
|| intent?.state === 'preflight-commit-started'
|
|
59
|
+
|| intent?.state === 'preflight-passed'
|
|
60
|
+
|| intent?.state === 'passed'
|
|
61
|
+
|| intent?.state === 'commit-started';
|
|
62
|
+
};
|
|
63
|
+
let faultTriggered = false;
|
|
64
|
+
const fault = (window: TaskWorkflowFaultWindow): void => {
|
|
65
|
+
if (faultTriggered || options.faultWindow !== window) return;
|
|
66
|
+
faultTriggered = true;
|
|
67
|
+
throw new Error(`TASK_WORKFLOW_FAULT_INJECTED:${window}`);
|
|
68
|
+
};
|
|
69
|
+
const response = (result: Record<string, unknown>): SandboxControlExecutionResult => {
|
|
70
|
+
fault('before-result-return');
|
|
71
|
+
return executionResult(result);
|
|
72
|
+
};
|
|
31
73
|
try {
|
|
74
|
+
fault('before-call');
|
|
32
75
|
const taskDir = assertSandboxTaskSource(manifest.repoRoot, request.taskId);
|
|
33
76
|
const [command] = TASK_WORKFLOW_COMMANDS[request.operation];
|
|
34
77
|
if (request.operation === 'event') {
|
|
@@ -36,57 +79,95 @@ export async function executeTaskWorkflow(
|
|
|
36
79
|
const boundEventRequest = lifecycleRecoveryAttestation && eventRequest.requestId === undefined
|
|
37
80
|
? { ...eventRequest, requestId: lifecycleRecoveryAttestation.lifecycleRequestId }
|
|
38
81
|
: eventRequest;
|
|
82
|
+
fault('before-domain-write');
|
|
39
83
|
const result = applyTaskEvent(boundEventRequest, {
|
|
40
84
|
repoRoot: manifest.repoRoot,
|
|
41
85
|
lifecycleRecoveryAttestation,
|
|
42
86
|
deferLifecycleRecoveryConsumption: true
|
|
43
87
|
});
|
|
44
|
-
|
|
88
|
+
publicationCommitted = result.changed;
|
|
89
|
+
fault('after-atomic-rename');
|
|
90
|
+
return response(result);
|
|
45
91
|
}
|
|
46
92
|
if (command !== 'task-artifact' && command !== 'task-review') {
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
93
|
+
// The broker owns this short-lived executor; no global service or worker
|
|
94
|
+
// credential participates in the command's authority or recovery path.
|
|
95
|
+
fault('before-domain-write');
|
|
96
|
+
const result = dispatchWorkflowCommand(manifest.repoRoot, request.operation, request.args);
|
|
97
|
+
publicationCommitted = result.changed === true;
|
|
98
|
+
fault('after-atomic-rename');
|
|
99
|
+
return response(result);
|
|
53
100
|
}
|
|
54
101
|
const input = command === 'task-artifact' ? parseArtifactCommand(request.args) : parseReviewCommand(request.args);
|
|
55
102
|
if (command === 'task-artifact' && 'operation' in input && input.operation === 'inspect') {
|
|
56
|
-
|
|
103
|
+
fault('before-domain-write');
|
|
104
|
+
const result = executeArtifactCommand(input, { repoRoot: manifest.repoRoot });
|
|
105
|
+
publicationCommitted = result.changed === true;
|
|
106
|
+
fault('after-atomic-rename');
|
|
107
|
+
return response(result);
|
|
57
108
|
}
|
|
58
109
|
if ('operation' in input && input.operation === 'init') {
|
|
59
|
-
|
|
110
|
+
fault('before-domain-write');
|
|
111
|
+
const result = executeArtifactCommand(input, { repoRoot: manifest.repoRoot, artifactDir: taskDir });
|
|
112
|
+
publicationCommitted = result.changed === true;
|
|
113
|
+
fault('after-atomic-rename');
|
|
114
|
+
return response(result);
|
|
60
115
|
}
|
|
61
116
|
return await withTaskExecutionLock(manifest.repoRoot, request.taskId, `sandbox-control.${request.operation}`, async () => {
|
|
62
117
|
const artifact = await readTaskArtifact(taskDir, { artifact: input.artifact });
|
|
63
118
|
const content = artifact.bytes.toString('utf8');
|
|
119
|
+
fault('before-domain-write');
|
|
64
120
|
let result: Record<string, unknown>;
|
|
65
121
|
if ('operation' in input) {
|
|
66
122
|
const { family } = input;
|
|
67
123
|
if (family !== 'analysis' && family !== 'plan' && family !== 'code') throw new Error('ARTIFACT_IDENTITY_INVALID');
|
|
68
124
|
const local = { taskRef: request.taskId, family, artifact: input.artifact, repoRoot: manifest.repoRoot, recoveryId: input.recoveryId, lockAlreadyHeld: true } as const;
|
|
125
|
+
if (input.operation === 'preflight') {
|
|
126
|
+
// Preflight only seals a verified generation. It must not publish the
|
|
127
|
+
// formal artifact, set publicationStarted, or append finalizer audit.
|
|
128
|
+
const result = preflightLocalArtifact(local);
|
|
129
|
+
recoveryObservation = { family, artifact: input.artifact, wasPassed: false };
|
|
130
|
+
observeRecoveryPublication();
|
|
131
|
+
return response(result);
|
|
132
|
+
}
|
|
133
|
+
recoveryObservation = {
|
|
134
|
+
family,
|
|
135
|
+
artifact: input.artifact,
|
|
136
|
+
wasPassed: readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, family, input.artifact)?.state === 'passed'
|
|
137
|
+
};
|
|
69
138
|
const prepared = prepareLocalArtifact(local, content, lifecycleRecoveryAttestation ?? undefined);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
result
|
|
139
|
+
observeRecoveryPublication();
|
|
140
|
+
const commitOptions = { afterPublish: () => fault('after-atomic-rename') };
|
|
141
|
+
if (prepared.result.status === 'failed') return executionResult(commitLocalArtifactProvenance(prepared, commitOptions));
|
|
142
|
+
result = commitLocalArtifactProvenance(prepared, commitOptions);
|
|
73
143
|
} else {
|
|
74
144
|
if (input.overrideTicket) throw new Error('TASK_WORKFLOW_OVERRIDE_UNSUPPORTED');
|
|
145
|
+
const family = `review-${input.stage}` as 'review-analysis' | 'review-plan' | 'review-code';
|
|
146
|
+
recoveryObservation = {
|
|
147
|
+
family,
|
|
148
|
+
artifact: input.artifact,
|
|
149
|
+
wasPassed: readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, family, input.artifact)?.state === 'passed'
|
|
150
|
+
};
|
|
75
151
|
const prepared = prepareReviewSummaryCandidate(input, content, { repoRoot: manifest.repoRoot, lockAlreadyHeld: true, startRecovery: true });
|
|
152
|
+
observeRecoveryPublication();
|
|
76
153
|
if (input.dryRun || prepared.result.status === 'planned') return executionResult(prepared.result);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
result = commitReviewSummaryProvenance(prepared, manifest.repoRoot);
|
|
154
|
+
const commitOptions = { afterPublish: () => fault('after-atomic-rename') };
|
|
155
|
+
if (prepared.result.status === 'failed') return executionResult(commitReviewSummaryProvenance(prepared, manifest.repoRoot, commitOptions));
|
|
156
|
+
result = commitReviewSummaryProvenance(prepared, manifest.repoRoot, commitOptions);
|
|
80
157
|
}
|
|
158
|
+
observeRecoveryPublication();
|
|
159
|
+
publicationCommitted ||= result.changed === true;
|
|
81
160
|
if (result.status !== 'failed') appendDiagnosticAudit(manifest, 'task-workflow-artifact-finalized', {
|
|
82
161
|
requestId: request.id, sandboxTaskId: request.taskId, workflowOperation: request.operation,
|
|
83
162
|
artifact: input.artifact, sha256: result.artifactSha256 as string, semanticDigest: result.semanticDigest as string
|
|
84
163
|
});
|
|
85
|
-
|
|
164
|
+
fault('after-atomic-rename');
|
|
165
|
+
return response(result);
|
|
86
166
|
});
|
|
87
167
|
} catch (error) {
|
|
168
|
+
observeRecoveryPublication();
|
|
88
169
|
const message = error instanceof Error ? error.message : String(error);
|
|
89
170
|
const code = (error as { code?: string })?.code ?? /^([A-Z][A-Z0-9_]+)/u.exec(message)?.[1] ?? 'TASK_WORKFLOW_REQUEST_INVALID';
|
|
90
|
-
return executionResult({ status: 'failed', changed:
|
|
171
|
+
return executionResult({ status: 'failed', changed: publicationCommitted ? null : false, error: { code, message } });
|
|
91
172
|
}
|
|
92
173
|
}
|
|
@@ -12,6 +12,11 @@ export type ProcessIdentity = Readonly<{
|
|
|
12
12
|
export type ProcessIdentityState = 'alive' | 'dead' | 'unknown';
|
|
13
13
|
export type ProcessIdentityProbe = (identity: ProcessIdentity) => ProcessIdentityState;
|
|
14
14
|
|
|
15
|
+
type ProcessIdentityRuntime = Readonly<{
|
|
16
|
+
probePid?: (pid: number) => void;
|
|
17
|
+
execFileSync?: typeof execFileSync;
|
|
18
|
+
}>;
|
|
19
|
+
|
|
15
20
|
export type PidRecord = ProcessIdentity & {
|
|
16
21
|
version: 2;
|
|
17
22
|
};
|
|
@@ -132,7 +137,8 @@ export function getProcessStartTime(
|
|
|
132
137
|
|
|
133
138
|
export function getProcessIdentityState(
|
|
134
139
|
identity: ProcessIdentity,
|
|
135
|
-
platform: NodeJS.Platform = process.platform
|
|
140
|
+
platform: NodeJS.Platform = process.platform,
|
|
141
|
+
runtime: ProcessIdentityRuntime = {}
|
|
136
142
|
): ProcessIdentityState {
|
|
137
143
|
if (!Number.isInteger(identity.pid) || identity.pid <= 0 || !Number.isSafeInteger(identity.startTime)) {
|
|
138
144
|
return 'dead';
|
|
@@ -147,8 +153,9 @@ export function getProcessIdentityState(
|
|
|
147
153
|
return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'dead' : 'unknown';
|
|
148
154
|
}
|
|
149
155
|
}
|
|
156
|
+
const probePid = runtime.probePid ?? ((pid: number) => process.kill(pid, 0));
|
|
150
157
|
try {
|
|
151
|
-
|
|
158
|
+
probePid(identity.pid);
|
|
152
159
|
} catch (error) {
|
|
153
160
|
const code = (error as NodeJS.ErrnoException).code;
|
|
154
161
|
if (code === 'ESRCH') return 'dead';
|
|
@@ -157,20 +164,34 @@ export function getProcessIdentityState(
|
|
|
157
164
|
const query = buildProcessStartTimeQuery(identity.pid, platform);
|
|
158
165
|
if (query === null) return 'unknown';
|
|
159
166
|
try {
|
|
160
|
-
const output = execFileSync(query.command, query.args, {
|
|
167
|
+
const output = (runtime.execFileSync ?? execFileSync)(query.command, query.args, {
|
|
161
168
|
encoding: 'utf8',
|
|
162
169
|
env: platform === 'darwin' ? { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } : process.env
|
|
163
170
|
}).trim();
|
|
164
|
-
if (output.length === 0) return
|
|
171
|
+
if (output.length === 0) return recheckDarwinProcessExit(identity.pid, platform, probePid);
|
|
165
172
|
const startTime = platform === 'win32'
|
|
166
173
|
? new Date(output).getTime()
|
|
167
174
|
: platform === 'darwin'
|
|
168
175
|
? parseDarwinStartTime(output)
|
|
169
176
|
: null;
|
|
170
|
-
if (startTime === null || Number.isNaN(startTime)) return
|
|
177
|
+
if (startTime === null || Number.isNaN(startTime)) return recheckDarwinProcessExit(identity.pid, platform, probePid);
|
|
171
178
|
return startTime === identity.startTime ? 'alive' : 'dead';
|
|
172
179
|
} catch {
|
|
180
|
+
return recheckDarwinProcessExit(identity.pid, platform, probePid);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function recheckDarwinProcessExit(
|
|
185
|
+
pid: number,
|
|
186
|
+
platform: NodeJS.Platform,
|
|
187
|
+
probePid: (pid: number) => void
|
|
188
|
+
): ProcessIdentityState {
|
|
189
|
+
if (platform !== 'darwin') return 'unknown';
|
|
190
|
+
try {
|
|
191
|
+
probePid(pid);
|
|
173
192
|
return 'unknown';
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return (error as NodeJS.ErrnoException).code === 'ESRCH' ? 'dead' : 'unknown';
|
|
174
195
|
}
|
|
175
196
|
}
|
|
176
197
|
|
|
@@ -4,12 +4,12 @@ import { resolveArtifactContext, hasOpenArtifactRound } from './artifact-lifecyc
|
|
|
4
4
|
import { parseArtifactName } from './artifact-name.ts';
|
|
5
5
|
import { resolveTaskRef } from './resolve-ref.ts';
|
|
6
6
|
import { getArtifactSchema } from './artifact-schema.ts';
|
|
7
|
-
import { finalizeLocalArtifact } from './local-artifact-finalization.ts';
|
|
7
|
+
import { finalizeLocalArtifact, preflightLocalArtifact } from './local-artifact-finalization.ts';
|
|
8
8
|
import { initializeArtifactSkeleton } from './artifact-operations.ts';
|
|
9
9
|
|
|
10
10
|
export type ArtifactCommand = Readonly<{
|
|
11
11
|
taskRef: string;
|
|
12
|
-
operation: 'inspect' | 'init' | 'finalize-local';
|
|
12
|
+
operation: 'inspect' | 'init' | 'preflight' | 'finalize-local';
|
|
13
13
|
family: string;
|
|
14
14
|
artifact: string;
|
|
15
15
|
locale?: 'zh-CN' | 'en';
|
|
@@ -19,13 +19,14 @@ export type ArtifactCommand = Readonly<{
|
|
|
19
19
|
/** One option contract for the CLI and trusted projection executor. */
|
|
20
20
|
export function parseArtifactCommand(args: readonly string[]): ArtifactCommand {
|
|
21
21
|
const [taskRef, operation] = args;
|
|
22
|
-
if (!taskRef || !['inspect', 'init', 'finalize-local'].includes(operation ?? '')) {
|
|
22
|
+
if (!taskRef || !['inspect', 'init', 'preflight', 'finalize-local'].includes(operation ?? '')) {
|
|
23
23
|
throw new Error('task ref and a supported artifact operation are required');
|
|
24
24
|
}
|
|
25
25
|
const fields: Record<string, string> = {};
|
|
26
26
|
const allowed = {
|
|
27
27
|
inspect: ['--family'],
|
|
28
28
|
init: ['--family', '--artifact', '--locale'],
|
|
29
|
+
'preflight': ['--family', '--artifact', '--recovery-id'],
|
|
29
30
|
'finalize-local': ['--family', '--artifact', '--recovery-id']
|
|
30
31
|
}[operation as ArtifactCommand['operation']];
|
|
31
32
|
for (let index = 2; index < args.length; index += 1) {
|
|
@@ -79,7 +80,8 @@ export function executeArtifactCommand(
|
|
|
79
80
|
return { ...initializeArtifactSkeleton({ ...input, ...(locale ? { locale } : {}) }), ...identity };
|
|
80
81
|
}
|
|
81
82
|
if (family !== 'analysis' && family !== 'plan' && family !== 'code') {
|
|
82
|
-
return fail('ARTIFACT_PAYLOAD_INVALID', "finalize-local only
|
|
83
|
+
return fail('ARTIFACT_PAYLOAD_INVALID', "preflight and finalize-local only support 'analysis', 'plan', and 'code'");
|
|
83
84
|
}
|
|
85
|
+
if (operation === 'preflight') return { ...preflightLocalArtifact({ taskRef, family, artifact, repoRoot: resolved.repoRoot, recoveryId }) };
|
|
84
86
|
return { ...finalizeLocalArtifact({ taskRef, family, artifact, repoRoot: resolved.repoRoot, recoveryId }) };
|
|
85
87
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { readStableFileSync } from '
|
|
4
|
+
import { readStableFileSync } from './secure-fs.ts';
|
|
5
5
|
import { parseArtifactName } from './artifact-name.ts';
|
|
6
6
|
|
|
7
7
|
import { scanVisibleMarkdown, type VisibleMarkdown, type VisibleHeading } from './markdown.ts';
|
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
|
|
5
5
|
import { writeDurableFile } from '../fs/durable-write.ts';
|
|
6
|
-
import { readStableFileSync } from '
|
|
6
|
+
import { readStableFileSync } from './secure-fs.ts';
|
|
7
7
|
import { parseArtifactName } from './artifact-name.ts';
|
|
8
8
|
import { canonicalSemanticDigest, sha256Content } from './artifact-operations.ts';
|
|
9
9
|
import {
|
|
@@ -39,7 +39,7 @@ export type ArtifactRecoveryContext = Readonly<ArtifactRecoveryTuple & {
|
|
|
39
39
|
formalPath: string;
|
|
40
40
|
stagingPath: string;
|
|
41
41
|
baselinePath: string;
|
|
42
|
-
|
|
42
|
+
generationsPath: string;
|
|
43
43
|
baselineSha256: string;
|
|
44
44
|
baselineSemanticDigest: string;
|
|
45
45
|
}>;
|
|
@@ -191,7 +191,7 @@ function contextPaths(
|
|
|
191
191
|
formalPath: path.join(taskDir, tuple.artifact),
|
|
192
192
|
stagingPath: path.join(root, 'candidate.md'),
|
|
193
193
|
baselinePath: path.join(root, 'baseline.md'),
|
|
194
|
-
|
|
194
|
+
generationsPath: path.join(root, 'generations'),
|
|
195
195
|
baselineSha256: '',
|
|
196
196
|
baselineSemanticDigest: ''
|
|
197
197
|
};
|
|
@@ -221,16 +221,22 @@ function contextWithIntent(context: ArtifactRecoveryContext, intent: ArtifactRec
|
|
|
221
221
|
|
|
222
222
|
function createIntent(context: ArtifactRecoveryContext, now: number): ArtifactRecoveryIntent {
|
|
223
223
|
return {
|
|
224
|
-
version:
|
|
224
|
+
version: 4,
|
|
225
225
|
taskId: context.taskId,
|
|
226
226
|
family: context.family,
|
|
227
227
|
artifact: context.artifact,
|
|
228
228
|
round: context.round,
|
|
229
|
-
state: 'awaiting-recovery',
|
|
229
|
+
state: 'awaiting-preflight-recovery',
|
|
230
230
|
baselineSha256: context.baselineSha256,
|
|
231
231
|
baselineSemanticDigest: context.baselineSemanticDigest,
|
|
232
232
|
stagingId: context.stagingId,
|
|
233
233
|
candidateSha256: context.baselineSha256,
|
|
234
|
+
preflightArtifactSha256: null,
|
|
235
|
+
preflightSemanticDigest: null,
|
|
236
|
+
activeGenerationSha256: null,
|
|
237
|
+
activeGenerationSemanticDigest: null,
|
|
238
|
+
pendingGenerationSha256: null,
|
|
239
|
+
pendingGenerationSemanticDigest: null,
|
|
234
240
|
finalArtifactSha256: null,
|
|
235
241
|
finalSemanticDigest: null,
|
|
236
242
|
recoveryOperationId: context.recoveryId,
|
|
@@ -267,7 +273,7 @@ export function beginArtifactRecovery(
|
|
|
267
273
|
fail('ARTIFACT_RECOVERY_BASELINE_MISMATCH', 'formal artifact does not match the recovery baseline');
|
|
268
274
|
}
|
|
269
275
|
const existing = readArtifactRecoveryIntent(prepared.repoRoot, prepared.taskId, prepared.family, prepared.artifact);
|
|
270
|
-
if (existing &&
|
|
276
|
+
if (existing && !['aborted', 'consumed', 'passed'].includes(existing.state)) {
|
|
271
277
|
fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
|
|
272
278
|
}
|
|
273
279
|
writeBytes(prepared.baselinePath, baselineBytes);
|
|
@@ -287,6 +293,7 @@ export function recordArtifactRecoveryPassed(
|
|
|
287
293
|
assertRecoveryId(context.recoveryId);
|
|
288
294
|
assertRegular(context.formalPath, 'formal artifact');
|
|
289
295
|
return runLocked(context, 'task-artifact.recovery.record-passed', () => {
|
|
296
|
+
assertRecoveryRoot(context);
|
|
290
297
|
const current = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
291
298
|
const prepared = {
|
|
292
299
|
...context,
|
|
@@ -306,18 +313,18 @@ export function recordArtifactRecoveryPassed(
|
|
|
306
313
|
&& existing.finalSemanticDigest === prepared.baselineSemanticDigest) {
|
|
307
314
|
return recoveryContextFromIntent(prepared.repoRoot, prepared.taskDir, existing);
|
|
308
315
|
}
|
|
309
|
-
if (existing && existing.state !== 'aborted') {
|
|
316
|
+
if (existing && existing.state !== 'aborted' && existing.state !== 'passed') {
|
|
310
317
|
fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
|
|
311
318
|
}
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
};
|
|
320
|
-
|
|
319
|
+
const base = createIntent(prepared, Date.now());
|
|
320
|
+
writeBytes(prepared.baselinePath, current.bytes);
|
|
321
|
+
writeBytes(prepared.stagingPath, current.bytes);
|
|
322
|
+
writeArtifactRecoveryIntent(prepared.repoRoot, base, { expected: existing });
|
|
323
|
+
const staged = stageArtifactCandidate(prepared, current.bytes, { lockAlreadyHeld: true });
|
|
324
|
+
prepareArtifactRecoveryCommit(prepared, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: true });
|
|
325
|
+
commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
|
|
326
|
+
prepareArtifactRecoveryFinal(prepared, current.bytes, { lockAlreadyHeld: true });
|
|
327
|
+
commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
|
|
321
328
|
return prepared;
|
|
322
329
|
}, options.lockAlreadyHeld);
|
|
323
330
|
}
|
|
@@ -330,7 +337,7 @@ export function stageArtifactCandidate(
|
|
|
330
337
|
return runLocked(context, 'task-artifact.recovery.stage', () => {
|
|
331
338
|
assertRecoveryRoot(context);
|
|
332
339
|
const intent = readIntentForContext(context);
|
|
333
|
-
if (intent.state !== 'awaiting-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot stage a candidate from '${intent.state}'`);
|
|
340
|
+
if (intent.state !== 'awaiting-preflight-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot stage a candidate from '${intent.state}'`);
|
|
334
341
|
const candidateSha256 = sha256Content(candidateBytes.toString('utf8'));
|
|
335
342
|
const semanticDigest = canonicalSemanticDigest(candidateBytes.toString('utf8'));
|
|
336
343
|
writeBytes(context.stagingPath, candidateBytes);
|
|
@@ -340,6 +347,49 @@ export function stageArtifactCandidate(
|
|
|
340
347
|
}, options.lockAlreadyHeld);
|
|
341
348
|
}
|
|
342
349
|
|
|
350
|
+
function generationPath(context: ArtifactRecoveryContext, sha256: string): string {
|
|
351
|
+
assertDigest(sha256, 'generation digest');
|
|
352
|
+
return path.join(context.generationsPath, `${sha256}.md`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Persist and verify an immutable digest-addressed snapshot before it is journaled. */
|
|
356
|
+
function writeGeneration(context: ArtifactRecoveryContext, bytes: Buffer, sha256: string, semanticDigest: string): void {
|
|
357
|
+
ensureOwnedDirectoryTree(context.repoRoot, context.generationsPath);
|
|
358
|
+
const target = generationPath(context, sha256);
|
|
359
|
+
if (fs.existsSync(target)) {
|
|
360
|
+
const existing = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
|
|
361
|
+
if (canonicalSemanticDigest(existing.bytes.toString('utf8')) !== semanticDigest) {
|
|
362
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'existing generation does not match its semantic digest');
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
writeBytes(target, bytes);
|
|
367
|
+
fs.chmodSync(target, 0o400);
|
|
368
|
+
const sealed = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
|
|
369
|
+
if (canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== semanticDigest) {
|
|
370
|
+
fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'generation does not match its validated semantic digest');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function publishGeneration(context: ArtifactRecoveryContext, sha256: string, semanticDigest: string, baselineSha256: string): void {
|
|
375
|
+
const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
376
|
+
const generation = readStableFileSync(generationPath(context, sha256), { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
|
|
377
|
+
if (canonicalSemanticDigest(generation.bytes.toString('utf8')) !== semanticDigest) {
|
|
378
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'generation semantic digest does not match the journal');
|
|
379
|
+
}
|
|
380
|
+
if (target.sha256 === sha256) return;
|
|
381
|
+
if (target.sha256 !== baselineSha256) fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact changed outside the expected generation');
|
|
382
|
+
const publishPath = path.join(path.dirname(context.stagingPath), 'publish.md');
|
|
383
|
+
unlinkIfPresent(publishPath);
|
|
384
|
+
writeBytes(publishPath, generation.bytes);
|
|
385
|
+
fs.chmodSync(publishPath, 0o400);
|
|
386
|
+
fs.renameSync(publishPath, context.formalPath);
|
|
387
|
+
const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
|
|
388
|
+
if (canonicalSemanticDigest(published.bytes.toString('utf8')) !== semanticDigest) {
|
|
389
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'published artifact does not match its generation');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
343
393
|
export function prepareArtifactRecoveryCommit(
|
|
344
394
|
context: ArtifactRecoveryContext,
|
|
345
395
|
finalSha256: string,
|
|
@@ -350,24 +400,21 @@ export function prepareArtifactRecoveryCommit(
|
|
|
350
400
|
assertDigest(finalSemanticDigest, 'final semantic digest');
|
|
351
401
|
return runLocked(context, 'task-artifact.recovery.prepare', () => {
|
|
352
402
|
const intent = readIntentForContext(context);
|
|
353
|
-
if (intent.state !== 'awaiting-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a
|
|
403
|
+
if (intent.state !== 'awaiting-preflight-recovery') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a preflight from '${intent.state}'`);
|
|
354
404
|
assertRecoveryRoot(context);
|
|
355
405
|
const staged = readStableFileSync(context.stagingPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
356
406
|
if (staged.sha256 !== finalSha256 || canonicalSemanticDigest(staged.bytes.toString('utf8')) !== finalSemanticDigest) {
|
|
357
407
|
fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'staged candidate does not match the finalizer digest');
|
|
358
408
|
}
|
|
359
|
-
|
|
360
|
-
fs.chmodSync(context.finalPath, 0o400);
|
|
361
|
-
const sealed = readStableFileSync(context.finalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
362
|
-
if (sealed.sha256 !== finalSha256 || canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== finalSemanticDigest) {
|
|
363
|
-
fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'sealed candidate does not match the finalizer digest');
|
|
364
|
-
}
|
|
409
|
+
writeGeneration(context, staged.bytes, finalSha256, finalSemanticDigest);
|
|
365
410
|
const next: ArtifactRecoveryIntent = {
|
|
366
411
|
...intent,
|
|
367
|
-
state: '
|
|
412
|
+
state: 'preflight-ready',
|
|
368
413
|
candidateSha256: staged.sha256,
|
|
369
|
-
|
|
370
|
-
finalSemanticDigest,
|
|
414
|
+
preflightArtifactSha256: finalSha256,
|
|
415
|
+
preflightSemanticDigest: finalSemanticDigest,
|
|
416
|
+
activeGenerationSha256: finalSha256,
|
|
417
|
+
activeGenerationSemanticDigest: finalSemanticDigest,
|
|
371
418
|
errorCode: null,
|
|
372
419
|
errorMessage: null,
|
|
373
420
|
updatedAt: Date.now()
|
|
@@ -378,59 +425,61 @@ export function prepareArtifactRecoveryCommit(
|
|
|
378
425
|
}
|
|
379
426
|
|
|
380
427
|
function markCommitStarted(context: ArtifactRecoveryContext, intent: ArtifactRecoveryIntent): ArtifactRecoveryIntent {
|
|
381
|
-
if (intent.state === 'commit-started') return intent;
|
|
382
|
-
if (intent.state !== '
|
|
383
|
-
const next: ArtifactRecoveryIntent = { ...intent, state: 'commit-started', updatedAt: Date.now() };
|
|
428
|
+
if (intent.state === 'preflight-commit-started' || intent.state === 'commit-started') return intent;
|
|
429
|
+
if (intent.state !== 'preflight-ready' && intent.state !== 'full-finalizer-ready') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot commit from '${intent.state}'`);
|
|
430
|
+
const next: ArtifactRecoveryIntent = { ...intent, state: intent.state === 'preflight-ready' ? 'preflight-commit-started' : 'commit-started', updatedAt: Date.now() };
|
|
384
431
|
writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
|
|
385
432
|
return next;
|
|
386
433
|
}
|
|
387
434
|
|
|
388
435
|
export function commitArtifactRecovery(
|
|
389
436
|
context: ArtifactRecoveryContext,
|
|
390
|
-
options: Readonly<{ lockAlreadyHeld?: boolean }> = {}
|
|
437
|
+
options: Readonly<{ lockAlreadyHeld?: boolean; afterPublish?: () => void }> = {}
|
|
391
438
|
): ArtifactRecoveryIntent {
|
|
392
439
|
return runLocked(context, 'task-artifact.recovery.commit', () => {
|
|
393
440
|
let intent = readIntentForContext(context);
|
|
394
441
|
if (intent.state === 'passed' || intent.state === 'consumed') return intent;
|
|
395
442
|
assertRecoveryRoot(context);
|
|
396
443
|
intent = markCommitStarted(context, intent);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const passed
|
|
444
|
+
if (intent.state === 'preflight-commit-started') {
|
|
445
|
+
publishGeneration(context, intent.activeGenerationSha256!, intent.activeGenerationSemanticDigest!, intent.baselineSha256);
|
|
446
|
+
const passed = { ...intent, state: 'preflight-passed' as const, updatedAt: Date.now() };
|
|
400
447
|
writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
|
|
401
448
|
return passed;
|
|
402
449
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
// Publish the bytes that were validated above. The sealed path is only a
|
|
412
|
-
// recovery input; renaming it directly would reopen a same-uid path swap
|
|
413
|
-
// between validation and publication.
|
|
414
|
-
const publishPath = path.join(path.dirname(context.finalPath), 'publish.md');
|
|
415
|
-
unlinkIfPresent(publishPath);
|
|
416
|
-
writeBytes(publishPath, sealed.bytes);
|
|
417
|
-
fs.chmodSync(publishPath, 0o400);
|
|
418
|
-
const publish = readStableFileSync(publishPath, {
|
|
419
|
-
maxBytes: MAX_ARTIFACT_BYTES,
|
|
420
|
-
expectedSha256: intent.finalArtifactSha256!
|
|
421
|
-
});
|
|
422
|
-
if (canonicalSemanticDigest(publish.bytes.toString('utf8')) !== intent.finalSemanticDigest) {
|
|
423
|
-
fail('ARTIFACT_RECOVERY_CONFLICT', 'publish artifact does not match the staged final semantic digest');
|
|
424
|
-
}
|
|
425
|
-
fs.renameSync(publishPath, context.formalPath);
|
|
426
|
-
const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
427
|
-
if (published.sha256 !== intent.finalArtifactSha256) fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact does not match the staged final digest');
|
|
428
|
-
const passed: ArtifactRecoveryIntent = { ...intent, state: 'passed', updatedAt: Date.now() };
|
|
450
|
+
publishGeneration(context, intent.pendingGenerationSha256!, intent.pendingGenerationSemanticDigest!, intent.activeGenerationSha256!);
|
|
451
|
+
options.afterPublish?.();
|
|
452
|
+
const passed: ArtifactRecoveryIntent = {
|
|
453
|
+
...intent, state: 'passed', activeGenerationSha256: intent.pendingGenerationSha256,
|
|
454
|
+
activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest,
|
|
455
|
+
pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now()
|
|
456
|
+
};
|
|
429
457
|
writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
|
|
430
458
|
return passed;
|
|
431
459
|
}, options.lockAlreadyHeld);
|
|
432
460
|
}
|
|
433
461
|
|
|
462
|
+
export function prepareArtifactRecoveryFinal(
|
|
463
|
+
context: ArtifactRecoveryContext,
|
|
464
|
+
finalBytes: Buffer,
|
|
465
|
+
options: Readonly<{ lockAlreadyHeld?: boolean }> = {}
|
|
466
|
+
): ArtifactRecoveryIntent {
|
|
467
|
+
return runLocked(context, 'task-artifact.recovery.finalize', () => {
|
|
468
|
+
const intent = readIntentForContext(context);
|
|
469
|
+
if (intent.state !== 'preflight-passed') fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot finalize from '${intent.state}'`);
|
|
470
|
+
const finalSha256 = sha256Content(finalBytes.toString('utf8'));
|
|
471
|
+
const finalSemanticDigest = canonicalSemanticDigest(finalBytes.toString('utf8'));
|
|
472
|
+
if (finalSha256 !== intent.activeGenerationSha256) writeGeneration(context, finalBytes, finalSha256, finalSemanticDigest);
|
|
473
|
+
const next: ArtifactRecoveryIntent = {
|
|
474
|
+
...intent, state: 'full-finalizer-ready', pendingGenerationSha256: finalSha256,
|
|
475
|
+
pendingGenerationSemanticDigest: finalSemanticDigest, finalArtifactSha256: finalSha256,
|
|
476
|
+
finalSemanticDigest, updatedAt: Date.now()
|
|
477
|
+
};
|
|
478
|
+
writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
|
|
479
|
+
return next;
|
|
480
|
+
}, options.lockAlreadyHeld);
|
|
481
|
+
}
|
|
482
|
+
|
|
434
483
|
export function abortArtifactRecovery(
|
|
435
484
|
context: ArtifactRecoveryContext,
|
|
436
485
|
code: string,
|
|
@@ -462,19 +511,23 @@ export function reconcileArtifactRecovery(
|
|
|
462
511
|
lockAlreadyHeld?: boolean;
|
|
463
512
|
validateFinal?: () => boolean;
|
|
464
513
|
restoreBaseline?: boolean;
|
|
514
|
+
afterPublish?: () => void;
|
|
465
515
|
}> = {}
|
|
466
516
|
): ArtifactRecoveryReconcileResult {
|
|
467
517
|
return runLocked(context, 'task-artifact.recovery.reconcile', () => {
|
|
468
518
|
const intent = readIntentForContext(context);
|
|
469
|
-
if (intent.state !== 'commit-started') return { status: intent.state, intent };
|
|
519
|
+
if (intent.state !== 'preflight-commit-started' && intent.state !== 'commit-started') return { status: intent.state, intent };
|
|
470
520
|
const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
521
|
+
const expected = intent.state === 'preflight-commit-started' ? intent.activeGenerationSha256! : intent.pendingGenerationSha256!;
|
|
522
|
+
if (target.sha256 === expected && (options.validateFinal?.() ?? true)) {
|
|
523
|
+
const next = intent.state === 'preflight-commit-started'
|
|
524
|
+
? { ...intent, state: 'preflight-passed' as const, updatedAt: Date.now() }
|
|
525
|
+
: { ...intent, state: 'passed' as const, activeGenerationSha256: intent.pendingGenerationSha256, activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest, pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now() };
|
|
526
|
+
writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
|
|
527
|
+
return { status: next.state, intent: next };
|
|
475
528
|
}
|
|
476
|
-
if (target.sha256 === intent.baselineSha256) {
|
|
477
|
-
const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true });
|
|
529
|
+
if (target.sha256 === (intent.state === 'preflight-commit-started' ? intent.baselineSha256 : intent.activeGenerationSha256)) {
|
|
530
|
+
const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true, afterPublish: options.afterPublish });
|
|
478
531
|
return { status: retried.state, intent: retried };
|
|
479
532
|
}
|
|
480
533
|
if (options.restoreBaseline) {
|