@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
|
@@ -14,11 +14,14 @@ import { allowsManualOverride } from './guard-override.ts';
|
|
|
14
14
|
import type { ManualOverrideCapability } from './guard-override.ts';
|
|
15
15
|
import { getArtifactSchema } from './artifact-schema.ts';
|
|
16
16
|
import { canonicalSemanticDigest, inspectArtifactContract, sha256Content } from './artifact-operations.ts';
|
|
17
|
+
import { expectedQualificationRelations, validateQualificationAudit } from './qualification-audit.ts';
|
|
17
18
|
import { readArtifactRecoveryIntent } from './artifact-repair-intent.ts';
|
|
18
19
|
import {
|
|
19
20
|
beginArtifactRecovery,
|
|
20
21
|
commitArtifactRecovery,
|
|
22
|
+
prepareArtifactRecoveryFinal,
|
|
21
23
|
prepareArtifactRecoveryCommit,
|
|
24
|
+
reconcileArtifactRecovery,
|
|
22
25
|
recoveryContextFromIntent,
|
|
23
26
|
stageArtifactCandidate
|
|
24
27
|
} from './artifact-recovery.ts';
|
|
@@ -36,6 +39,7 @@ type ReviewFinalizationErrorCode =
|
|
|
36
39
|
| 'REVIEW_SUMMARY_PLACEHOLDER_INVALID'
|
|
37
40
|
| 'REVIEW_SUMMARY_COUNT_MISMATCH'
|
|
38
41
|
| 'REVIEW_ARTIFACT_STRUCTURE_INVALID'
|
|
42
|
+
| 'REVIEW_ARTIFACT_QUALIFICATION_INVALID'
|
|
39
43
|
| 'REVIEW_DECISION_DETAIL_INVALID'
|
|
40
44
|
| 'REVIEW_ARTIFACT_CONFLICT'
|
|
41
45
|
| 'REVIEW_PROVENANCE_INVALID'
|
|
@@ -73,6 +77,11 @@ type ReviewFinalizationResult = {
|
|
|
73
77
|
}[];
|
|
74
78
|
error: ReviewFinalizationError | null;
|
|
75
79
|
};
|
|
80
|
+
type ReviewPreflightResult = Omit<ReviewFinalizationResult, 'status' | 'intent' | 'stageStatus'> & {
|
|
81
|
+
status: 'passed' | 'failed';
|
|
82
|
+
intent: 'preflight';
|
|
83
|
+
stageStatus: null;
|
|
84
|
+
};
|
|
76
85
|
type ReviewFileSystem = {
|
|
77
86
|
readFileSync: (file: string) => string;
|
|
78
87
|
};
|
|
@@ -118,6 +127,149 @@ function failed(
|
|
|
118
127
|
};
|
|
119
128
|
}
|
|
120
129
|
|
|
130
|
+
function preflightFailed(
|
|
131
|
+
request: ReviewFinalizationRequest,
|
|
132
|
+
code: ReviewFinalizationErrorCode,
|
|
133
|
+
message: string,
|
|
134
|
+
taskId: string | null = null,
|
|
135
|
+
extra: Partial<ReviewPreflightResult> = {}
|
|
136
|
+
): ReviewPreflightResult {
|
|
137
|
+
const { status: _status, intent: _intent, stageStatus: _stageStatus, ...finalizationExtra } = extra;
|
|
138
|
+
return {
|
|
139
|
+
...failed(request, code, message, taskId, null, finalizationExtra),
|
|
140
|
+
status: 'failed',
|
|
141
|
+
intent: 'preflight',
|
|
142
|
+
stageStatus: null
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Validate and seal a review draft before any finding-ledger write. */
|
|
147
|
+
function preflightReviewSummaryUnlocked(
|
|
148
|
+
request: ReviewFinalizationRequest,
|
|
149
|
+
options: ReviewFinalizationOptions = {}
|
|
150
|
+
): ReviewPreflightResult {
|
|
151
|
+
const stage = request.stage as ReviewStage;
|
|
152
|
+
const spec = STAGES[stage];
|
|
153
|
+
if (!spec) return preflightFailed(request, 'REVIEW_STAGE_INVALID', `unsupported review stage '${request.stage}'`);
|
|
154
|
+
if (!request.taskRef || !request.artifact) return preflightFailed(request, 'REVIEW_PAYLOAD_INVALID', 'taskRef, stage, and artifact are required');
|
|
155
|
+
const resolved = resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
|
|
156
|
+
if (!resolved.ok) return preflightFailed(request, resolved.code, resolved.message, resolved.taskId ?? null);
|
|
157
|
+
if (resolved.state !== 'active') return preflightFailed(request, 'TASK_STATE_MISMATCH', `task ${resolved.taskId} is ${resolved.state}, expected active`, resolved.taskId);
|
|
158
|
+
const parsed = parseArtifactName(request.artifact);
|
|
159
|
+
if (!parsed || parsed.family !== spec.family) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', `artifact '${request.artifact}' does not match ${spec.family}`, resolved.taskId);
|
|
160
|
+
const publicationError = validateArtifactPublication(resolved.taskDir, spec.family, request.artifact);
|
|
161
|
+
if (publicationError) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', publicationError.message, resolved.taskId);
|
|
162
|
+
|
|
163
|
+
let taskContent: string;
|
|
164
|
+
let content: string;
|
|
165
|
+
let recovery: ArtifactRecoveryContext | undefined;
|
|
166
|
+
let existingIntent;
|
|
167
|
+
try {
|
|
168
|
+
taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
|
|
169
|
+
if (request.recoveryId) {
|
|
170
|
+
const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, spec.family, request.artifact);
|
|
171
|
+
if (!intent || intent.recoveryOperationId !== request.recoveryId) return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', 'recovery id does not match the artifact journal', resolved.taskId);
|
|
172
|
+
if (!['awaiting-preflight-recovery', 'preflight-ready'].includes(intent.state)) return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', `recovery journal is in '${intent.state}' state`, resolved.taskId);
|
|
173
|
+
recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
|
|
174
|
+
content = fs.existsSync(recovery.stagingPath)
|
|
175
|
+
? fs.readFileSync(recovery.stagingPath, 'utf8')
|
|
176
|
+
: fs.readFileSync(recovery.formalPath, 'utf8');
|
|
177
|
+
} else {
|
|
178
|
+
const validated = validateCompletedArtifact(resolved.taskDir, spec.family, request.artifact);
|
|
179
|
+
if (!validated.ok) return preflightFailed(request, validated.error.code === 'ARTIFACT_NOT_REGULAR' ? 'REVIEW_ARTIFACT_NOT_REGULAR' : 'REVIEW_ARTIFACT_IDENTITY_INVALID', validated.error.message, resolved.taskId);
|
|
180
|
+
content = fs.readFileSync(validated.artifact.path, 'utf8');
|
|
181
|
+
const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, spec.family, request.artifact);
|
|
182
|
+
existingIntent = intent;
|
|
183
|
+
if (intent?.state === 'preflight-ready') recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
|
|
184
|
+
if (intent?.state === 'consumed') return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', 'review artifact was already finalized', resolved.taskId);
|
|
185
|
+
}
|
|
186
|
+
} catch (error) { return preflightFailed(request, 'REVIEW_ARTIFACT_NOT_REGULAR', String(error), resolved.taskId); }
|
|
187
|
+
|
|
188
|
+
if (!hasOpenArtifactRound(taskContent, spec.family, parsed.round)) return preflightFailed(request, 'REVIEW_ARTIFACT_IDENTITY_INVALID', `${request.artifact} does not have one matching open started review event`, resolved.taskId);
|
|
189
|
+
const execution = validateLifecycleExecution(request.taskRef, {
|
|
190
|
+
mode: request.orchestrated ? 'orchestrated' : 'standalone',
|
|
191
|
+
identity: { stage: spec.family, round: parsed.round, artifact: request.artifact, role: 'reviewer' },
|
|
192
|
+
dryRun: request.dryRun
|
|
193
|
+
}, { repoRoot: options.repoRoot });
|
|
194
|
+
if (!execution.ok) return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', `${execution.error?.code ?? 'ORCHESTRATION_PROVENANCE_MISMATCH'}: ${execution.error?.message ?? 'orchestration provenance validation failed'}`, resolved.taskId);
|
|
195
|
+
|
|
196
|
+
const artifactSha256 = sha256Content(content);
|
|
197
|
+
const semanticDigest = canonicalSemanticDigest(content);
|
|
198
|
+
const detail = inspectDecisionDetailDuplicates(content);
|
|
199
|
+
const structure = inspectArtifactContract(content, getArtifactSchema(spec.family)!);
|
|
200
|
+
const expected = expectedQualificationRelations(taskContent, spec.family);
|
|
201
|
+
if (!expected.ok) return preflightFailed(request, 'REVIEW_ARTIFACT_QUALIFICATION_INVALID', `${expected.code}: ${expected.message}`, resolved.taskId, { artifactSha256, semanticDigest });
|
|
202
|
+
const qualification = validateQualificationAudit(taskContent, content, {
|
|
203
|
+
family: spec.family,
|
|
204
|
+
artifact: request.artifact,
|
|
205
|
+
expectedUpstreamRelations: expected.relations
|
|
206
|
+
});
|
|
207
|
+
const qualificationError = qualification.ok ? null : qualification;
|
|
208
|
+
if (!detail.ok || !structure.ok || qualificationError) {
|
|
209
|
+
if (!recovery && !request.dryRun) {
|
|
210
|
+
try {
|
|
211
|
+
recovery = beginArtifactRecovery({
|
|
212
|
+
taskId: resolved.taskId, family: spec.family, artifact: request.artifact, round: parsed.round,
|
|
213
|
+
requestId: `review-preflight:${resolved.taskId}:${stage}:${parsed.round}`
|
|
214
|
+
}, Buffer.from(content, 'utf8'), { repoRoot: resolved.repoRoot, taskDir: resolved.taskDir, lockAlreadyHeld: options.lockAlreadyHeld });
|
|
215
|
+
} catch (error) { return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', String(error), resolved.taskId); }
|
|
216
|
+
}
|
|
217
|
+
const diagnostics = [
|
|
218
|
+
...(!detail.ok ? [`${detail.code}: ${detail.message}`] : []),
|
|
219
|
+
...structure.diagnostics.map((item) => `${item.code}: ${item.message}`),
|
|
220
|
+
...(qualificationError ? [`${qualificationError.code}: ${qualificationError.message}`] : [])
|
|
221
|
+
].join('; ');
|
|
222
|
+
return preflightFailed(request, !detail.ok ? 'REVIEW_DECISION_DETAIL_INVALID' : !structure.ok ? 'REVIEW_ARTIFACT_STRUCTURE_INVALID' : 'REVIEW_ARTIFACT_QUALIFICATION_INVALID', diagnostics, resolved.taskId, {
|
|
223
|
+
artifactSha256, semanticDigest,
|
|
224
|
+
...(recovery ? { recovery: recoveryInfo(recovery) } : {})
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (!recovery && existingIntent?.state === 'passed') {
|
|
228
|
+
return {
|
|
229
|
+
...preflightFailed(request, 'REVIEW_ARTIFACT_CONFLICT', '', resolved.taskId, { artifactSha256, semanticDigest }),
|
|
230
|
+
status: 'passed', changed: false, error: null
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (request.dryRun || recovery && readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, spec.family, request.artifact)?.state === 'preflight-ready') {
|
|
234
|
+
return {
|
|
235
|
+
...preflightFailed(request, 'REVIEW_ARTIFACT_CONFLICT', '', resolved.taskId, { artifactSha256, semanticDigest, ...(recovery ? { recovery: recoveryInfo(recovery) } : {}) }),
|
|
236
|
+
status: 'passed', changed: false, error: null
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
recovery ??= beginArtifactRecovery({
|
|
241
|
+
taskId: resolved.taskId, family: spec.family, artifact: request.artifact, round: parsed.round,
|
|
242
|
+
requestId: `review-preflight:${resolved.taskId}:${stage}:${parsed.round}`
|
|
243
|
+
}, Buffer.from(content, 'utf8'), { repoRoot: resolved.repoRoot, taskDir: resolved.taskDir, lockAlreadyHeld: options.lockAlreadyHeld });
|
|
244
|
+
const staged = stageArtifactCandidate(recovery, Buffer.from(content, 'utf8'), { lockAlreadyHeld: options.lockAlreadyHeld });
|
|
245
|
+
prepareArtifactRecoveryCommit(recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: options.lockAlreadyHeld });
|
|
246
|
+
return {
|
|
247
|
+
...preflightFailed(request, 'REVIEW_ARTIFACT_CONFLICT', '', resolved.taskId, { artifactSha256, semanticDigest, recovery: recoveryInfo(recovery) }),
|
|
248
|
+
status: 'passed', changed: false, error: null
|
|
249
|
+
};
|
|
250
|
+
} catch (error) { return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', String(error), resolved.taskId, { artifactSha256, semanticDigest }); }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function preflightReviewSummary(
|
|
254
|
+
request: ReviewFinalizationRequest,
|
|
255
|
+
options: ReviewFinalizationOptions = {}
|
|
256
|
+
): ReviewPreflightResult {
|
|
257
|
+
if (request.dryRun || options.lockAlreadyHeld) return preflightReviewSummaryUnlocked(request, options);
|
|
258
|
+
const resolved = resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
|
|
259
|
+
if (!resolved.ok) return preflightReviewSummaryUnlocked(request, options);
|
|
260
|
+
try {
|
|
261
|
+
return withTaskExecutionLock(
|
|
262
|
+
resolved.repoRoot,
|
|
263
|
+
resolved.taskId,
|
|
264
|
+
'task-review.preflight',
|
|
265
|
+
() => preflightReviewSummaryUnlocked(request, { ...options, lockAlreadyHeld: true })
|
|
266
|
+
);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (!(error instanceof TaskExecutionLockError)) throw error;
|
|
269
|
+
return preflightFailed(request, 'REVIEW_PROVENANCE_INVALID', `${error.code}: ${error.message}`, resolved.taskId);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
121
273
|
type ReviewSummaryCandidatePreparation = Readonly<{
|
|
122
274
|
result: ReviewFinalizationResult;
|
|
123
275
|
content: string;
|
|
@@ -125,6 +277,15 @@ type ReviewSummaryCandidatePreparation = Readonly<{
|
|
|
125
277
|
lockAlreadyHeld?: boolean;
|
|
126
278
|
}>;
|
|
127
279
|
|
|
280
|
+
function recoveryInfo(recovery: ArtifactRecoveryContext): NonNullable<ReviewFinalizationResult['recovery']> {
|
|
281
|
+
return {
|
|
282
|
+
recoveryId: recovery.recoveryId,
|
|
283
|
+
candidatePath: recovery.stagingPath,
|
|
284
|
+
baselineSha256: recovery.baselineSha256,
|
|
285
|
+
baselineSemanticDigest: recovery.baselineSemanticDigest
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
128
289
|
/** Prepare domain validation, summary bytes and provenance before any publication. */
|
|
129
290
|
function prepareReviewSummaryCandidate(
|
|
130
291
|
request: ReviewFinalizationRequest,
|
|
@@ -158,7 +319,7 @@ function prepareReviewSummaryCandidate(
|
|
|
158
319
|
try {
|
|
159
320
|
const intent = readArtifactRecoveryIntent(resolved.repoRoot, taskId!, spec.family, request.artifact);
|
|
160
321
|
if (!intent || intent.recoveryOperationId !== request.recoveryId) return reject('REVIEW_PROVENANCE_INVALID', 'recovery id does not match the artifact journal');
|
|
161
|
-
if (!['awaiting-recovery', '
|
|
322
|
+
if (!['awaiting-preflight-recovery', 'preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready', 'commit-started'].includes(intent.state)) return reject('REVIEW_PROVENANCE_INVALID', `recovery journal is in '${intent.state}' state`);
|
|
162
323
|
recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
|
|
163
324
|
artifactContent = fs.existsSync(recovery.stagingPath)
|
|
164
325
|
? fs.readFileSync(recovery.stagingPath, 'utf8')
|
|
@@ -171,11 +332,15 @@ function prepareReviewSummaryCandidate(
|
|
|
171
332
|
let repairIntent;
|
|
172
333
|
try { repairIntent = readArtifactRecoveryIntent(resolved.repoRoot, taskId!, spec.family, request.artifact); }
|
|
173
334
|
catch (error) { return reject('REVIEW_PROVENANCE_INVALID', String(error), digests); }
|
|
174
|
-
if (repairIntent?.state === '
|
|
335
|
+
if (repairIntent?.state === 'consumed') {
|
|
175
336
|
if (repairIntent.finalArtifactSha256 !== artifactSha256 || repairIntent.finalSemanticDigest !== semanticDigest) {
|
|
176
337
|
return reject('REVIEW_PROVENANCE_INVALID', 'the review artifact changed after its finalization provenance was recorded', digests);
|
|
177
338
|
}
|
|
178
339
|
}
|
|
340
|
+
if (repairIntent?.state === 'passed'
|
|
341
|
+
&& (repairIntent.finalArtifactSha256 !== artifactSha256 || repairIntent.finalSemanticDigest !== semanticDigest)) {
|
|
342
|
+
repairIntent = undefined;
|
|
343
|
+
}
|
|
179
344
|
if (!hasOpenArtifactRound(taskContent, spec.family, parsed.round)) {
|
|
180
345
|
return reject('REVIEW_ARTIFACT_IDENTITY_INVALID', `${request.artifact} does not have one matching open started review event`);
|
|
181
346
|
}
|
|
@@ -223,6 +388,12 @@ function prepareReviewSummaryCandidate(
|
|
|
223
388
|
const transformed = finalizeReviewSummaryContent(artifactContent, stageStatus.unresolvedFindingCounts);
|
|
224
389
|
if (!transformed.ok) return reject(transformed.code, transformed.message);
|
|
225
390
|
const finalDigests = { artifactSha256: sha256Content(transformed.content), semanticDigest: canonicalSemanticDigest(transformed.content) };
|
|
391
|
+
if (!recovery && repairIntent?.state === 'commit-started') {
|
|
392
|
+
if (repairIntent.finalArtifactSha256 !== finalDigests.artifactSha256 || repairIntent.finalSemanticDigest !== finalDigests.semanticDigest) {
|
|
393
|
+
return reject('REVIEW_PROVENANCE_INVALID', 'formal review artifact does not match the interrupted recovery journal', finalDigests);
|
|
394
|
+
}
|
|
395
|
+
recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, repairIntent);
|
|
396
|
+
}
|
|
226
397
|
if (!recovery && (repairIntent?.state === 'passed' || repairIntent?.state === 'consumed')) {
|
|
227
398
|
if (transformed.changed) return reject('REVIEW_PROVENANCE_INVALID', 'the review artifact requires changes after its completed recovery was consumed', finalDigests);
|
|
228
399
|
return {
|
|
@@ -260,21 +431,43 @@ function prepareReviewSummaryCandidate(
|
|
|
260
431
|
|
|
261
432
|
function commitReviewSummaryProvenance(
|
|
262
433
|
prepared: ReviewSummaryCandidatePreparation,
|
|
263
|
-
repoRoot: string
|
|
434
|
+
repoRoot: string,
|
|
435
|
+
options: Readonly<{ afterPublish?: () => void }> = {}
|
|
264
436
|
): ReviewFinalizationResult {
|
|
265
437
|
if (prepared.result.status === 'failed' || !prepared.recovery) return prepared.result;
|
|
266
438
|
try {
|
|
267
439
|
const intent = readArtifactRecoveryIntent(prepared.recovery.repoRoot, prepared.recovery.taskId, prepared.recovery.family, prepared.recovery.artifact);
|
|
268
440
|
if (!intent) throw new Error('ARTIFACT_RECOVERY_INTENT_MISSING');
|
|
269
441
|
let committed;
|
|
270
|
-
if (intent.state === '
|
|
271
|
-
|
|
272
|
-
|
|
442
|
+
if (intent.state === 'passed' || intent.state === 'consumed') {
|
|
443
|
+
return {
|
|
444
|
+
...prepared.result,
|
|
445
|
+
status: 'no-op',
|
|
446
|
+
changed: false,
|
|
447
|
+
artifactSha256: intent.finalArtifactSha256,
|
|
448
|
+
semanticDigest: intent.finalSemanticDigest,
|
|
449
|
+
error: null
|
|
450
|
+
};
|
|
451
|
+
} else if (intent.state === 'commit-started') {
|
|
452
|
+
const reconciled = reconcileArtifactRecovery(prepared.recovery, {
|
|
453
|
+
lockAlreadyHeld: prepared.lockAlreadyHeld,
|
|
454
|
+
afterPublish: options.afterPublish
|
|
455
|
+
});
|
|
456
|
+
committed = reconciled.intent;
|
|
457
|
+
} else if (intent.state === 'awaiting-preflight-recovery') {
|
|
273
458
|
const staged = stageArtifactCandidate(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
274
459
|
prepareArtifactRecoveryCommit(prepared.recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
275
460
|
committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
461
|
+
} else if (intent.state === 'preflight-ready' || intent.state === 'preflight-commit-started') {
|
|
462
|
+
committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
463
|
+
}
|
|
464
|
+
if (committed?.state === 'preflight-passed' || intent.state === 'preflight-passed') {
|
|
465
|
+
prepareArtifactRecoveryFinal(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
466
|
+
committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
|
|
467
|
+
} else if (intent.state === 'full-finalizer-ready') {
|
|
468
|
+
committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
|
|
276
469
|
}
|
|
277
|
-
if (committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed.state}'`);
|
|
470
|
+
if (!committed || committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed?.state ?? intent.state}'`);
|
|
278
471
|
return {
|
|
279
472
|
...prepared.result,
|
|
280
473
|
artifactSha256: committed.finalArtifactSha256,
|
|
@@ -282,6 +475,7 @@ function commitReviewSummaryProvenance(
|
|
|
282
475
|
error: null
|
|
283
476
|
};
|
|
284
477
|
} catch (error) {
|
|
478
|
+
if (error instanceof Error && error.message.startsWith('TASK_WORKFLOW_FAULT_INJECTED:')) throw error;
|
|
285
479
|
return { ...prepared.result, status: 'failed', error: {
|
|
286
480
|
code: 'REVIEW_RECOVERY_COMMIT_FAILED', message: `cannot publish review candidate: ${String(error)}`
|
|
287
481
|
} };
|
|
@@ -335,12 +529,13 @@ function finalizeReviewSummary(
|
|
|
335
529
|
}
|
|
336
530
|
}
|
|
337
531
|
|
|
338
|
-
export { finalizeReviewSummary, prepareReviewSummaryCandidate, commitReviewSummaryProvenance };
|
|
532
|
+
export { finalizeReviewSummary, preflightReviewSummary, prepareReviewSummaryCandidate, commitReviewSummaryProvenance };
|
|
339
533
|
export type {
|
|
340
534
|
ReviewFinalizationError,
|
|
341
535
|
ReviewFinalizationErrorCode,
|
|
342
536
|
ReviewFinalizationOptions,
|
|
343
537
|
ReviewFinalizationRequest,
|
|
344
538
|
ReviewFinalizationResult,
|
|
539
|
+
ReviewPreflightResult,
|
|
345
540
|
ReviewSummaryCandidatePreparation
|
|
346
541
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { createHash } from 'node:crypto';
|
|
4
4
|
|
|
5
5
|
const NO_FOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
6
6
|
const DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
|
|
@@ -25,11 +25,8 @@ function conflict(message: string): never {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function sameStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
|
|
28
|
-
return left.dev === right.dev
|
|
29
|
-
&& left.
|
|
30
|
-
&& left.size === right.size
|
|
31
|
-
&& left.mtimeNs === right.mtimeNs
|
|
32
|
-
&& left.ctimeNs === right.ctimeNs;
|
|
28
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
29
|
+
&& left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
function assertNoSymlinkAncestors(candidate: string): void {
|
|
@@ -43,25 +40,15 @@ function assertNoSymlinkAncestors(candidate: string): void {
|
|
|
43
40
|
}
|
|
44
41
|
}
|
|
45
42
|
|
|
46
|
-
export async function readStableFile(
|
|
47
|
-
candidate: string,
|
|
48
|
-
options: Readonly<{ maxBytes: number; expectedSha256?: string }>
|
|
49
|
-
): Promise<StableFile> {
|
|
43
|
+
export async function readStableFile(candidate: string, options: Readonly<{ maxBytes: number; expectedSha256?: string }>): Promise<StableFile> {
|
|
50
44
|
return readStableFileSync(candidate, options);
|
|
51
45
|
}
|
|
52
46
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
candidate: string,
|
|
56
|
-
options: Readonly<{ maxBytes: number; expectedSha256?: string }>
|
|
57
|
-
): StableFile {
|
|
58
|
-
if (!path.isAbsolute(candidate)) {
|
|
59
|
-
conflict('candidate path must be absolute and terminal');
|
|
60
|
-
}
|
|
47
|
+
export function readStableFileSync(candidate: string, options: Readonly<{ maxBytes: number; expectedSha256?: string }>): StableFile {
|
|
48
|
+
if (!path.isAbsolute(candidate)) conflict('candidate path must be absolute and terminal');
|
|
61
49
|
assertNoSymlinkAncestors(candidate);
|
|
62
50
|
let descriptor: number | undefined;
|
|
63
51
|
try {
|
|
64
|
-
// O_NONBLOCK lets fstat reject a FIFO even when it has no writer.
|
|
65
52
|
descriptor = fs.openSync(candidate, fs.constants.O_RDONLY | NO_FOLLOW | (fs.constants.O_NONBLOCK ?? 0));
|
|
66
53
|
const before = fs.fstatSync(descriptor, { bigint: true });
|
|
67
54
|
if (!before.isFile()) conflict('candidate is not a regular file');
|
|
@@ -76,16 +63,12 @@ export function readStableFileSync(
|
|
|
76
63
|
const after = fs.fstatSync(descriptor, { bigint: true });
|
|
77
64
|
if (!sameStat(before, after)) conflict('candidate identity or metadata changed during read');
|
|
78
65
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
79
|
-
if (options.expectedSha256 !== undefined && sha256 !== options.expectedSha256)
|
|
80
|
-
conflict('candidate digest does not match the expected digest');
|
|
81
|
-
}
|
|
66
|
+
if (options.expectedSha256 !== undefined && sha256 !== options.expectedSha256) conflict('candidate digest does not match the expected digest');
|
|
82
67
|
return { bytes, sha256, stat: after };
|
|
83
68
|
} catch (error) {
|
|
84
69
|
if (error instanceof SecureFileError) throw error;
|
|
85
70
|
const code = (error as NodeJS.ErrnoException).code;
|
|
86
|
-
if (code === 'ELOOP' || code === 'ENOTSUP' || code === 'EINVAL')
|
|
87
|
-
conflict('candidate terminal is not a stable non-symlink file');
|
|
88
|
-
}
|
|
71
|
+
if (code === 'ELOOP' || code === 'ENOTSUP' || code === 'EINVAL') conflict('candidate terminal is not a stable non-symlink file');
|
|
89
72
|
throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', error instanceof Error ? error.message : String(error));
|
|
90
73
|
} finally {
|
|
91
74
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
@@ -99,18 +82,14 @@ export async function writeAtomicFile(target: string, bytes: Buffer, mode = 0o60
|
|
|
99
82
|
let stat: fs.Stats;
|
|
100
83
|
try { stat = await fs.promises.lstat(current); }
|
|
101
84
|
catch { throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative parent directory is missing'); }
|
|
102
|
-
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
103
|
-
throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative parent directory is not a real directory');
|
|
104
|
-
}
|
|
85
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative parent directory is not a real directory');
|
|
105
86
|
const next = path.dirname(current);
|
|
106
87
|
if (next === current) break;
|
|
107
88
|
current = next;
|
|
108
89
|
}
|
|
109
90
|
try {
|
|
110
91
|
const existing = await fs.promises.lstat(target);
|
|
111
|
-
if (existing.isSymbolicLink() || !existing.isFile())
|
|
112
|
-
throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative target is not a regular file');
|
|
113
|
-
}
|
|
92
|
+
if (existing.isSymbolicLink() || !existing.isFile()) throw new SecureFileError('TASK_ARTIFACT_WRITE_CONFLICT', 'authoritative target is not a regular file');
|
|
114
93
|
} catch (error) {
|
|
115
94
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
116
95
|
}
|
|
@@ -123,7 +102,6 @@ export async function writeAtomicFile(target: string, bytes: Buffer, mode = 0o60
|
|
|
123
102
|
await handle.chmod(mode);
|
|
124
103
|
await handle.close();
|
|
125
104
|
handle = undefined;
|
|
126
|
-
// Check after all asynchronous preparation; keep the final check/rename adjacent.
|
|
127
105
|
if (expectedSha256 !== undefined) readStableFileSync(target, { maxBytes: 1024 * 1024, expectedSha256 });
|
|
128
106
|
fs.renameSync(temporary, target);
|
|
129
107
|
const directory = await fs.promises.open(parent, fs.constants.O_RDONLY | DIRECTORY);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export const TASK_WORKFLOW_COMMANDS = Object.freeze({
|
|
2
2
|
'artifact-inspect': ['task-artifact', 'inspect'],
|
|
3
3
|
'artifact-init': ['task-artifact', 'init'],
|
|
4
|
+
'artifact-preflight': ['task-artifact', 'preflight'],
|
|
4
5
|
'artifact-finalize-local': ['task-artifact', 'finalize-local'],
|
|
6
|
+
'review-preflight': ['task-review', 'preflight'],
|
|
5
7
|
'review-finalize-summary': ['task-review', 'finalize-summary'],
|
|
6
8
|
event: ['task-event', null],
|
|
7
9
|
'ledger-finding-response': ['task-ledger', 'finding-respond'],
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { reconcileTaskInvalidation } from './invalidation-command.ts';
|
|
2
|
+
import { applyLedgerIntent } from './ledger-intents.ts';
|
|
3
|
+
import type { LedgerIntent } from './ledger-intents.ts';
|
|
4
|
+
import { TaskExecutionLockError, withTaskExecutionLock } from './task-execution-lock.ts';
|
|
5
|
+
import type { TaskWorkflowOperation } from './workflow-command.ts';
|
|
6
|
+
import { applyWorkflowWarningIntent } from './workflow-warning-intents.ts';
|
|
7
|
+
import type { WorkflowWarningIntent } from './workflow-warning-intents.ts';
|
|
8
|
+
|
|
9
|
+
export type WorkflowDispatchResult = Readonly<Record<string, unknown>>;
|
|
10
|
+
|
|
11
|
+
type OptionValues = Readonly<Record<string, string | boolean>>;
|
|
12
|
+
type DispatchFailure = Readonly<{
|
|
13
|
+
status: 'failed';
|
|
14
|
+
changed: false;
|
|
15
|
+
error: Readonly<{ code: string; message: string }>;
|
|
16
|
+
}>;
|
|
17
|
+
|
|
18
|
+
function failed(code: string, message: string): DispatchFailure {
|
|
19
|
+
return { status: 'failed', changed: false, error: { code, message } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isFailure(value: unknown): value is DispatchFailure {
|
|
23
|
+
return Boolean(value) && typeof value === 'object' && (value as { status?: unknown }).status === 'failed';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function options(
|
|
27
|
+
args: readonly string[],
|
|
28
|
+
allowed: Readonly<Record<string, string>>
|
|
29
|
+
): OptionValues | DispatchFailure {
|
|
30
|
+
const values: Record<string, string | boolean> = {};
|
|
31
|
+
for (let index = 2; index < args.length; index += 1) {
|
|
32
|
+
const flag = args[index]!;
|
|
33
|
+
if (flag === '--dry-run') {
|
|
34
|
+
if (!Object.hasOwn(allowed, flag) || values[flag] !== undefined) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', `invalid option '${flag}'`);
|
|
35
|
+
values[flag] = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const key = allowed[flag];
|
|
39
|
+
if (!key || values[flag] !== undefined) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', `invalid option '${flag}'`);
|
|
40
|
+
const value = args[++index];
|
|
41
|
+
if (value === undefined || value.startsWith('--')) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', `option '${flag}' requires a value`);
|
|
42
|
+
if (flag === '--needs-implementation' && value !== 'true' && value !== 'false') {
|
|
43
|
+
return failed('TASK_WORKFLOW_PAYLOAD_INVALID', "option '--needs-implementation' must be true or false");
|
|
44
|
+
}
|
|
45
|
+
values[flag] = value;
|
|
46
|
+
}
|
|
47
|
+
return values;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function value(values: OptionValues, flag: string): string | undefined {
|
|
51
|
+
const candidate = values[flag];
|
|
52
|
+
return typeof candidate === 'string' ? candidate : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function required(values: OptionValues, flags: readonly string[]): DispatchFailure | null {
|
|
56
|
+
const missing = flags.find((flag) => value(values, flag) === undefined);
|
|
57
|
+
return missing ? failed('TASK_WORKFLOW_PAYLOAD_INVALID', `option '${missing}' is required`) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resultForLock(error: unknown): WorkflowDispatchResult {
|
|
61
|
+
if (error instanceof TaskExecutionLockError) return failed(error.code, error.message);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ledgerIntent(operation: TaskWorkflowOperation, args: readonly string[]): LedgerIntent | DispatchFailure {
|
|
66
|
+
const [taskRef, kind] = args;
|
|
67
|
+
const mappings: Partial<Record<TaskWorkflowOperation, {
|
|
68
|
+
kind: LedgerIntent['kind']; required: readonly string[]; flags: Readonly<Record<string, string>>;
|
|
69
|
+
}>> = {
|
|
70
|
+
'ledger-finding-response': {
|
|
71
|
+
kind: 'finding-respond', required: ['--id', '--round', '--status', '--evidence'],
|
|
72
|
+
flags: { '--id': 'id', '--round': 'round', '--status': 'status', '--evidence': 'evidence', '--dry-run': 'dryRun' }
|
|
73
|
+
},
|
|
74
|
+
'ledger-finding-review': {
|
|
75
|
+
kind: 'finding-review', required: ['--id', '--status', '--evidence'],
|
|
76
|
+
flags: { '--id': 'id', '--status': 'status', '--evidence': 'evidence', '--needs-implementation': 'needsImplementation', '--dry-run': 'dryRun' }
|
|
77
|
+
},
|
|
78
|
+
'ledger-finding-upsert': {
|
|
79
|
+
kind: 'finding-upsert', required: ['--stage', '--review-artifact', '--ordinal', '--severity', '--evidence'],
|
|
80
|
+
flags: { '--stage': 'stage', '--review-artifact': 'reviewArtifact', '--ordinal': 'ordinal', '--severity': 'severity', '--evidence': 'evidence', '--dry-run': 'dryRun' }
|
|
81
|
+
},
|
|
82
|
+
'decision-next-id': { kind: 'decision-next-id', required: [], flags: {} },
|
|
83
|
+
'decision-upsert': {
|
|
84
|
+
kind: 'decision-upsert', required: ['--id', '--stage', '--artifact'],
|
|
85
|
+
flags: { '--id': 'id', '--stage': 'stage', '--artifact': 'artifact', '--needs-implementation': 'needsImplementation', '--dry-run': 'dryRun' }
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const mapping = mappings[operation];
|
|
89
|
+
if (!mapping || !taskRef || kind === undefined) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', 'task workflow ledger request is invalid');
|
|
90
|
+
const values = options(args, mapping.flags);
|
|
91
|
+
if (isFailure(values)) return values;
|
|
92
|
+
const missing = required(values, mapping.required);
|
|
93
|
+
if (missing) return missing;
|
|
94
|
+
const dryRun = values['--dry-run'] === true;
|
|
95
|
+
if (mapping.kind === 'decision-next-id') return { kind: mapping.kind, taskRef };
|
|
96
|
+
if (mapping.kind === 'finding-respond') return {
|
|
97
|
+
kind: mapping.kind, taskRef, id: value(values, '--id')!, round: Number(value(values, '--round')),
|
|
98
|
+
status: value(values, '--status') as never, evidence: value(values, '--evidence')!, ...(dryRun ? { dryRun } : {})
|
|
99
|
+
} as LedgerIntent;
|
|
100
|
+
if (mapping.kind === 'finding-review') return {
|
|
101
|
+
kind: mapping.kind, taskRef, id: value(values, '--id')!, status: value(values, '--status') as never,
|
|
102
|
+
evidence: value(values, '--evidence')!, ...(value(values, '--needs-implementation') === 'true' ? { needsImplementation: true } : {}),
|
|
103
|
+
...(dryRun ? { dryRun } : {})
|
|
104
|
+
} as LedgerIntent;
|
|
105
|
+
if (mapping.kind === 'finding-upsert') return {
|
|
106
|
+
kind: mapping.kind, taskRef, stage: value(values, '--stage') as never, reviewArtifact: value(values, '--review-artifact')!,
|
|
107
|
+
ordinal: Number(value(values, '--ordinal')), severity: value(values, '--severity') as never, evidence: value(values, '--evidence')!,
|
|
108
|
+
...(dryRun ? { dryRun } : {})
|
|
109
|
+
} as LedgerIntent;
|
|
110
|
+
return {
|
|
111
|
+
kind: mapping.kind, taskRef, id: value(values, '--id')!, stage: value(values, '--stage') as never,
|
|
112
|
+
artifact: value(values, '--artifact')!, ...(value(values, '--needs-implementation') === 'true' ? { needsImplementation: true } : {}),
|
|
113
|
+
...(dryRun ? { dryRun } : {})
|
|
114
|
+
} as LedgerIntent;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function warningIntent(args: readonly string[]): WorkflowWarningIntent | DispatchFailure {
|
|
118
|
+
const [taskRef, kind] = args;
|
|
119
|
+
if (!taskRef || kind !== 'add') return failed('TASK_WORKFLOW_PAYLOAD_INVALID', 'task workflow warning request is invalid');
|
|
120
|
+
const values = options(args, {
|
|
121
|
+
'--step': 'step', '--severity': 'severity', '--code': 'code', '--target': 'target',
|
|
122
|
+
'--message': 'message', '--action': 'action', '--dry-run': 'dryRun'
|
|
123
|
+
});
|
|
124
|
+
if (isFailure(values)) return values;
|
|
125
|
+
const missing = required(values, ['--step', '--severity', '--code', '--target', '--message', '--action']);
|
|
126
|
+
if (missing) return missing;
|
|
127
|
+
return {
|
|
128
|
+
kind: 'add', taskRef, step: value(values, '--step')!, severity: value(values, '--severity') as never,
|
|
129
|
+
code: value(values, '--code')!, target: value(values, '--target')!, message: value(values, '--message')!,
|
|
130
|
+
action: value(values, '--action')!, ...(values['--dry-run'] === true ? { dryRun: true } : {})
|
|
131
|
+
} as WorkflowWarningIntent;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The caller has already selected and authorized the execution boundary.
|
|
136
|
+
* This function deliberately invokes task domains in that process: starting a
|
|
137
|
+
* second CLI process would discard the broker binding and could misclassify a
|
|
138
|
+
* sandbox process with cleared environment variables as direct-host.
|
|
139
|
+
*/
|
|
140
|
+
export function dispatchWorkflowCommand(
|
|
141
|
+
repoRoot: string,
|
|
142
|
+
operation: TaskWorkflowOperation,
|
|
143
|
+
args: readonly string[]
|
|
144
|
+
): WorkflowDispatchResult {
|
|
145
|
+
const taskRef = args[0];
|
|
146
|
+
if (!taskRef) return failed('TASK_WORKFLOW_PAYLOAD_INVALID', 'task ref is required');
|
|
147
|
+
try {
|
|
148
|
+
if (operation === 'invalidation-reconcile') {
|
|
149
|
+
const values = options(args, { '--max-targets': 'maxTargets', '--dry-run': 'dryRun' });
|
|
150
|
+
if (isFailure(values)) return values;
|
|
151
|
+
const maxTargets = value(values, '--max-targets');
|
|
152
|
+
if (maxTargets !== undefined && (!Number.isSafeInteger(Number(maxTargets)) || Number(maxTargets) < 1)) {
|
|
153
|
+
return failed('TASK_WORKFLOW_PAYLOAD_INVALID', '--max-targets must be a positive integer');
|
|
154
|
+
}
|
|
155
|
+
return withTaskExecutionLock(repoRoot, taskRef, 'task-invalidation.reconcile', () => reconcileTaskInvalidation(taskRef, {
|
|
156
|
+
repoRoot, ...(maxTargets === undefined ? {} : { maxTargets: Number(maxTargets) }),
|
|
157
|
+
...(values['--dry-run'] === true ? { dryRun: true } : {})
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
if (operation === 'warning-add') {
|
|
161
|
+
const intent = warningIntent(args);
|
|
162
|
+
if (isFailure(intent)) return intent;
|
|
163
|
+
return withTaskExecutionLock(repoRoot, taskRef, 'task-warning.add', () => applyWorkflowWarningIntent(intent, { repoRoot }));
|
|
164
|
+
}
|
|
165
|
+
const intent = ledgerIntent(operation, args);
|
|
166
|
+
if (isFailure(intent)) return intent;
|
|
167
|
+
return withTaskExecutionLock(repoRoot, taskRef, `task-ledger.${intent.kind}`, () => applyLedgerIntent(intent, { repoRoot }));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return resultForLock(error);
|
|
170
|
+
}
|
|
171
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fitlab-ai/agent-infra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Antigravity CLI, and OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,12 +11,18 @@
|
|
|
11
11
|
| `artifactChunk` | `<!-- sync-issue:{task-id}:{artifact-stem}:{part}/{total} -->` |
|
|
12
12
|
| `summary` | `<!-- sync-issue:{task-id}:summary -->` |
|
|
13
13
|
| `cancel` | `<!-- sync-issue:{task-id}:cancel -->` |
|
|
14
|
+
| `recoveryAction` | `<!-- sync-issue:{task-id}:recovery-action:{action-id} -->` |
|
|
15
|
+
| `recoveryPrepare` | `<!-- sync-issue:{task-id}:recovery-prepare:{commit-id} -->` |
|
|
16
|
+
| `recoveryCommit` | `<!-- sync-issue:{task-id}:recovery-commit:{commit-id} -->` |
|
|
14
17
|
|
|
15
18
|
`pr-review` content is synced only as an Issue artifact comment (via the `artifact` / `artifactChunk` markers) and is never a `restore-task` recovery source; restore still accepts only an Issue token and reads only registered Issue markers, with no PR source.
|
|
16
19
|
|
|
17
20
|
Comments use `platform-comment`; Issue resources use `platform-issue`:
|
|
18
21
|
|
|
19
22
|
```bash
|
|
23
|
+
agent-infra-internal platform-comment list --issue <issue-token> [--cwd <path>]
|
|
24
|
+
agent-infra-internal platform-comment recover --issue <issue-token> --task-id <TASK-id> \
|
|
25
|
+
--output <.agents/workspace/.restore-staging-*/task.md> [--cwd <path>]
|
|
20
26
|
agent-infra-internal platform-issue inspect {task-id}
|
|
21
27
|
agent-infra-internal platform-issue create {task-id} --agent {standard-agent-token}
|
|
22
28
|
agent-infra-internal platform-issue bind {task-id} --issue {issue-token} --agent {standard-agent-token}
|
|
@@ -25,7 +31,9 @@ agent-infra-internal platform-issue sync {task-id} --agent {standard-agent-token
|
|
|
25
31
|
|
|
26
32
|
The core owns status/in labels, assignees, milestones, Issue Type, pinned fields, requirements, state, capabilities, dry-run, retries, errors, and idempotency. Omitted flags preserve values; `none` explicitly clears them. Status labels converge to at most one, and ambiguous requirement identity fails closed.
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
The task comment synchronizes only a deterministic current-task projection. Process sections such as the Activity Log, Artifact Lifecycle Receipts, and invalidation records are carried by the recovery transaction. Byte preflight and platform verification consume the same final rendered UTF-8 body.
|
|
35
|
+
|
|
36
|
+
Recovery comments use `recovery-action`, `recovery-prepare`, and `recovery-commit` markers with a stable recovery id and a body file. Task sync publishes prepare, action, a task snapshot that is written and re-read, and then commit. Replaying identical content is a no-op; conflicting immutable content fails with `RECOVERY_IMMUTABLE_CONFLICT`. Restore accepts only an author-consistent, complete, phase/digest/marker-valid transaction bound to the current task comment body SHA-256.
|
|
29
37
|
|
|
30
38
|
PR event `in:` synchronization uses `agent-infra-internal platform-pr sync-in-labels --pr <N> [--cwd <path>]`. PR files are the event evidence; a unique closing Issue is written and re-read before the PR. Zero or multiple closing Issues update only the PR and return `degraded`; unknown side effects or failed convergence return `blocked` with `IN_LABEL_SYNC_PARTIAL`.
|
|
31
39
|
|