@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
|
@@ -125,7 +125,9 @@ export async function prepareSandboxControlExecution(params) {
|
|
|
125
125
|
owner: gateOwner,
|
|
126
126
|
requestId: params.request.id,
|
|
127
127
|
operationId: authority?.operationId ?? params.request.id,
|
|
128
|
-
phase: authority?.phase ?? (params.request.family === 'task-workflow'
|
|
128
|
+
phase: authority?.phase ?? (params.request.family === 'task-workflow'
|
|
129
|
+
&& params.request.workflow?.operation === 'artifact-preflight' ? 'artifact.preflight'
|
|
130
|
+
: params.request.family === 'task-workflow' ? 'artifact.finalize-local' : 'orchestration.prepare'),
|
|
129
131
|
authority
|
|
130
132
|
});
|
|
131
133
|
},
|
|
@@ -163,7 +165,7 @@ function waitForGate(nonce, timeoutMs = 2_000) {
|
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
165
167
|
if (typeof value.requestId !== 'string' || typeof value.operationId !== 'string'
|
|
166
|
-
|| !['orchestration.prepare', 'artifact.finalize-local', 'task-event.completed'].includes(value.phase)
|
|
168
|
+
|| !['orchestration.prepare', 'artifact.preflight', 'artifact.finalize-local', 'task-event.completed'].includes(value.phase)
|
|
167
169
|
|| (value.authority !== null && value.authority !== undefined && (() => {
|
|
168
170
|
try {
|
|
169
171
|
validateLifecycleRecoveryAttestation(value.authority);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { readStableFile, SecureFileError, writeAtomicFile } from '../../
|
|
4
|
+
import { readStableFile, SecureFileError, writeAtomicFile } from '../../task/secure-fs.js';
|
|
5
5
|
import { TASK_WORKFLOW_COMMANDS, TASK_WORKFLOW_OPERATIONS } from '../../task/workflow-command.js';
|
|
6
6
|
export function validateTaskWorkflowRequest(value) {
|
|
7
7
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { TASK_WORKFLOW_COMMANDS } from '../../task/workflow-command.js';
|
|
2
2
|
import { parseArtifactCommand, executeArtifactCommand } from '../../task/artifact-command.js';
|
|
3
3
|
import { parseReviewCommand } from '../../task/review-command.js';
|
|
4
|
-
import { prepareLocalArtifact, commitLocalArtifactProvenance } from '../../task/local-artifact-finalization.js';
|
|
4
|
+
import { prepareLocalArtifact, preflightLocalArtifact, commitLocalArtifactProvenance } from '../../task/local-artifact-finalization.js';
|
|
5
5
|
import { prepareReviewSummaryCandidate, commitReviewSummaryProvenance } from '../../task/review-finalization.js';
|
|
6
|
+
import { readArtifactRecoveryIntent } from '../../task/artifact-repair-intent.js';
|
|
6
7
|
import { withTaskExecutionLock } from '../../task/task-execution-lock.js';
|
|
7
|
-
import {
|
|
8
|
+
import { dispatchWorkflowCommand } from '../../task/workflow-dispatch.js';
|
|
8
9
|
import { assertSandboxTaskSource } from '../workspace-view.js';
|
|
9
10
|
import { appendDiagnosticAudit } from './audit.js';
|
|
10
11
|
import { readTaskArtifact } from './task-workflow.js';
|
|
@@ -14,9 +15,32 @@ function executionResult(result) {
|
|
|
14
15
|
return { exitCode: result.status === 'failed' || result.status === 'refused' ? 1 : 0, stdout: `${JSON.stringify(result)}\n`, stderr: '' };
|
|
15
16
|
}
|
|
16
17
|
/** The broker already authorized this host executor; domain operations own validation. */
|
|
17
|
-
export async function executeTaskWorkflow(manifest, request, lifecycleRecoveryAttestation = null) {
|
|
18
|
-
let
|
|
18
|
+
export async function executeTaskWorkflow(manifest, request, lifecycleRecoveryAttestation = null, options = {}) {
|
|
19
|
+
let publicationCommitted = false;
|
|
20
|
+
let recoveryObservation = null;
|
|
21
|
+
const observeRecoveryPublication = () => {
|
|
22
|
+
if (!recoveryObservation || recoveryObservation.wasPassed)
|
|
23
|
+
return;
|
|
24
|
+
const intent = readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, recoveryObservation.family, recoveryObservation.artifact);
|
|
25
|
+
publicationCommitted ||= intent?.state === 'preflight-ready'
|
|
26
|
+
|| intent?.state === 'preflight-commit-started'
|
|
27
|
+
|| intent?.state === 'preflight-passed'
|
|
28
|
+
|| intent?.state === 'passed'
|
|
29
|
+
|| intent?.state === 'commit-started';
|
|
30
|
+
};
|
|
31
|
+
let faultTriggered = false;
|
|
32
|
+
const fault = (window) => {
|
|
33
|
+
if (faultTriggered || options.faultWindow !== window)
|
|
34
|
+
return;
|
|
35
|
+
faultTriggered = true;
|
|
36
|
+
throw new Error(`TASK_WORKFLOW_FAULT_INJECTED:${window}`);
|
|
37
|
+
};
|
|
38
|
+
const response = (result) => {
|
|
39
|
+
fault('before-result-return');
|
|
40
|
+
return executionResult(result);
|
|
41
|
+
};
|
|
19
42
|
try {
|
|
43
|
+
fault('before-call');
|
|
20
44
|
const taskDir = assertSandboxTaskSource(manifest.repoRoot, request.taskId);
|
|
21
45
|
const [command] = TASK_WORKFLOW_COMMANDS[request.operation];
|
|
22
46
|
if (request.operation === 'event') {
|
|
@@ -24,66 +48,104 @@ export async function executeTaskWorkflow(manifest, request, lifecycleRecoveryAt
|
|
|
24
48
|
const boundEventRequest = lifecycleRecoveryAttestation && eventRequest.requestId === undefined
|
|
25
49
|
? { ...eventRequest, requestId: lifecycleRecoveryAttestation.lifecycleRequestId }
|
|
26
50
|
: eventRequest;
|
|
51
|
+
fault('before-domain-write');
|
|
27
52
|
const result = applyTaskEvent(boundEventRequest, {
|
|
28
53
|
repoRoot: manifest.repoRoot,
|
|
29
54
|
lifecycleRecoveryAttestation,
|
|
30
55
|
deferLifecycleRecoveryConsumption: true
|
|
31
56
|
});
|
|
32
|
-
|
|
57
|
+
publicationCommitted = result.changed;
|
|
58
|
+
fault('after-atomic-rename');
|
|
59
|
+
return response(result);
|
|
33
60
|
}
|
|
34
61
|
if (command !== 'task-artifact' && command !== 'task-review') {
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
62
|
+
// The broker owns this short-lived executor; no global service or worker
|
|
63
|
+
// credential participates in the command's authority or recovery path.
|
|
64
|
+
fault('before-domain-write');
|
|
65
|
+
const result = dispatchWorkflowCommand(manifest.repoRoot, request.operation, request.args);
|
|
66
|
+
publicationCommitted = result.changed === true;
|
|
67
|
+
fault('after-atomic-rename');
|
|
68
|
+
return response(result);
|
|
41
69
|
}
|
|
42
70
|
const input = command === 'task-artifact' ? parseArtifactCommand(request.args) : parseReviewCommand(request.args);
|
|
43
71
|
if (command === 'task-artifact' && 'operation' in input && input.operation === 'inspect') {
|
|
44
|
-
|
|
72
|
+
fault('before-domain-write');
|
|
73
|
+
const result = executeArtifactCommand(input, { repoRoot: manifest.repoRoot });
|
|
74
|
+
publicationCommitted = result.changed === true;
|
|
75
|
+
fault('after-atomic-rename');
|
|
76
|
+
return response(result);
|
|
45
77
|
}
|
|
46
78
|
if ('operation' in input && input.operation === 'init') {
|
|
47
|
-
|
|
79
|
+
fault('before-domain-write');
|
|
80
|
+
const result = executeArtifactCommand(input, { repoRoot: manifest.repoRoot, artifactDir: taskDir });
|
|
81
|
+
publicationCommitted = result.changed === true;
|
|
82
|
+
fault('after-atomic-rename');
|
|
83
|
+
return response(result);
|
|
48
84
|
}
|
|
49
85
|
return await withTaskExecutionLock(manifest.repoRoot, request.taskId, `sandbox-control.${request.operation}`, async () => {
|
|
50
86
|
const artifact = await readTaskArtifact(taskDir, { artifact: input.artifact });
|
|
51
87
|
const content = artifact.bytes.toString('utf8');
|
|
88
|
+
fault('before-domain-write');
|
|
52
89
|
let result;
|
|
53
90
|
if ('operation' in input) {
|
|
54
91
|
const { family } = input;
|
|
55
92
|
if (family !== 'analysis' && family !== 'plan' && family !== 'code')
|
|
56
93
|
throw new Error('ARTIFACT_IDENTITY_INVALID');
|
|
57
94
|
const local = { taskRef: request.taskId, family, artifact: input.artifact, repoRoot: manifest.repoRoot, recoveryId: input.recoveryId, lockAlreadyHeld: true };
|
|
95
|
+
if (input.operation === 'preflight') {
|
|
96
|
+
// Preflight only seals a verified generation. It must not publish the
|
|
97
|
+
// formal artifact, set publicationStarted, or append finalizer audit.
|
|
98
|
+
const result = preflightLocalArtifact(local);
|
|
99
|
+
recoveryObservation = { family, artifact: input.artifact, wasPassed: false };
|
|
100
|
+
observeRecoveryPublication();
|
|
101
|
+
return response(result);
|
|
102
|
+
}
|
|
103
|
+
recoveryObservation = {
|
|
104
|
+
family,
|
|
105
|
+
artifact: input.artifact,
|
|
106
|
+
wasPassed: readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, family, input.artifact)?.state === 'passed'
|
|
107
|
+
};
|
|
58
108
|
const prepared = prepareLocalArtifact(local, content, lifecycleRecoveryAttestation ?? undefined);
|
|
109
|
+
observeRecoveryPublication();
|
|
110
|
+
const commitOptions = { afterPublish: () => fault('after-atomic-rename') };
|
|
59
111
|
if (prepared.result.status === 'failed')
|
|
60
|
-
return executionResult(commitLocalArtifactProvenance(prepared));
|
|
61
|
-
|
|
62
|
-
result = commitLocalArtifactProvenance(prepared);
|
|
112
|
+
return executionResult(commitLocalArtifactProvenance(prepared, commitOptions));
|
|
113
|
+
result = commitLocalArtifactProvenance(prepared, commitOptions);
|
|
63
114
|
}
|
|
64
115
|
else {
|
|
65
116
|
if (input.overrideTicket)
|
|
66
117
|
throw new Error('TASK_WORKFLOW_OVERRIDE_UNSUPPORTED');
|
|
118
|
+
const family = `review-${input.stage}`;
|
|
119
|
+
recoveryObservation = {
|
|
120
|
+
family,
|
|
121
|
+
artifact: input.artifact,
|
|
122
|
+
wasPassed: readArtifactRecoveryIntent(manifest.repoRoot, request.taskId, family, input.artifact)?.state === 'passed'
|
|
123
|
+
};
|
|
67
124
|
const prepared = prepareReviewSummaryCandidate(input, content, { repoRoot: manifest.repoRoot, lockAlreadyHeld: true, startRecovery: true });
|
|
125
|
+
observeRecoveryPublication();
|
|
68
126
|
if (input.dryRun || prepared.result.status === 'planned')
|
|
69
127
|
return executionResult(prepared.result);
|
|
128
|
+
const commitOptions = { afterPublish: () => fault('after-atomic-rename') };
|
|
70
129
|
if (prepared.result.status === 'failed')
|
|
71
|
-
return executionResult(commitReviewSummaryProvenance(prepared, manifest.repoRoot));
|
|
72
|
-
|
|
73
|
-
result = commitReviewSummaryProvenance(prepared, manifest.repoRoot);
|
|
130
|
+
return executionResult(commitReviewSummaryProvenance(prepared, manifest.repoRoot, commitOptions));
|
|
131
|
+
result = commitReviewSummaryProvenance(prepared, manifest.repoRoot, commitOptions);
|
|
74
132
|
}
|
|
133
|
+
observeRecoveryPublication();
|
|
134
|
+
publicationCommitted ||= result.changed === true;
|
|
75
135
|
if (result.status !== 'failed')
|
|
76
136
|
appendDiagnosticAudit(manifest, 'task-workflow-artifact-finalized', {
|
|
77
137
|
requestId: request.id, sandboxTaskId: request.taskId, workflowOperation: request.operation,
|
|
78
138
|
artifact: input.artifact, sha256: result.artifactSha256, semanticDigest: result.semanticDigest
|
|
79
139
|
});
|
|
80
|
-
|
|
140
|
+
fault('after-atomic-rename');
|
|
141
|
+
return response(result);
|
|
81
142
|
});
|
|
82
143
|
}
|
|
83
144
|
catch (error) {
|
|
145
|
+
observeRecoveryPublication();
|
|
84
146
|
const message = error instanceof Error ? error.message : String(error);
|
|
85
147
|
const code = error?.code ?? /^([A-Z][A-Z0-9_]+)/u.exec(message)?.[1] ?? 'TASK_WORKFLOW_REQUEST_INVALID';
|
|
86
|
-
return executionResult({ status: 'failed', changed:
|
|
148
|
+
return executionResult({ status: 'failed', changed: publicationCommitted ? null : false, error: { code, message } });
|
|
87
149
|
}
|
|
88
150
|
}
|
|
89
151
|
//# sourceMappingURL=workflow-executor.js.map
|
|
@@ -97,7 +97,7 @@ export function getProcessStartTime(pid, platform = process.platform) {
|
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
export function getProcessIdentityState(identity, platform = process.platform) {
|
|
100
|
+
export function getProcessIdentityState(identity, platform = process.platform, runtime = {}) {
|
|
101
101
|
if (!Number.isInteger(identity.pid) || identity.pid <= 0 || !Number.isSafeInteger(identity.startTime)) {
|
|
102
102
|
return 'dead';
|
|
103
103
|
}
|
|
@@ -114,8 +114,9 @@ export function getProcessIdentityState(identity, platform = process.platform) {
|
|
|
114
114
|
return error.code === 'ENOENT' ? 'dead' : 'unknown';
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
+
const probePid = runtime.probePid ?? ((pid) => process.kill(pid, 0));
|
|
117
118
|
try {
|
|
118
|
-
|
|
119
|
+
probePid(identity.pid);
|
|
119
120
|
}
|
|
120
121
|
catch (error) {
|
|
121
122
|
const code = error.code;
|
|
@@ -128,23 +129,34 @@ export function getProcessIdentityState(identity, platform = process.platform) {
|
|
|
128
129
|
if (query === null)
|
|
129
130
|
return 'unknown';
|
|
130
131
|
try {
|
|
131
|
-
const output = execFileSync(query.command, query.args, {
|
|
132
|
+
const output = (runtime.execFileSync ?? execFileSync)(query.command, query.args, {
|
|
132
133
|
encoding: 'utf8',
|
|
133
134
|
env: platform === 'darwin' ? { ...process.env, LC_ALL: 'C', LANG: 'C', TZ: 'UTC' } : process.env
|
|
134
135
|
}).trim();
|
|
135
136
|
if (output.length === 0)
|
|
136
|
-
return
|
|
137
|
+
return recheckDarwinProcessExit(identity.pid, platform, probePid);
|
|
137
138
|
const startTime = platform === 'win32'
|
|
138
139
|
? new Date(output).getTime()
|
|
139
140
|
: platform === 'darwin'
|
|
140
141
|
? parseDarwinStartTime(output)
|
|
141
142
|
: null;
|
|
142
143
|
if (startTime === null || Number.isNaN(startTime))
|
|
143
|
-
return
|
|
144
|
+
return recheckDarwinProcessExit(identity.pid, platform, probePid);
|
|
144
145
|
return startTime === identity.startTime ? 'alive' : 'dead';
|
|
145
146
|
}
|
|
146
147
|
catch {
|
|
148
|
+
return recheckDarwinProcessExit(identity.pid, platform, probePid);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function recheckDarwinProcessExit(pid, platform, probePid) {
|
|
152
|
+
if (platform !== 'darwin')
|
|
147
153
|
return 'unknown';
|
|
154
|
+
try {
|
|
155
|
+
probePid(pid);
|
|
156
|
+
return 'unknown';
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
return error.code === 'ESRCH' ? 'dead' : 'unknown';
|
|
148
160
|
}
|
|
149
161
|
}
|
|
150
162
|
function parsePidRecord(raw) {
|
|
@@ -3,18 +3,19 @@ import { resolveArtifactContext, hasOpenArtifactRound } from './artifact-lifecyc
|
|
|
3
3
|
import { parseArtifactName } from './artifact-name.js';
|
|
4
4
|
import { resolveTaskRef } from './resolve-ref.js';
|
|
5
5
|
import { getArtifactSchema } from './artifact-schema.js';
|
|
6
|
-
import { finalizeLocalArtifact } from './local-artifact-finalization.js';
|
|
6
|
+
import { finalizeLocalArtifact, preflightLocalArtifact } from './local-artifact-finalization.js';
|
|
7
7
|
import { initializeArtifactSkeleton } from './artifact-operations.js';
|
|
8
8
|
/** One option contract for the CLI and trusted projection executor. */
|
|
9
9
|
export function parseArtifactCommand(args) {
|
|
10
10
|
const [taskRef, operation] = args;
|
|
11
|
-
if (!taskRef || !['inspect', 'init', 'finalize-local'].includes(operation ?? '')) {
|
|
11
|
+
if (!taskRef || !['inspect', 'init', 'preflight', 'finalize-local'].includes(operation ?? '')) {
|
|
12
12
|
throw new Error('task ref and a supported artifact operation are required');
|
|
13
13
|
}
|
|
14
14
|
const fields = {};
|
|
15
15
|
const allowed = {
|
|
16
16
|
inspect: ['--family'],
|
|
17
17
|
init: ['--family', '--artifact', '--locale'],
|
|
18
|
+
'preflight': ['--family', '--artifact', '--recovery-id'],
|
|
18
19
|
'finalize-local': ['--family', '--artifact', '--recovery-id']
|
|
19
20
|
}[operation];
|
|
20
21
|
for (let index = 2; index < args.length; index += 1) {
|
|
@@ -72,8 +73,10 @@ export function executeArtifactCommand(command, options = {}) {
|
|
|
72
73
|
return { ...initializeArtifactSkeleton({ ...input, ...(locale ? { locale } : {}) }), ...identity };
|
|
73
74
|
}
|
|
74
75
|
if (family !== 'analysis' && family !== 'plan' && family !== 'code') {
|
|
75
|
-
return fail('ARTIFACT_PAYLOAD_INVALID', "finalize-local only
|
|
76
|
+
return fail('ARTIFACT_PAYLOAD_INVALID', "preflight and finalize-local only support 'analysis', 'plan', and 'code'");
|
|
76
77
|
}
|
|
78
|
+
if (operation === 'preflight')
|
|
79
|
+
return { ...preflightLocalArtifact({ taskRef, family, artifact, repoRoot: resolved.repoRoot, recoveryId }) };
|
|
77
80
|
return { ...finalizeLocalArtifact({ taskRef, family, artifact, repoRoot: resolved.repoRoot, recoveryId }) };
|
|
78
81
|
}
|
|
79
82
|
//# sourceMappingURL=artifact-command.js.map
|
|
@@ -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.js';
|
|
5
5
|
import { parseArtifactName } from './artifact-name.js';
|
|
6
6
|
import { scanVisibleMarkdown } from './markdown.js';
|
|
7
7
|
import { getArtifactSchema, renderArtifactSkeleton } from './artifact-schema.js';
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { writeDurableFile } from '../fs/durable-write.js';
|
|
5
|
-
import { readStableFileSync } from '
|
|
5
|
+
import { readStableFileSync } from './secure-fs.js';
|
|
6
6
|
import { parseArtifactName } from './artifact-name.js';
|
|
7
7
|
import { canonicalSemanticDigest, sha256Content } from './artifact-operations.js';
|
|
8
8
|
import { artifactRecoveryRoot, readArtifactRecoveryIntent, writeArtifactRecoveryIntent } from './artifact-repair-intent.js';
|
|
@@ -139,7 +139,7 @@ function contextPaths(tuple, options, recoveryId) {
|
|
|
139
139
|
formalPath: path.join(taskDir, tuple.artifact),
|
|
140
140
|
stagingPath: path.join(root, 'candidate.md'),
|
|
141
141
|
baselinePath: path.join(root, 'baseline.md'),
|
|
142
|
-
|
|
142
|
+
generationsPath: path.join(root, 'generations'),
|
|
143
143
|
baselineSha256: '',
|
|
144
144
|
baselineSemanticDigest: ''
|
|
145
145
|
};
|
|
@@ -167,16 +167,22 @@ function contextWithIntent(context, intent) {
|
|
|
167
167
|
}
|
|
168
168
|
function createIntent(context, now) {
|
|
169
169
|
return {
|
|
170
|
-
version:
|
|
170
|
+
version: 4,
|
|
171
171
|
taskId: context.taskId,
|
|
172
172
|
family: context.family,
|
|
173
173
|
artifact: context.artifact,
|
|
174
174
|
round: context.round,
|
|
175
|
-
state: 'awaiting-recovery',
|
|
175
|
+
state: 'awaiting-preflight-recovery',
|
|
176
176
|
baselineSha256: context.baselineSha256,
|
|
177
177
|
baselineSemanticDigest: context.baselineSemanticDigest,
|
|
178
178
|
stagingId: context.stagingId,
|
|
179
179
|
candidateSha256: context.baselineSha256,
|
|
180
|
+
preflightArtifactSha256: null,
|
|
181
|
+
preflightSemanticDigest: null,
|
|
182
|
+
activeGenerationSha256: null,
|
|
183
|
+
activeGenerationSemanticDigest: null,
|
|
184
|
+
pendingGenerationSha256: null,
|
|
185
|
+
pendingGenerationSemanticDigest: null,
|
|
180
186
|
finalArtifactSha256: null,
|
|
181
187
|
finalSemanticDigest: null,
|
|
182
188
|
recoveryOperationId: context.recoveryId,
|
|
@@ -209,7 +215,7 @@ export function beginArtifactRecovery(tuple, baselineBytes, options) {
|
|
|
209
215
|
fail('ARTIFACT_RECOVERY_BASELINE_MISMATCH', 'formal artifact does not match the recovery baseline');
|
|
210
216
|
}
|
|
211
217
|
const existing = readArtifactRecoveryIntent(prepared.repoRoot, prepared.taskId, prepared.family, prepared.artifact);
|
|
212
|
-
if (existing &&
|
|
218
|
+
if (existing && !['aborted', 'consumed', 'passed'].includes(existing.state)) {
|
|
213
219
|
fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
|
|
214
220
|
}
|
|
215
221
|
writeBytes(prepared.baselinePath, baselineBytes);
|
|
@@ -225,6 +231,7 @@ export function recordArtifactRecoveryPassed(tuple, options) {
|
|
|
225
231
|
assertRecoveryId(context.recoveryId);
|
|
226
232
|
assertRegular(context.formalPath, 'formal artifact');
|
|
227
233
|
return runLocked(context, 'task-artifact.recovery.record-passed', () => {
|
|
234
|
+
assertRecoveryRoot(context);
|
|
228
235
|
const current = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
229
236
|
const prepared = {
|
|
230
237
|
...context,
|
|
@@ -244,18 +251,18 @@ export function recordArtifactRecoveryPassed(tuple, options) {
|
|
|
244
251
|
&& existing.finalSemanticDigest === prepared.baselineSemanticDigest) {
|
|
245
252
|
return recoveryContextFromIntent(prepared.repoRoot, prepared.taskDir, existing);
|
|
246
253
|
}
|
|
247
|
-
if (existing && existing.state !== 'aborted') {
|
|
254
|
+
if (existing && existing.state !== 'aborted' && existing.state !== 'passed') {
|
|
248
255
|
fail('ARTIFACT_RECOVERY_CONFLICT', 'an active recovery journal already exists for this artifact');
|
|
249
256
|
}
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
};
|
|
258
|
-
|
|
257
|
+
const base = createIntent(prepared, Date.now());
|
|
258
|
+
writeBytes(prepared.baselinePath, current.bytes);
|
|
259
|
+
writeBytes(prepared.stagingPath, current.bytes);
|
|
260
|
+
writeArtifactRecoveryIntent(prepared.repoRoot, base, { expected: existing });
|
|
261
|
+
const staged = stageArtifactCandidate(prepared, current.bytes, { lockAlreadyHeld: true });
|
|
262
|
+
prepareArtifactRecoveryCommit(prepared, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: true });
|
|
263
|
+
commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
|
|
264
|
+
prepareArtifactRecoveryFinal(prepared, current.bytes, { lockAlreadyHeld: true });
|
|
265
|
+
commitArtifactRecovery(prepared, { lockAlreadyHeld: true });
|
|
259
266
|
return prepared;
|
|
260
267
|
}, options.lockAlreadyHeld);
|
|
261
268
|
}
|
|
@@ -263,7 +270,7 @@ export function stageArtifactCandidate(context, candidateBytes, options = {}) {
|
|
|
263
270
|
return runLocked(context, 'task-artifact.recovery.stage', () => {
|
|
264
271
|
assertRecoveryRoot(context);
|
|
265
272
|
const intent = readIntentForContext(context);
|
|
266
|
-
if (intent.state !== 'awaiting-recovery')
|
|
273
|
+
if (intent.state !== 'awaiting-preflight-recovery')
|
|
267
274
|
fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot stage a candidate from '${intent.state}'`);
|
|
268
275
|
const candidateSha256 = sha256Content(candidateBytes.toString('utf8'));
|
|
269
276
|
const semanticDigest = canonicalSemanticDigest(candidateBytes.toString('utf8'));
|
|
@@ -273,30 +280,69 @@ export function stageArtifactCandidate(context, candidateBytes, options = {}) {
|
|
|
273
280
|
return { recoveryId: context.recoveryId, candidateSha256, semanticDigest, path: context.stagingPath };
|
|
274
281
|
}, options.lockAlreadyHeld);
|
|
275
282
|
}
|
|
283
|
+
function generationPath(context, sha256) {
|
|
284
|
+
assertDigest(sha256, 'generation digest');
|
|
285
|
+
return path.join(context.generationsPath, `${sha256}.md`);
|
|
286
|
+
}
|
|
287
|
+
/** Persist and verify an immutable digest-addressed snapshot before it is journaled. */
|
|
288
|
+
function writeGeneration(context, bytes, sha256, semanticDigest) {
|
|
289
|
+
ensureOwnedDirectoryTree(context.repoRoot, context.generationsPath);
|
|
290
|
+
const target = generationPath(context, sha256);
|
|
291
|
+
if (fs.existsSync(target)) {
|
|
292
|
+
const existing = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
|
|
293
|
+
if (canonicalSemanticDigest(existing.bytes.toString('utf8')) !== semanticDigest) {
|
|
294
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'existing generation does not match its semantic digest');
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
writeBytes(target, bytes);
|
|
299
|
+
fs.chmodSync(target, 0o400);
|
|
300
|
+
const sealed = readStableFileSync(target, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
|
|
301
|
+
if (canonicalSemanticDigest(sealed.bytes.toString('utf8')) !== semanticDigest) {
|
|
302
|
+
fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'generation does not match its validated semantic digest');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function publishGeneration(context, sha256, semanticDigest, baselineSha256) {
|
|
306
|
+
const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
307
|
+
const generation = readStableFileSync(generationPath(context, sha256), { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
|
|
308
|
+
if (canonicalSemanticDigest(generation.bytes.toString('utf8')) !== semanticDigest) {
|
|
309
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'generation semantic digest does not match the journal');
|
|
310
|
+
}
|
|
311
|
+
if (target.sha256 === sha256)
|
|
312
|
+
return;
|
|
313
|
+
if (target.sha256 !== baselineSha256)
|
|
314
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'formal artifact changed outside the expected generation');
|
|
315
|
+
const publishPath = path.join(path.dirname(context.stagingPath), 'publish.md');
|
|
316
|
+
unlinkIfPresent(publishPath);
|
|
317
|
+
writeBytes(publishPath, generation.bytes);
|
|
318
|
+
fs.chmodSync(publishPath, 0o400);
|
|
319
|
+
fs.renameSync(publishPath, context.formalPath);
|
|
320
|
+
const published = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES, expectedSha256: sha256 });
|
|
321
|
+
if (canonicalSemanticDigest(published.bytes.toString('utf8')) !== semanticDigest) {
|
|
322
|
+
fail('ARTIFACT_RECOVERY_CONFLICT', 'published artifact does not match its generation');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
276
325
|
export function prepareArtifactRecoveryCommit(context, finalSha256, finalSemanticDigest, options = {}) {
|
|
277
326
|
assertDigest(finalSha256, 'final artifact digest');
|
|
278
327
|
assertDigest(finalSemanticDigest, 'final semantic digest');
|
|
279
328
|
return runLocked(context, 'task-artifact.recovery.prepare', () => {
|
|
280
329
|
const intent = readIntentForContext(context);
|
|
281
|
-
if (intent.state !== 'awaiting-recovery')
|
|
282
|
-
fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a
|
|
330
|
+
if (intent.state !== 'awaiting-preflight-recovery')
|
|
331
|
+
fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot prepare a preflight from '${intent.state}'`);
|
|
283
332
|
assertRecoveryRoot(context);
|
|
284
333
|
const staged = readStableFileSync(context.stagingPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
285
334
|
if (staged.sha256 !== finalSha256 || canonicalSemanticDigest(staged.bytes.toString('utf8')) !== finalSemanticDigest) {
|
|
286
335
|
fail('ARTIFACT_RECOVERY_CANDIDATE_MISMATCH', 'staged candidate does not match the finalizer digest');
|
|
287
336
|
}
|
|
288
|
-
|
|
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
|
-
}
|
|
337
|
+
writeGeneration(context, staged.bytes, finalSha256, finalSemanticDigest);
|
|
294
338
|
const next = {
|
|
295
339
|
...intent,
|
|
296
|
-
state: '
|
|
340
|
+
state: 'preflight-ready',
|
|
297
341
|
candidateSha256: staged.sha256,
|
|
298
|
-
|
|
299
|
-
finalSemanticDigest,
|
|
342
|
+
preflightArtifactSha256: finalSha256,
|
|
343
|
+
preflightSemanticDigest: finalSemanticDigest,
|
|
344
|
+
activeGenerationSha256: finalSha256,
|
|
345
|
+
activeGenerationSemanticDigest: finalSemanticDigest,
|
|
300
346
|
errorCode: null,
|
|
301
347
|
errorMessage: null,
|
|
302
348
|
updatedAt: Date.now()
|
|
@@ -306,11 +352,11 @@ export function prepareArtifactRecoveryCommit(context, finalSha256, finalSemanti
|
|
|
306
352
|
}, options.lockAlreadyHeld);
|
|
307
353
|
}
|
|
308
354
|
function markCommitStarted(context, intent) {
|
|
309
|
-
if (intent.state === 'commit-started')
|
|
355
|
+
if (intent.state === 'preflight-commit-started' || intent.state === 'commit-started')
|
|
310
356
|
return intent;
|
|
311
|
-
if (intent.state !== '
|
|
357
|
+
if (intent.state !== 'preflight-ready' && intent.state !== 'full-finalizer-ready')
|
|
312
358
|
fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot commit from '${intent.state}'`);
|
|
313
|
-
const next = { ...intent, state: 'commit-started', updatedAt: Date.now() };
|
|
359
|
+
const next = { ...intent, state: intent.state === 'preflight-ready' ? 'preflight-commit-started' : 'commit-started', updatedAt: Date.now() };
|
|
314
360
|
writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
|
|
315
361
|
return next;
|
|
316
362
|
}
|
|
@@ -321,44 +367,41 @@ export function commitArtifactRecovery(context, options = {}) {
|
|
|
321
367
|
return intent;
|
|
322
368
|
assertRecoveryRoot(context);
|
|
323
369
|
intent = markCommitStarted(context, intent);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const passed = { ...intent, state: 'passed', updatedAt: Date.now() };
|
|
370
|
+
if (intent.state === 'preflight-commit-started') {
|
|
371
|
+
publishGeneration(context, intent.activeGenerationSha256, intent.activeGenerationSemanticDigest, intent.baselineSha256);
|
|
372
|
+
const passed = { ...intent, state: 'preflight-passed', updatedAt: Date.now() };
|
|
327
373
|
writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
|
|
328
374
|
return passed;
|
|
329
375
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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() };
|
|
376
|
+
publishGeneration(context, intent.pendingGenerationSha256, intent.pendingGenerationSemanticDigest, intent.activeGenerationSha256);
|
|
377
|
+
options.afterPublish?.();
|
|
378
|
+
const passed = {
|
|
379
|
+
...intent, state: 'passed', activeGenerationSha256: intent.pendingGenerationSha256,
|
|
380
|
+
activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest,
|
|
381
|
+
pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now()
|
|
382
|
+
};
|
|
358
383
|
writeArtifactRecoveryIntent(context.repoRoot, passed, { expected: intent });
|
|
359
384
|
return passed;
|
|
360
385
|
}, options.lockAlreadyHeld);
|
|
361
386
|
}
|
|
387
|
+
export function prepareArtifactRecoveryFinal(context, finalBytes, options = {}) {
|
|
388
|
+
return runLocked(context, 'task-artifact.recovery.finalize', () => {
|
|
389
|
+
const intent = readIntentForContext(context);
|
|
390
|
+
if (intent.state !== 'preflight-passed')
|
|
391
|
+
fail('ARTIFACT_RECOVERY_STATE_INVALID', `cannot finalize from '${intent.state}'`);
|
|
392
|
+
const finalSha256 = sha256Content(finalBytes.toString('utf8'));
|
|
393
|
+
const finalSemanticDigest = canonicalSemanticDigest(finalBytes.toString('utf8'));
|
|
394
|
+
if (finalSha256 !== intent.activeGenerationSha256)
|
|
395
|
+
writeGeneration(context, finalBytes, finalSha256, finalSemanticDigest);
|
|
396
|
+
const next = {
|
|
397
|
+
...intent, state: 'full-finalizer-ready', pendingGenerationSha256: finalSha256,
|
|
398
|
+
pendingGenerationSemanticDigest: finalSemanticDigest, finalArtifactSha256: finalSha256,
|
|
399
|
+
finalSemanticDigest, updatedAt: Date.now()
|
|
400
|
+
};
|
|
401
|
+
writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
|
|
402
|
+
return next;
|
|
403
|
+
}, options.lockAlreadyHeld);
|
|
404
|
+
}
|
|
362
405
|
export function abortArtifactRecovery(context, code, message, options = {}) {
|
|
363
406
|
return runLocked(context, 'task-artifact.recovery.abort', () => {
|
|
364
407
|
const intent = readIntentForContext(context);
|
|
@@ -383,16 +426,19 @@ export function abortArtifactRecovery(context, code, message, options = {}) {
|
|
|
383
426
|
export function reconcileArtifactRecovery(context, options = {}) {
|
|
384
427
|
return runLocked(context, 'task-artifact.recovery.reconcile', () => {
|
|
385
428
|
const intent = readIntentForContext(context);
|
|
386
|
-
if (intent.state !== 'commit-started')
|
|
429
|
+
if (intent.state !== 'preflight-commit-started' && intent.state !== 'commit-started')
|
|
387
430
|
return { status: intent.state, intent };
|
|
388
431
|
const target = readStableFileSync(context.formalPath, { maxBytes: MAX_ARTIFACT_BYTES });
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
432
|
+
const expected = intent.state === 'preflight-commit-started' ? intent.activeGenerationSha256 : intent.pendingGenerationSha256;
|
|
433
|
+
if (target.sha256 === expected && (options.validateFinal?.() ?? true)) {
|
|
434
|
+
const next = intent.state === 'preflight-commit-started'
|
|
435
|
+
? { ...intent, state: 'preflight-passed', updatedAt: Date.now() }
|
|
436
|
+
: { ...intent, state: 'passed', activeGenerationSha256: intent.pendingGenerationSha256, activeGenerationSemanticDigest: intent.pendingGenerationSemanticDigest, pendingGenerationSha256: null, pendingGenerationSemanticDigest: null, updatedAt: Date.now() };
|
|
437
|
+
writeArtifactRecoveryIntent(context.repoRoot, next, { expected: intent });
|
|
438
|
+
return { status: next.state, intent: next };
|
|
393
439
|
}
|
|
394
|
-
if (target.sha256 === intent.baselineSha256) {
|
|
395
|
-
const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true });
|
|
440
|
+
if (target.sha256 === (intent.state === 'preflight-commit-started' ? intent.baselineSha256 : intent.activeGenerationSha256)) {
|
|
441
|
+
const retried = commitArtifactRecovery(context, { lockAlreadyHeld: true, afterPublish: options.afterPublish });
|
|
396
442
|
return { status: retried.state, intent: retried };
|
|
397
443
|
}
|
|
398
444
|
if (options.restoreBaseline) {
|