@fitlab-ai/agent-infra 0.10.0 → 0.11.1
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/manual-validation.js +6 -2
- 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/issues.js +3 -4
- 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 +184 -162
- 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 +90 -22
- package/dist/lib/server/process-state.js +17 -5
- package/dist/lib/task/artifact-command.js +6 -3
- package/dist/lib/task/artifact-lifecycle.js +15 -52
- package/dist/lib/task/artifact-operations.js +28 -2
- package/dist/lib/task/artifact-recovery.js +114 -68
- package/dist/lib/task/artifact-repair-intent.js +25 -8
- package/dist/lib/task/capabilities.js +0 -2
- package/dist/lib/task/control-authority.js +1 -1
- package/dist/lib/task/events.js +66 -49
- package/dist/lib/task/gate-policy.js +94 -0
- package/dist/lib/task/intermediate-cleanup.js +28 -3
- package/dist/lib/task/local-artifact-finalization.js +85 -18
- package/dist/lib/task/orchestration.js +8 -6
- package/dist/lib/task/review-artifacts.js +12 -6
- package/dist/lib/task/review-command.js +2 -2
- package/dist/lib/task/review-finalization.js +198 -11
- package/dist/lib/{host-control → task}/secure-fs.js +8 -19
- package/dist/lib/task/verification-engine.js +28 -17
- package/dist/lib/task/verification.js +25 -9
- 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/manual-validation.ts +6 -2
- 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/issues.ts +3 -4
- package/lib/platform/task-comment-projection.ts +79 -0
- package/lib/platform/task-recovery.ts +54 -0
- package/lib/platform/verification-sync.ts +203 -188
- 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 +113 -22
- package/lib/server/process-state.ts +26 -5
- package/lib/task/artifact-command.ts +6 -4
- package/lib/task/artifact-lifecycle.ts +12 -49
- package/lib/task/artifact-operations.ts +28 -2
- package/lib/task/artifact-recovery.ts +120 -67
- package/lib/task/artifact-repair-intent.ts +38 -11
- package/lib/task/capabilities.ts +0 -1
- package/lib/task/control-authority.ts +1 -1
- package/lib/task/events.ts +55 -38
- package/lib/task/gate-policy.ts +133 -0
- package/lib/task/intermediate-cleanup.ts +18 -2
- package/lib/task/local-artifact-finalization.ts +91 -15
- package/lib/task/orchestration.ts +12 -7
- package/lib/task/review-artifacts.ts +8 -2
- package/lib/task/review-command.ts +1 -1
- package/lib/task/review-finalization.ts +198 -9
- package/lib/{host-control → task}/secure-fs.ts +10 -32
- package/lib/task/verification-engine.ts +24 -14
- package/lib/task/verification-types.ts +7 -0
- package/lib/task/verification.ts +25 -9
- 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 +11 -1
- package/templates/.agents/rules/issue-sync.zh-CN.md +6 -2
- package/templates/.agents/rules/local-artifact-repair.en.md +18 -9
- package/templates/.agents/rules/local-artifact-repair.zh-CN.md +21 -11
- package/templates/.agents/skills/analyze-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -10
- package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -10
- package/templates/.agents/skills/block-task/config/verify.json +1 -3
- package/templates/.agents/skills/cancel-task/config/verify.json +1 -4
- package/templates/.agents/skills/code-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +5 -5
- package/templates/.agents/skills/code-task/config/verify.en.json +1 -12
- package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -12
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/reference/report-template.en.md +11 -0
- package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +11 -0
- package/templates/.agents/skills/commit/config/verify.json +1 -5
- package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +6 -6
- package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +6 -6
- package/templates/.agents/skills/complete-task/config/verify.en.json +3 -14
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +3 -14
- package/templates/.agents/skills/create-pr/config/verify.json +1 -10
- package/templates/.agents/skills/create-task/config/verify.json +1 -4
- package/templates/.agents/skills/import-issue/config/verify.json +1 -2
- package/templates/.agents/skills/plan-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/plan-task/config/verify.en.json +1 -10
- package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -10
- package/templates/.agents/skills/restore-task/SKILL.en.md +9 -4
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +10 -5
- 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-analysis/config/verify.en.json +1 -10
- package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -10
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +2 -0
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +2 -0
- 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-code/config/verify.en.json +1 -10
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -10
- package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -0
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -0
- package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/review-plan/config/verify.en.json +1 -10
- package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -10
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +2 -0
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +2 -0
- package/templates/.agents/skills/review-pr/config/verify.en.json +1 -8
- package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -8
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +38 -13
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +38 -13
- 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
package/lib/task/events.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
3
4
|
|
|
4
5
|
import { appendActivityEntry, locateActivityLog, pairEntries, startedBackedRows } from './activity-log.ts';
|
|
5
6
|
import {
|
|
6
7
|
buildArtifactLinkSection,
|
|
7
8
|
inspectArtifactDirectory,
|
|
8
|
-
parseReviewedInputReference,
|
|
9
|
-
parseCodePlanInputReference,
|
|
10
9
|
resolveArtifactContext,
|
|
11
10
|
validateCompletedArtifact
|
|
12
11
|
} from './artifact-lifecycle.ts';
|
|
@@ -24,6 +23,7 @@ import {
|
|
|
24
23
|
import { LEDGER_SECTION_MISSING_CODE, LEDGER_SECTION_MISSING_MESSAGE, parseLedgerDocument, summarizeLedgerStage, validateLedgerRows } from './ledger.ts';
|
|
25
24
|
import type { ReviewStage } from './ledger.ts';
|
|
26
25
|
import { parseReviewSummary, resolveCanonicalVerdict } from './review-artifacts.ts';
|
|
26
|
+
import { extractReviewBaseline, extractReviewedHead, extractReviewedSnapshotTree } from './review-fingerprint.ts';
|
|
27
27
|
import { resolveTaskRef } from './resolve-ref.ts';
|
|
28
28
|
import { findSectionHeading } from './sections.ts';
|
|
29
29
|
import { validateLifecycleExecution } from './lifecycle-execution.ts';
|
|
@@ -46,6 +46,7 @@ import { buildLifecycleFacts, canStart } from './capabilities.ts';
|
|
|
46
46
|
import type { ExplicitTrigger, LifecycleAction, TriggerInitiator, TriggerReason } from './capabilities.ts';
|
|
47
47
|
import { createInvalidationOperation, invalidationMutation, parseInvalidationDocument, targetIdFor, upsertInvalidation } from './invalidation.ts';
|
|
48
48
|
import type { InvalidationTargetKind } from './invalidation.ts';
|
|
49
|
+
import { reconcileTaskInvalidation } from './invalidation-command.ts';
|
|
49
50
|
import { consumeReworkIntents, parseReworkIntentDocument, reworkIntentMutation, supersedeReworkIntents } from './rework-intent.ts';
|
|
50
51
|
import { ARTIFACT_FAMILIES, expectedQualificationRelations, parseQualificationAudit, parseTaskQualification, upstreamArtifactDigest, validateQualificationAudit } from './qualification-audit.ts';
|
|
51
52
|
import type { QualificationAudit, UpstreamRelation } from './qualification-audit.ts';
|
|
@@ -113,6 +114,23 @@ type TaskEventResult = {
|
|
|
113
114
|
operations: readonly TaskOperationSummary[]; error: TaskEventError | null;
|
|
114
115
|
};
|
|
115
116
|
|
|
117
|
+
function approvedCleanReviewedCommit(reviewContent: string, verdict: Verdict | undefined, repoRoot: string): string | null {
|
|
118
|
+
if (verdict !== 'approved') return null;
|
|
119
|
+
const reviewedHead = extractReviewedHead(reviewContent) || extractReviewBaseline(reviewContent);
|
|
120
|
+
const reviewedTree = extractReviewedSnapshotTree(reviewContent);
|
|
121
|
+
if (!/^[a-f0-9]{40}$/.test(reviewedHead) || !/^[a-f0-9]{40}$/.test(reviewedTree)) return null;
|
|
122
|
+
try {
|
|
123
|
+
const currentHead = execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
124
|
+
if (currentHead !== reviewedHead) return null;
|
|
125
|
+
const currentTree = execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD^{tree}'], { encoding: 'utf8' }).trim();
|
|
126
|
+
if (currentTree !== reviewedTree) return null;
|
|
127
|
+
execFileSync('git', ['-C', repoRoot, 'diff-index', '--quiet', 'HEAD', '--']);
|
|
128
|
+
return reviewedHead;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
116
134
|
const BASE_FIELDS = new Set(['taskRef', 'event', 'agent', 'dryRun', 'overrideTicket', 'overrideTarget', 'overrideScope', 'initiator', 'requestId', 'reasonCode', 'sourceFinding', 'sourceArtifact', 'sourceSha256']);
|
|
117
135
|
const SCHEMAS: Record<TaskEventName, { required?: string[]; optional?: string[] }> = {
|
|
118
136
|
'analyze.started': { optional: ['round'] },
|
|
@@ -402,7 +420,7 @@ function reconcileReviewCompletionReplay(
|
|
|
402
420
|
}
|
|
403
421
|
intent = reconciled.intent;
|
|
404
422
|
}
|
|
405
|
-
if (!['passed', 'consumed'].includes(intent.state)) {
|
|
423
|
+
if (!['passed', 'consumption-started', 'consumed'].includes(intent.state)) {
|
|
406
424
|
return {
|
|
407
425
|
code: 'EVENT_ARTIFACT_CONFLICT',
|
|
408
426
|
message: `review finalizer provenance is incomplete for ${artifact}`
|
|
@@ -413,9 +431,7 @@ function reconcileReviewCompletionReplay(
|
|
|
413
431
|
return { code: 'EVENT_ARTIFACT_CONFLICT', message: validated.error.message };
|
|
414
432
|
}
|
|
415
433
|
const content = fs.readFileSync(validated.artifact.path, 'utf8');
|
|
416
|
-
const expectedRequestId = `review-finalize:${taskId}:${stage}:${round}`;
|
|
417
434
|
if (intent.round !== round
|
|
418
|
-
|| intent.requestId !== expectedRequestId
|
|
419
435
|
|| intent.finalArtifactSha256 !== sha256Content(content)
|
|
420
436
|
|| intent.finalSemanticDigest !== canonicalSemanticDigest(content)) {
|
|
421
437
|
return {
|
|
@@ -423,7 +439,7 @@ function reconcileReviewCompletionReplay(
|
|
|
423
439
|
message: `review finalizer provenance does not match ${artifact}`
|
|
424
440
|
};
|
|
425
441
|
}
|
|
426
|
-
if (intent.state === 'passed') {
|
|
442
|
+
if (intent.state === 'passed' || intent.state === 'consumption-started') {
|
|
427
443
|
const consumed = { ...intent, state: 'consumed' as const, updatedAt: Date.now() };
|
|
428
444
|
writeArtifactRecoveryIntent(repoRoot, consumed, { expected: intent });
|
|
429
445
|
}
|
|
@@ -652,26 +668,20 @@ function buildCompletionReceipt(
|
|
|
652
668
|
frontmatter: Record<string, unknown>
|
|
653
669
|
): { ok: true; receipt: ArtifactReceipt } | { ok: false; message: string } | null {
|
|
654
670
|
if (family === 'code') {
|
|
655
|
-
let content: string;
|
|
656
|
-
try { content = fs.readFileSync(artifact.path, 'utf8'); }
|
|
657
|
-
catch (error) { return { ok: false, message: `cannot read ${artifact.name}: ${String(error)}` }; }
|
|
658
|
-
const input = parseCodePlanInputReference(content);
|
|
659
|
-
if (!input) return { ok: false, message: `${artifact.name} does not reference a canonical plan artifact` };
|
|
660
671
|
const startedInput = typeof frontmatter.code_input_artifact === 'string' ? frontmatter.code_input_artifact : '';
|
|
661
672
|
const startedSha256 = typeof frontmatter.code_input_sha256 === 'string' ? frontmatter.code_input_sha256 : '';
|
|
662
673
|
if (!startedInput || !startedSha256) return { ok: false, message: 'code.started plan input context is missing' };
|
|
663
|
-
if (input.name !== startedInput) return { ok: false, message: `${artifact.name} plan input '${input.name}' does not match code.started input '${startedInput}'` };
|
|
664
674
|
const plan = inspectArtifactDirectory(taskDir, 'plan');
|
|
665
|
-
if (plan.status !== 'ready' || !plan.latest || plan.latest.name !==
|
|
666
|
-
return { ok: false, message: `code input '${
|
|
675
|
+
if (plan.status !== 'ready' || !plan.latest || plan.latest.name !== startedInput) {
|
|
676
|
+
return { ok: false, message: `code input '${startedInput}' is not the latest plan artifact` };
|
|
667
677
|
}
|
|
668
678
|
try {
|
|
669
679
|
const inputSha256 = sha256File(plan.latest.path);
|
|
670
|
-
if (inputSha256 !== startedSha256) return { ok: false, message: `code input ${
|
|
680
|
+
if (inputSha256 !== startedSha256) return { ok: false, message: `code input ${startedInput} changed after code.started` };
|
|
671
681
|
return {
|
|
672
682
|
ok: true,
|
|
673
683
|
receipt: {
|
|
674
|
-
event: 'code.completed', output: artifact.name, input:
|
|
684
|
+
event: 'code.completed', output: artifact.name, input: startedInput,
|
|
675
685
|
inputSha256, completedAt
|
|
676
686
|
}
|
|
677
687
|
};
|
|
@@ -681,18 +691,12 @@ function buildCompletionReceipt(
|
|
|
681
691
|
}
|
|
682
692
|
if (!family.startsWith('review-')) return null;
|
|
683
693
|
const expectedFamily = reviewInputFamily(family);
|
|
684
|
-
let content: string;
|
|
685
|
-
try { content = fs.readFileSync(artifact.path, 'utf8'); }
|
|
686
|
-
catch (error) { return { ok: false, message: `cannot read ${artifact.name}: ${String(error)}` }; }
|
|
687
|
-
const input = parseReviewedInputReference(content, expectedFamily);
|
|
688
|
-
if (!input) return { ok: false, message: `${artifact.name} does not reference a canonical ${expectedFamily} artifact` };
|
|
689
694
|
const startedInput = typeof frontmatter.review_input_artifact === 'string' ? frontmatter.review_input_artifact : '';
|
|
690
695
|
const startedSha256 = typeof frontmatter.review_input_sha256 === 'string' ? frontmatter.review_input_sha256 : '';
|
|
691
696
|
if (!startedInput || !startedSha256) return { ok: false, message: 'review started input context is missing' };
|
|
692
|
-
if (input.name !== startedInput) return { ok: false, message: `${artifact.name} input '${input.name}' does not match review.started input '${startedInput}'` };
|
|
693
697
|
const current = inspectArtifactDirectory(taskDir, expectedFamily);
|
|
694
|
-
if (current.status !== 'ready' || !current.latest || current.latest.name !==
|
|
695
|
-
return { ok: false, message: `review input '${
|
|
698
|
+
if (current.status !== 'ready' || !current.latest || current.latest.name !== startedInput) {
|
|
699
|
+
return { ok: false, message: `review input '${startedInput}' is not the latest ${expectedFamily} artifact` };
|
|
696
700
|
}
|
|
697
701
|
const event = family === 'review-analysis'
|
|
698
702
|
? 'review-analysis.completed' as const
|
|
@@ -701,16 +705,16 @@ function buildCompletionReceipt(
|
|
|
701
705
|
const stat = fs.lstatSync(current.latest.path);
|
|
702
706
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('review input is not a regular file');
|
|
703
707
|
const inputSha256 = sha256File(current.latest.path);
|
|
704
|
-
if (inputSha256 !== startedSha256) return { ok: false, message: `review input ${
|
|
708
|
+
if (inputSha256 !== startedSha256) return { ok: false, message: `review input ${startedInput} changed after review.started` };
|
|
705
709
|
return {
|
|
706
710
|
ok: true,
|
|
707
711
|
receipt: {
|
|
708
|
-
event, output: artifact.name, input:
|
|
712
|
+
event, output: artifact.name, input: startedInput,
|
|
709
713
|
inputSha256, completedAt
|
|
710
714
|
}
|
|
711
715
|
};
|
|
712
716
|
} catch (error) {
|
|
713
|
-
return { ok: false, message: `cannot hash review input ${
|
|
717
|
+
return { ok: false, message: `cannot hash review input ${startedInput}: ${error instanceof Error ? error.message : String(error)}` };
|
|
714
718
|
}
|
|
715
719
|
}
|
|
716
720
|
|
|
@@ -724,6 +728,20 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
724
728
|
let content: string;
|
|
725
729
|
try { content = fs.readFileSync(resolved.taskMdPath, 'utf8'); }
|
|
726
730
|
catch (error) { return failed(request, { code: 'TASK_READ_FAILED', message: String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath }); }
|
|
731
|
+
const initialParts = eventParts(request.event);
|
|
732
|
+
if (initialParts.phase === 'started' && !request.dryRun) {
|
|
733
|
+
const reconciled = reconcileTaskInvalidation(request.taskRef, { repoRoot: resolved.repoRoot });
|
|
734
|
+
if (reconciled.status === 'failed') {
|
|
735
|
+
return failed(request, {
|
|
736
|
+
code: 'EVENT_TRANSITION_INVALID',
|
|
737
|
+
message: `INVALIDATION_RECONCILE_FAILED: ${reconciled.error?.code ?? 'UNKNOWN'}: ${reconciled.error?.message ?? ''}`
|
|
738
|
+
}, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
|
|
739
|
+
}
|
|
740
|
+
if (reconciled.changed) {
|
|
741
|
+
try { content = fs.readFileSync(resolved.taskMdPath, 'utf8'); }
|
|
742
|
+
catch (error) { return failed(request, { code: 'TASK_READ_FAILED', message: String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath }); }
|
|
743
|
+
}
|
|
744
|
+
}
|
|
727
745
|
let frontmatter;
|
|
728
746
|
try { frontmatter = parseTypedTaskFrontmatter(content); }
|
|
729
747
|
catch (error) { return failed(request, { code: 'TASK_DOCUMENT_INVALID', message: error instanceof Error ? error.message : String(error) }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath }); }
|
|
@@ -732,7 +750,6 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
732
750
|
const rows = startedBackedRows(pairEntries(section.entries));
|
|
733
751
|
let normalized = request;
|
|
734
752
|
let artifactContext: ArtifactContextResult | null = null;
|
|
735
|
-
const initialParts = eventParts(request.event);
|
|
736
753
|
if (initialParts.phase === 'started') {
|
|
737
754
|
const openIdentity = openStartedIdentity(rows, initialParts.family);
|
|
738
755
|
if (openIdentity && 'conflict' in openIdentity) return failed(request, { code: 'EVENT_LOG_CONFLICT', message: 'artifact family has more than one open started event' }, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath });
|
|
@@ -963,20 +980,15 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
963
980
|
);
|
|
964
981
|
if (reconciled.status === 'passed' || reconciled.status === 'consumed') reviewFinalizationIntent = reconciled.intent;
|
|
965
982
|
}
|
|
966
|
-
if (!reviewFinalizationIntent || !['passed', 'consumed'].includes(reviewFinalizationIntent.state)) {
|
|
983
|
+
if (!reviewFinalizationIntent || !['passed', 'consumption-started', 'consumed'].includes(reviewFinalizationIntent.state)) {
|
|
967
984
|
return failed(normalized, {
|
|
968
985
|
code: 'EVENT_ARTIFACT_CONFLICT',
|
|
969
986
|
message: `review finalizer provenance is missing or incomplete for ${completedArtifact.name}`
|
|
970
987
|
}, { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, fromStep: currentStep, toStep: currentStep, action: eventIdentity.action, phase: eventIdentity.phase });
|
|
971
988
|
}
|
|
972
|
-
const reviewStage = eventIdentity.family === 'review-analysis'
|
|
973
|
-
? 'analysis'
|
|
974
|
-
: eventIdentity.family === 'review-plan' ? 'plan' : 'code';
|
|
975
989
|
const actualSha256 = sha256File(completedArtifact.path);
|
|
976
990
|
const actualSemanticDigest = canonicalSemanticDigest(reviewContent);
|
|
977
|
-
const expectedRequestId = `review-finalize:${resolved.taskId}:${reviewStage}:${normalized.round}`;
|
|
978
991
|
if (reviewFinalizationIntent.round !== normalized.round
|
|
979
|
-
|| reviewFinalizationIntent.requestId !== expectedRequestId
|
|
980
992
|
|| reviewFinalizationIntent.finalArtifactSha256 !== actualSha256
|
|
981
993
|
|| reviewFinalizationIntent.finalSemanticDigest !== actualSemanticDigest) {
|
|
982
994
|
return failed(normalized, {
|
|
@@ -1072,6 +1084,11 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
1072
1084
|
} else if (eventIdentity.phase === 'completed' && eventIdentity.family.startsWith('review-')) {
|
|
1073
1085
|
frontmatterRemove = ['review_input_artifact', 'review_input_sha256'];
|
|
1074
1086
|
}
|
|
1087
|
+
if (eventIdentity.phase === 'completed' && eventIdentity.family === 'review-code' && reviewContent !== null) {
|
|
1088
|
+
const reviewedCommit = approvedCleanReviewedCommit(reviewContent, normalized.verdict, resolved.repoRoot);
|
|
1089
|
+
if (reviewedCommit) frontmatterSet.last_reviewed_commit = reviewedCommit;
|
|
1090
|
+
else if (normalized.verdict === 'approved') frontmatterSet.last_reviewed_commit = '';
|
|
1091
|
+
}
|
|
1075
1092
|
if (eventIdentity.phase === 'completed') frontmatterRemove = [...(frontmatterRemove ?? []), 'qualification_input_relations'];
|
|
1076
1093
|
if (eventIdentity.phase === 'started' && normalized.implementationInput) frontmatterSet.last_reviewed_commit = '';
|
|
1077
1094
|
const mutations: Parameters<typeof writeTask>[0]['mutations'][number][] = [
|
|
@@ -1116,7 +1133,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
1116
1133
|
const expectedIntent = localFinalizationIntent;
|
|
1117
1134
|
localFinalizationIntent = {
|
|
1118
1135
|
...localFinalizationIntent,
|
|
1119
|
-
state: '
|
|
1136
|
+
state: 'consumption-started',
|
|
1120
1137
|
phase: 'task-event.completed',
|
|
1121
1138
|
authorityDigest: lifecycleRecoveryAttestationDigest(lifecycleAuthority),
|
|
1122
1139
|
requestId: lifecycleAuthority.lifecycleRequestId,
|
|
@@ -1146,7 +1163,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
1146
1163
|
const expectedIntent = reviewFinalizationIntent;
|
|
1147
1164
|
reviewFinalizationIntent = {
|
|
1148
1165
|
...reviewFinalizationIntent,
|
|
1149
|
-
state: '
|
|
1166
|
+
state: 'consumption-started',
|
|
1150
1167
|
phase: 'task-event.completed',
|
|
1151
1168
|
updatedAt: Date.now()
|
|
1152
1169
|
};
|
|
@@ -1183,7 +1200,7 @@ function applyTaskEventUnlocked(request: TaskEventRequest, options: TaskEventOpt
|
|
|
1183
1200
|
}
|
|
1184
1201
|
if (!normalized.dryRun && reviewFinalizationIntent && completedArtifact) {
|
|
1185
1202
|
try {
|
|
1186
|
-
if (reviewFinalizationIntent.state === '
|
|
1203
|
+
if (reviewFinalizationIntent.state === 'consumption-started') {
|
|
1187
1204
|
const consumed = { ...reviewFinalizationIntent, state: 'consumed' as const, updatedAt: Date.now() };
|
|
1188
1205
|
writeArtifactRecoveryIntent(resolved.repoRoot, consumed, { expected: reviewFinalizationIntent });
|
|
1189
1206
|
}
|
|
@@ -1231,7 +1248,7 @@ function applyTaskEvent(request: TaskEventRequest, options: TaskEventOptions = {
|
|
|
1231
1248
|
if (invalid) return failed(request, invalid);
|
|
1232
1249
|
const parts = eventParts(request.event);
|
|
1233
1250
|
if (options.lockAlreadyHeld) return applyTaskEventUnlocked(request, options);
|
|
1234
|
-
if (request.dryRun ||
|
|
1251
|
+
if (request.dryRun || !['started', 'completed'].includes(parts.phase)) {
|
|
1235
1252
|
return applyTaskEventUnlocked(request, options);
|
|
1236
1253
|
}
|
|
1237
1254
|
const resolved = resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { VerificationStatus } from './verification-types.ts';
|
|
2
|
+
|
|
3
|
+
export type GateClassification = 'hard' | 'soft' | 'info';
|
|
4
|
+
export type PlatformAuditId =
|
|
5
|
+
| 'closed-status-labels' | 'status-label' | 'comment-marker' | 'pr-comment-marker'
|
|
6
|
+
| 'pr-comment-last-commit' | 'pr-comment-content' | 'comment-content' | 'task-comment-content'
|
|
7
|
+
| 'in-labels-computed' | 'pr-type-label' | 'in-labels-match-pr' | 'pr-assignee'
|
|
8
|
+
| 'requirements' | 'issue-type' | 'issue-fields' | 'milestone';
|
|
9
|
+
|
|
10
|
+
export type PlatformAuditPolicy = {
|
|
11
|
+
classification: GateClassification;
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
expectedStatusLabel?: string;
|
|
14
|
+
expectedCommentMarkerKey?: 'artifact' | 'summary';
|
|
15
|
+
expectedPrCommentMarkerKey?: 'prSummary';
|
|
16
|
+
requireSpecificMilestone?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type VerificationRecord = {
|
|
20
|
+
type: string;
|
|
21
|
+
status: VerificationStatus;
|
|
22
|
+
message: string;
|
|
23
|
+
fail_type?: string;
|
|
24
|
+
checkId?: string;
|
|
25
|
+
classification?: GateClassification;
|
|
26
|
+
effectiveStatus?: VerificationStatus;
|
|
27
|
+
reason?: string;
|
|
28
|
+
action?: string;
|
|
29
|
+
dependsOn?: string;
|
|
30
|
+
warnings?: string[];
|
|
31
|
+
subchecks?: VerificationRecord[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const OPTIONAL_PLATFORM_AUDITS = new Set<PlatformAuditId>([
|
|
35
|
+
'closed-status-labels', 'status-label', 'comment-marker', 'pr-comment-marker',
|
|
36
|
+
'pr-comment-last-commit', 'pr-comment-content', 'comment-content', 'task-comment-content',
|
|
37
|
+
'in-labels-computed', 'pr-type-label', 'in-labels-match-pr', 'pr-assignee',
|
|
38
|
+
'requirements', 'issue-type', 'issue-fields', 'milestone'
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
42
|
+
'create-task': 'status: waiting-for-triage',
|
|
43
|
+
'analyze-task': 'status: pending-design-work',
|
|
44
|
+
'review-analysis': 'status: pending-design-work',
|
|
45
|
+
'plan-task': 'status: pending-design-work',
|
|
46
|
+
'review-plan': 'status: pending-design-work',
|
|
47
|
+
'code-task': 'status: in-progress',
|
|
48
|
+
'review-code': 'status: in-progress'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const ARTIFACT_COMMENT_SKILLS = new Set([
|
|
52
|
+
'analyze-task', 'plan-task', 'review-analysis', 'review-plan', 'code-task',
|
|
53
|
+
'review-code', 'review-pr', 'run-manual-validation'
|
|
54
|
+
]);
|
|
55
|
+
const PR_AUDIT_SKILLS = new Set(['create-pr']);
|
|
56
|
+
const IN_LABEL_AUDIT_SKILLS = new Set(['code-task', 'commit', 'create-pr']);
|
|
57
|
+
|
|
58
|
+
export function platformAuditPolicy(checkId: PlatformAuditId, context: { skillName?: string; artifactFile?: string | null }): PlatformAuditPolicy {
|
|
59
|
+
const skillName = context.skillName || '';
|
|
60
|
+
const artifactComment = Boolean(context.artifactFile) && ARTIFACT_COMMENT_SKILLS.has(skillName);
|
|
61
|
+
const prAudit = PR_AUDIT_SKILLS.has(skillName);
|
|
62
|
+
const inLabelAudit = IN_LABEL_AUDIT_SKILLS.has(skillName);
|
|
63
|
+
|
|
64
|
+
switch (checkId) {
|
|
65
|
+
case 'status-label':
|
|
66
|
+
return { classification: 'soft', enabled: Boolean(STATUS_LABELS[skillName]), expectedStatusLabel: STATUS_LABELS[skillName] };
|
|
67
|
+
case 'comment-marker':
|
|
68
|
+
return {
|
|
69
|
+
classification: 'soft',
|
|
70
|
+
enabled: artifactComment || skillName === 'complete-task',
|
|
71
|
+
...(artifactComment ? { expectedCommentMarkerKey: 'artifact' } : skillName === 'complete-task' ? { expectedCommentMarkerKey: 'summary' } : {})
|
|
72
|
+
};
|
|
73
|
+
case 'pr-comment-marker':
|
|
74
|
+
return {
|
|
75
|
+
classification: 'soft',
|
|
76
|
+
enabled: skillName === 'commit' || prAudit,
|
|
77
|
+
...(skillName === 'commit' || prAudit ? { expectedPrCommentMarkerKey: 'prSummary' } : {})
|
|
78
|
+
};
|
|
79
|
+
case 'pr-comment-last-commit':
|
|
80
|
+
return {
|
|
81
|
+
classification: 'soft',
|
|
82
|
+
enabled: skillName === 'commit',
|
|
83
|
+
...(skillName === 'commit' ? { expectedPrCommentMarkerKey: 'prSummary' } : {})
|
|
84
|
+
};
|
|
85
|
+
case 'pr-comment-content':
|
|
86
|
+
return {
|
|
87
|
+
classification: 'soft',
|
|
88
|
+
enabled: skillName === 'complete-manual-validation',
|
|
89
|
+
...(skillName === 'complete-manual-validation' ? { expectedPrCommentMarkerKey: 'prSummary' } : {})
|
|
90
|
+
};
|
|
91
|
+
case 'comment-content':
|
|
92
|
+
return { classification: 'soft', enabled: artifactComment, expectedCommentMarkerKey: 'artifact' };
|
|
93
|
+
case 'task-comment-content':
|
|
94
|
+
return { classification: 'soft', enabled: true };
|
|
95
|
+
case 'in-labels-computed':
|
|
96
|
+
return { classification: 'soft', enabled: inLabelAudit };
|
|
97
|
+
case 'pr-type-label':
|
|
98
|
+
case 'in-labels-match-pr':
|
|
99
|
+
case 'pr-assignee':
|
|
100
|
+
return { classification: 'soft', enabled: prAudit };
|
|
101
|
+
case 'requirements':
|
|
102
|
+
return { classification: 'soft', enabled: skillName === 'complete-task' };
|
|
103
|
+
case 'milestone':
|
|
104
|
+
return { classification: 'soft', enabled: true, requireSpecificMilestone: skillName === 'code-task' };
|
|
105
|
+
case 'closed-status-labels':
|
|
106
|
+
case 'issue-type':
|
|
107
|
+
case 'issue-fields':
|
|
108
|
+
return { classification: 'soft', enabled: true };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function classificationForCheck(checkId: string): GateClassification {
|
|
113
|
+
return checkId.startsWith('platform.') && OPTIONAL_PLATFORM_AUDITS.has(checkId.slice('platform.'.length) as PlatformAuditId)
|
|
114
|
+
? 'soft'
|
|
115
|
+
: 'hard';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function effectiveStatus(status: VerificationStatus, classification: GateClassification): VerificationStatus {
|
|
119
|
+
return classification === 'hard' ? status : 'pass';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function normalizeVerificationRecord(record: VerificationRecord): VerificationRecord {
|
|
123
|
+
const checkId = record.checkId ?? record.type;
|
|
124
|
+
const classification = record.classification ?? classificationForCheck(checkId);
|
|
125
|
+
return {
|
|
126
|
+
...record,
|
|
127
|
+
checkId,
|
|
128
|
+
classification,
|
|
129
|
+
effectiveStatus: record.effectiveStatus ?? effectiveStatus(record.status, classification),
|
|
130
|
+
reason: record.reason ?? record.fail_type ?? (classification === 'info' ? 'NOT_APPLICABLE' : record.status === 'pass' ? 'OK' : 'CHECK_FAILED'),
|
|
131
|
+
action: record.action ?? (classification === 'info' || record.status === 'pass' ? 'No action required' : 'Review the check evidence and retry after correction')
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -327,10 +327,26 @@ function cleanupConsumedRecoveryFiles(repoRoot: string, task: TaskRecord | undef
|
|
|
327
327
|
let entries: fs.Dirent[];
|
|
328
328
|
try { entries = fs.readdirSync(recovery, { withFileTypes: true }); } catch { return false; }
|
|
329
329
|
for (const entry of entries) {
|
|
330
|
-
if (entry.isSymbolicLink()
|
|
330
|
+
if (entry.isSymbolicLink()) return false;
|
|
331
|
+
if (entry.isFile()) {
|
|
332
|
+
if (!['baseline.md', 'candidate.md', 'final.md', 'publish.md'].includes(entry.name)) return false;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (!entry.isDirectory() || entry.name !== 'generations') return false;
|
|
336
|
+
let generations: fs.Dirent[];
|
|
337
|
+
try { generations = fs.readdirSync(path.join(recovery, entry.name), { withFileTypes: true }); } catch { return false; }
|
|
338
|
+
if (generations.some((generation) => generation.isSymbolicLink()
|
|
339
|
+
|| !generation.isFile() || !/^[a-f0-9]{64}\.md$/u.test(generation.name))) return false;
|
|
331
340
|
}
|
|
332
341
|
try {
|
|
333
|
-
for (const entry of entries)
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
const target = path.join(recovery, entry.name);
|
|
344
|
+
if (entry.isFile()) fs.unlinkSync(target);
|
|
345
|
+
else {
|
|
346
|
+
for (const generation of fs.readdirSync(target, { withFileTypes: true })) fs.unlinkSync(path.join(target, generation.name));
|
|
347
|
+
fs.rmdirSync(target);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
334
350
|
fs.rmdirSync(recovery);
|
|
335
351
|
const recoveryRoot = path.dirname(recovery);
|
|
336
352
|
if (fs.readdirSync(recoveryRoot).length === 0) fs.rmdirSync(recoveryRoot);
|
|
@@ -5,7 +5,6 @@ import { scanVisibleMarkdown } from './markdown.ts';
|
|
|
5
5
|
import { parseArtifactName } from './artifact-name.ts';
|
|
6
6
|
import {
|
|
7
7
|
hasOpenArtifactRound,
|
|
8
|
-
parseCodePlanInputReference,
|
|
9
8
|
validateArtifactPublication,
|
|
10
9
|
validateCompletedArtifact
|
|
11
10
|
} from './artifact-lifecycle.ts';
|
|
@@ -19,8 +18,10 @@ import {
|
|
|
19
18
|
beginArtifactRecovery,
|
|
20
19
|
commitArtifactRecovery,
|
|
21
20
|
consumeArtifactRecovery,
|
|
21
|
+
prepareArtifactRecoveryFinal,
|
|
22
22
|
prepareArtifactRecoveryCommit,
|
|
23
23
|
recordArtifactRecoveryPassed,
|
|
24
|
+
reconcileArtifactRecovery,
|
|
24
25
|
recoveryContextFromIntent,
|
|
25
26
|
stageArtifactCandidate
|
|
26
27
|
} from './artifact-recovery.ts';
|
|
@@ -204,12 +205,15 @@ function validateLocalArtifact(
|
|
|
204
205
|
if (!decisionDetails.ok) diagnostics.push({ code: 'LOCAL_DECISION_DETAIL_DUPLICATE', message: decisionDetails.message, line: null });
|
|
205
206
|
|
|
206
207
|
if (options.taskContent !== undefined) {
|
|
207
|
-
const
|
|
208
|
-
|
|
208
|
+
const identity = options.artifact ? parseArtifactName(options.artifact) : null;
|
|
209
|
+
const expected = identity?.family === options.family && hasOpenArtifactRound(options.taskContent, options.family, identity.round)
|
|
210
|
+
? expectedQualificationRelations(options.taskContent, options.family)
|
|
211
|
+
: undefined;
|
|
212
|
+
if (expected && !expected.ok) diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${expected.code}: ${expected.message}`, line: null });
|
|
209
213
|
const qualification = validateQualificationAudit(options.taskContent, content, {
|
|
210
214
|
family: options.family,
|
|
211
215
|
artifact: options.artifact,
|
|
212
|
-
expectedUpstreamRelations: expected
|
|
216
|
+
expectedUpstreamRelations: expected?.ok ? expected.relations : undefined
|
|
213
217
|
});
|
|
214
218
|
if (!qualification.ok) diagnostics.push({ code: 'LOCAL_QUALIFICATION_AUDIT_INVALID', message: `${qualification.code}: ${qualification.message}`, line: null });
|
|
215
219
|
}
|
|
@@ -265,7 +269,8 @@ function tupleFor(
|
|
|
265
269
|
function prepareLocalArtifact(
|
|
266
270
|
request: LocalArtifactFinalizationRequest,
|
|
267
271
|
candidate?: string,
|
|
268
|
-
authority?: LifecycleRecoveryAttestationV1
|
|
272
|
+
authority?: LifecycleRecoveryAttestationV1,
|
|
273
|
+
preflightOnly = false
|
|
269
274
|
): LocalArtifactPreparation {
|
|
270
275
|
const failed = (code: string, message: string, extra: Partial<LocalArtifactFinalizationResult> = {}): LocalArtifactPreparation => ({
|
|
271
276
|
result: failedFinalization(request, { code, message }, extra), content: candidate ?? ''
|
|
@@ -281,12 +286,14 @@ function prepareLocalArtifact(
|
|
|
281
286
|
let taskContent: string;
|
|
282
287
|
let content: string;
|
|
283
288
|
let recovery: ArtifactRecoveryContext | undefined;
|
|
289
|
+
let recoveryState: ArtifactRecoveryIntent['state'] | undefined;
|
|
284
290
|
try {
|
|
285
291
|
taskContent = fs.readFileSync(resolved.taskMdPath, 'utf8');
|
|
286
292
|
if (request.recoveryId) {
|
|
287
293
|
const intent = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, request.family, request.artifact);
|
|
288
294
|
if (!intent || intent.recoveryOperationId !== request.recoveryId) return failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'recovery id does not match the artifact journal');
|
|
289
|
-
if (!['awaiting-recovery', '
|
|
295
|
+
if (!['awaiting-preflight-recovery', 'preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready', 'commit-started'].includes(intent.state)) return failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', `recovery journal is in '${intent.state}' state`);
|
|
296
|
+
recoveryState = intent.state;
|
|
290
297
|
recovery = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, intent);
|
|
291
298
|
content = fs.existsSync(recovery.stagingPath)
|
|
292
299
|
? fs.readFileSync(recovery.stagingPath, 'utf8')
|
|
@@ -315,26 +322,66 @@ function prepareLocalArtifact(
|
|
|
315
322
|
|
|
316
323
|
if (request.recoveryId) {
|
|
317
324
|
if (!validation.ok) return { result, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
|
|
325
|
+
if (preflightOnly && recovery && recoveryState === 'awaiting-preflight-recovery') {
|
|
326
|
+
const staged = stageArtifactCandidate(recovery, Buffer.from(content, 'utf8'), { lockAlreadyHeld: request.lockAlreadyHeld });
|
|
327
|
+
prepareArtifactRecoveryCommit(recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: request.lockAlreadyHeld });
|
|
328
|
+
}
|
|
318
329
|
return {
|
|
319
|
-
result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(recovery!) },
|
|
330
|
+
result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(recovery!) },
|
|
320
331
|
content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld
|
|
321
332
|
};
|
|
322
333
|
}
|
|
323
334
|
|
|
324
335
|
const existing = readArtifactRecoveryIntent(resolved.repoRoot, resolved.taskId, request.family, request.artifact);
|
|
325
336
|
if (existing?.state === 'passed' || existing?.state === 'consumed') {
|
|
326
|
-
if (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest) {
|
|
337
|
+
if (existing.state === 'consumed' && (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest)) {
|
|
327
338
|
return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'formal artifact does not match its completed recovery journal'), content, repoRoot: resolved.repoRoot, authority, lockAlreadyHeld: request.lockAlreadyHeld };
|
|
328
339
|
}
|
|
340
|
+
if (existing.finalArtifactSha256 === artifactSha256 && existing.finalSemanticDigest === validation.semanticDigest) {
|
|
341
|
+
const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
|
|
342
|
+
return {
|
|
343
|
+
result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) },
|
|
344
|
+
content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (existing && ['preflight-ready', 'preflight-commit-started', 'preflight-passed', 'full-finalizer-ready'].includes(existing.state)) {
|
|
329
350
|
const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
|
|
330
351
|
return {
|
|
331
|
-
result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) },
|
|
352
|
+
result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) },
|
|
353
|
+
content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (existing?.state === 'commit-started') {
|
|
357
|
+
if (existing.finalArtifactSha256 !== artifactSha256 || existing.finalSemanticDigest !== validation.semanticDigest) {
|
|
358
|
+
return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', 'formal artifact does not match the interrupted recovery journal'), content, repoRoot: resolved.repoRoot, authority, lockAlreadyHeld: request.lockAlreadyHeld };
|
|
359
|
+
}
|
|
360
|
+
const context = recoveryContextFromIntent(resolved.repoRoot, resolved.taskDir, existing);
|
|
361
|
+
const reconciled = reconcileArtifactRecovery(context, {
|
|
362
|
+
lockAlreadyHeld: request.lockAlreadyHeld,
|
|
363
|
+
validateFinal: () => validation.semanticDigest === existing.finalSemanticDigest
|
|
364
|
+
});
|
|
365
|
+
if (reconciled.status !== 'passed') {
|
|
366
|
+
return { ...failed('LOCAL_RECOVERY_PROVENANCE_CONFLICT', `interrupted recovery could not be reconciled: ${reconciled.status}`), content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld };
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) },
|
|
332
370
|
content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld
|
|
333
371
|
};
|
|
334
372
|
}
|
|
335
373
|
|
|
336
374
|
try {
|
|
337
375
|
if (validation.ok) {
|
|
376
|
+
if (preflightOnly) {
|
|
377
|
+
const context = beginArtifactRecovery(tupleFor(request, resolved.taskId, authority), Buffer.from(content, 'utf8'), {
|
|
378
|
+
repoRoot: resolved.repoRoot, taskDir: resolved.taskDir,
|
|
379
|
+
...(authority ? { recoveryId: authority.operationId } : {}), lockAlreadyHeld: request.lockAlreadyHeld
|
|
380
|
+
});
|
|
381
|
+
const staged = stageArtifactCandidate(context, Buffer.from(content, 'utf8'), { lockAlreadyHeld: request.lockAlreadyHeld });
|
|
382
|
+
prepareArtifactRecoveryCommit(context, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: request.lockAlreadyHeld });
|
|
383
|
+
return { result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery: context, authority, lockAlreadyHeld: request.lockAlreadyHeld };
|
|
384
|
+
}
|
|
338
385
|
const context = recordArtifactRecoveryPassed(tupleFor(request, resolved.taskId, authority), {
|
|
339
386
|
repoRoot: resolved.repoRoot,
|
|
340
387
|
taskDir: resolved.taskDir,
|
|
@@ -344,7 +391,7 @@ function prepareLocalArtifact(
|
|
|
344
391
|
lockAlreadyHeld: request.lockAlreadyHeld
|
|
345
392
|
});
|
|
346
393
|
recovery = context;
|
|
347
|
-
return { result: { ...result, status: 'passed', error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
|
|
394
|
+
return { result: { ...result, status: 'passed', changed: false, error: null, recovery: recoveryInfo(context) }, content, repoRoot: resolved.repoRoot, recovery, authority, lockAlreadyHeld: request.lockAlreadyHeld };
|
|
348
395
|
}
|
|
349
396
|
const context = beginArtifactRecovery(tupleFor(request, resolved.taskId, authority), Buffer.from(content, 'utf8'), {
|
|
350
397
|
repoRoot: resolved.repoRoot,
|
|
@@ -362,7 +409,10 @@ function prepareLocalArtifact(
|
|
|
362
409
|
}
|
|
363
410
|
}
|
|
364
411
|
|
|
365
|
-
function commitLocalArtifactProvenance(
|
|
412
|
+
function commitLocalArtifactProvenance(
|
|
413
|
+
prepared: LocalArtifactPreparation,
|
|
414
|
+
options: Readonly<{ afterPublish?: () => void }> = {}
|
|
415
|
+
): LocalArtifactFinalizationResult {
|
|
366
416
|
if (!prepared.recovery || !prepared.repoRoot || prepared.result.status === 'failed' && !prepared.result.recovery) return prepared.result;
|
|
367
417
|
if (prepared.result.status === 'failed') return prepared.result;
|
|
368
418
|
try {
|
|
@@ -372,26 +422,42 @@ function commitLocalArtifactProvenance(prepared: LocalArtifactPreparation): Loca
|
|
|
372
422
|
if (intent.state === 'passed' || intent.state === 'consumed') {
|
|
373
423
|
return {
|
|
374
424
|
...prepared.result,
|
|
425
|
+
changed: false,
|
|
375
426
|
artifactSha256: intent.finalArtifactSha256,
|
|
376
427
|
semanticDigest: intent.finalSemanticDigest,
|
|
377
428
|
error: null
|
|
378
429
|
};
|
|
379
|
-
} else if (intent.state === '
|
|
380
|
-
|
|
381
|
-
|
|
430
|
+
} else if (intent.state === 'commit-started') {
|
|
431
|
+
const reconciled = reconcileArtifactRecovery(prepared.recovery, {
|
|
432
|
+
lockAlreadyHeld: prepared.lockAlreadyHeld,
|
|
433
|
+
afterPublish: options.afterPublish
|
|
434
|
+
});
|
|
435
|
+
committed = reconciled.intent;
|
|
436
|
+
} else if (intent.state === 'awaiting-preflight-recovery') {
|
|
382
437
|
const staged = stageArtifactCandidate(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
383
438
|
prepareArtifactRecoveryCommit(prepared.recovery, staged.candidateSha256, staged.semanticDigest, { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
384
439
|
committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
385
440
|
}
|
|
386
|
-
if (
|
|
441
|
+
if (intent.state === 'preflight-ready' || intent.state === 'preflight-commit-started') {
|
|
442
|
+
committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
443
|
+
}
|
|
444
|
+
if (committed?.state === 'preflight-passed' || intent.state === 'preflight-passed') {
|
|
445
|
+
prepareArtifactRecoveryFinal(prepared.recovery, Buffer.from(prepared.content, 'utf8'), { lockAlreadyHeld: prepared.lockAlreadyHeld });
|
|
446
|
+
committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
|
|
447
|
+
} else if (intent.state === 'full-finalizer-ready') {
|
|
448
|
+
committed = commitArtifactRecovery(prepared.recovery, { lockAlreadyHeld: prepared.lockAlreadyHeld, afterPublish: options.afterPublish });
|
|
449
|
+
}
|
|
450
|
+
if (!committed || committed.state !== 'passed') throw new Error(`ARTIFACT_RECOVERY_STATE_INVALID: commit ended in '${committed?.state ?? intent.state}'`);
|
|
387
451
|
return {
|
|
388
452
|
...prepared.result,
|
|
389
453
|
status: 'passed',
|
|
454
|
+
changed: false,
|
|
390
455
|
artifactSha256: committed.finalArtifactSha256,
|
|
391
456
|
semanticDigest: committed.finalSemanticDigest,
|
|
392
457
|
error: null
|
|
393
458
|
};
|
|
394
459
|
} catch (error) {
|
|
460
|
+
if (error instanceof Error && error.message.startsWith('TASK_WORKFLOW_FAULT_INJECTED:')) throw error;
|
|
395
461
|
return { ...prepared.result, status: 'failed', error: {
|
|
396
462
|
code: 'LOCAL_RECOVERY_COMMIT_FAILED', message: String(error)
|
|
397
463
|
} };
|
|
@@ -417,10 +483,20 @@ function finalizeLocalArtifact(
|
|
|
417
483
|
return result;
|
|
418
484
|
}
|
|
419
485
|
|
|
486
|
+
/** Validate and seal an immutable preflight generation without publishing the formal artifact. */
|
|
487
|
+
function preflightLocalArtifact(
|
|
488
|
+
request: LocalArtifactFinalizationRequest,
|
|
489
|
+
authority?: LifecycleRecoveryAttestationV1
|
|
490
|
+
): LocalArtifactFinalizationResult {
|
|
491
|
+
const prepared = prepareLocalArtifact(request, undefined, authority, true);
|
|
492
|
+
return prepared.result;
|
|
493
|
+
}
|
|
494
|
+
|
|
420
495
|
export {
|
|
421
496
|
LOCAL_ARTIFACT_REQUIRED_SECTIONS,
|
|
422
497
|
consumeLocalArtifactFinalizationIntent,
|
|
423
498
|
finalizeLocalArtifact,
|
|
499
|
+
preflightLocalArtifact,
|
|
424
500
|
prepareLocalArtifact,
|
|
425
501
|
commitLocalArtifactProvenance,
|
|
426
502
|
readArtifactRecoveryIntent as readLocalArtifactFinalizationIntent,
|