codex-workflow-v2 2.0.0-beta.12.5 → 2.0.0-beta.12.7
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 +29 -3
- package/dist/src/alpha6/downstream-proof.d.ts +41 -2
- package/dist/src/alpha6/downstream-proof.js +360 -5
- package/dist/src/alpha6/downstream-proof.js.map +1 -1
- package/dist/src/alpha6/plan-risk.js +2 -2
- package/dist/src/alpha6/plan-risk.js.map +1 -1
- package/dist/src/cli.js +57 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/contracts.d.ts +44 -1
- package/dist/src/dependency-provenance.d.ts +9 -2
- package/dist/src/dependency-provenance.js +49 -5
- package/dist/src/dependency-provenance.js.map +1 -1
- package/dist/src/git.d.ts +1 -1
- package/dist/src/git.js +4 -2
- package/dist/src/git.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/workflow.d.ts +11 -1
- package/dist/src/workflow.js +434 -28
- package/dist/src/workflow.js.map +1 -1
- package/docs/autonomy-guardrails.md +7 -0
- package/docs/development-flow.md +16 -0
- package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
- package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
- package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
- package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +26 -6
- package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +27 -7
- package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +34 -12
- package/docs/release.md +11 -1
- package/docs/updating-existing-project.md +61 -2
- package/docs/validation-report.md +92 -83
- package/package.json +2 -1
- package/plugins/codex-workflow-gateway/.codex-plugin/plugin.json +1 -1
- package/plugins/codex-workflow-gateway/references/protocol.md +12 -1
- package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +33 -1
- package/schemas/downstream-proof-invalidation-event.schema.json +1 -0
- package/schemas/downstream-proof-replan-recovery-event.schema.json +61 -0
- package/schemas/task.schema.json +56 -0
- package/scripts/generate-pdf-docs.py +13 -2
package/README.md
CHANGED
|
@@ -24,6 +24,10 @@ automation do not import anything from the parent V1 project.
|
|
|
24
24
|
- read-only update preflight for clean checkout, running-Step, and writer-lease blockers;
|
|
25
25
|
- beta.10 bounded recovery for one exact dependency-only HEAD, including retained product commits
|
|
26
26
|
proven by intact passed strict-review and reviewer-attestation chains;
|
|
27
|
+
- beta.12.6 active-downstream-proof update bridge: one exact dependency-only Task HEAD can be
|
|
28
|
+
registered while the already-classified proof work remains dirty and its Step remains active;
|
|
29
|
+
- beta.12.7 stranded-replan recovery: an accidental post-recovery Knowledge rebind cannot resurrect
|
|
30
|
+
the rejected Plan, and exact dirty bytes can be adopted by one newly authorized owning Step;
|
|
27
31
|
- bounded first-failure Plan-integrity recovery for legacy/in-flight contradictions that could not
|
|
28
32
|
be rejected by the current pre-authorization feasibility gate;
|
|
29
33
|
- read-only Milestone progress projection with stable membership ordinals and replacement links;
|
|
@@ -43,7 +47,7 @@ cross-machine synchronization are not part of its contract.
|
|
|
43
47
|
Node.js 22 or newer is required. Consumers pin the exact package version:
|
|
44
48
|
|
|
45
49
|
```bash
|
|
46
|
-
npm install --save-dev --save-exact codex-workflow-v2@2.0.0-beta.12.
|
|
50
|
+
npm install --save-dev --save-exact codex-workflow-v2@2.0.0-beta.12.7
|
|
47
51
|
npx codex-workflow gateway handshake --repo .
|
|
48
52
|
npx codex-workflow doctor --repo .
|
|
49
53
|
```
|
|
@@ -222,6 +226,27 @@ C1, and returns the same Task to ordinary planning. A fresh replacement Plan, Pl
|
|
|
222
226
|
execution authorization are required. Unrelated dirty files, non-predecessor ownership, unregistered
|
|
223
227
|
history, or damaged invalidation evidence fail closed.
|
|
224
228
|
|
|
229
|
+
If that exact condition is already present on an older package and therefore blocks the ordinary
|
|
230
|
+
clean-checkout update, beta.12.6 provides a bounded compatibility bridge. Update the active
|
|
231
|
+
Milestone base and Task branch with separate commits that each change only `package.json` and
|
|
232
|
+
`package-lock.json`, install the exact package, and continue only when fresh `next` advertises
|
|
233
|
+
`update downstream-proof-dependency-recover`. Its read-only preflight validates the candidate at
|
|
234
|
+
`HEAD`, the previously recorded Task history at `HEAD^`, aligned runtime/declared/locked/installed/
|
|
235
|
+
base versions, absence of leases and transactions, the exact active Step, predecessor ownership,
|
|
236
|
+
and a SHA-256 binding of every dirty product file. Recovery registers only that dependency commit,
|
|
237
|
+
does not touch product bytes or end the Step, and makes the existing `task downstream-proof-recover`
|
|
238
|
+
route reachable. Unknown history, an unrelated dirty path, a non-dependency candidate, stale base,
|
|
239
|
+
or changed authority remains a hard stop.
|
|
240
|
+
|
|
241
|
+
If beta.12.6 already invalidated the predecessor but a later Project Knowledge approval rebound the
|
|
242
|
+
rejected Plan, beta.12.7 exposes a separate exact compatibility route. Run the new package's read-only
|
|
243
|
+
`update downstream-proof-replan-update-preflight` before changing dependencies. After the two
|
|
244
|
+
dependency-only commits, follow fresh `next` through replan dependency registration and
|
|
245
|
+
`task downstream-proof-replan-recover`. Knowledge refresh may follow, but it cannot replace the
|
|
246
|
+
required `task plan-set`. Preserved dirty bytes are executable only when one replacement Step owns the
|
|
247
|
+
entire path set and `next` reports `downstreamProofCarryover.state=validated`; normal checks, Core commit,
|
|
248
|
+
and strict review remain mandatory.
|
|
249
|
+
|
|
225
250
|
Docker socket `EPERM`/permission errors observed inside a sandbox are access-boundary evidence, not
|
|
226
251
|
daemon-failure evidence. The gateway repeats the exact read-only probe or Plan check once with
|
|
227
252
|
sandbox escalation without consuming a remediation/infra retry. Docker Desktop restart or image
|
|
@@ -315,11 +340,12 @@ alpha regressions that must not recur are tracked in the
|
|
|
315
340
|
- [Chat-only development guide](docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf)
|
|
316
341
|
- [Deep technical reference](docs/pdf/codex-workflow-v2-technical-reference-ru.pdf)
|
|
317
342
|
|
|
318
|
-
These beta.12.
|
|
343
|
+
These beta.12.7 documents cover delegated Discovery, Milestone Autonomy Contract issuance,
|
|
319
344
|
dependency-aware Task routing, C1 credential handling, external-sealed reviews, bounded
|
|
320
345
|
Plan-integrity recovery, Task-local dependency provenance recovery, the human-confirmed legacy
|
|
321
346
|
attempt-four stop override (including append-only multi-rebind, dependency-only HEAD continuation,
|
|
322
|
-
|
|
347
|
+
evidence-accounted post-audit recovery revisions), both downstream-proof update bridges, exact
|
|
348
|
+
dirty-worktree carryover into a replacement Step, and the P04-A
|
|
323
349
|
structural-replacement stop boundary. Their reviewable
|
|
324
350
|
sources live in [`docs/pdf/sources`](docs/pdf/sources), and `npm run docs:pdf:check` proves the
|
|
325
351
|
tracked binaries were generated from the current source and package version. The PDFs remain
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DownstreamProofInvalidationEvent, TaskState } from '../contracts.js';
|
|
1
|
+
import type { DownstreamProofInvalidationEvent, DownstreamProofReplanCompatibility, DownstreamProofReplanRecoveryEvent, TaskState } from '../contracts.js';
|
|
2
2
|
import type { FileStateStore } from '../state/store.js';
|
|
3
3
|
export interface DownstreamProofInvalidatedStep {
|
|
4
4
|
stepId: string;
|
|
@@ -13,13 +13,38 @@ export interface DownstreamProofRecoveryAssessment {
|
|
|
13
13
|
outsideActiveStepFiles: string[];
|
|
14
14
|
invalidatedSteps: DownstreamProofInvalidatedStep[];
|
|
15
15
|
preservedHeadCommit: string | null;
|
|
16
|
+
dirtyWorktreeHash: string | null;
|
|
16
17
|
blockers: string[];
|
|
17
18
|
}
|
|
18
19
|
export interface PreparedDownstreamProofInvalidation {
|
|
19
20
|
event: DownstreamProofInvalidationEvent;
|
|
20
21
|
postimage: string;
|
|
21
22
|
}
|
|
22
|
-
export
|
|
23
|
+
export interface DownstreamProofReplanRecoveryAssessment {
|
|
24
|
+
applicable: boolean;
|
|
25
|
+
eligible: boolean;
|
|
26
|
+
invalidation: DownstreamProofInvalidationEvent | null;
|
|
27
|
+
rebind: TaskState['knowledgeRebinds'][number] | null;
|
|
28
|
+
compatibility: DownstreamProofReplanCompatibility | null;
|
|
29
|
+
dependencyRecoveryCommit: string | null;
|
|
30
|
+
blockers: string[];
|
|
31
|
+
}
|
|
32
|
+
export interface PreparedDownstreamProofReplanRecovery {
|
|
33
|
+
event: DownstreamProofReplanRecoveryEvent;
|
|
34
|
+
postimage: string;
|
|
35
|
+
}
|
|
36
|
+
export interface DownstreamProofCarryoverAssessment {
|
|
37
|
+
applicable: boolean;
|
|
38
|
+
eligible: boolean;
|
|
39
|
+
dirtyFiles: string[];
|
|
40
|
+
dirtyWorktreeHash: string | null;
|
|
41
|
+
invalidationEventId: string | null;
|
|
42
|
+
blockers: string[];
|
|
43
|
+
}
|
|
44
|
+
export declare function assessDownstreamProofRecovery(store: FileStateStore, repositoryRoot: string, task: TaskState, options?: {
|
|
45
|
+
historyBoundary?: string;
|
|
46
|
+
preservedHeadCommit?: string;
|
|
47
|
+
}): DownstreamProofRecoveryAssessment;
|
|
23
48
|
export declare function prepareDownstreamProofInvalidation(store: FileStateStore, resultTask: TaskState, assessment: DownstreamProofRecoveryAssessment, input: {
|
|
24
49
|
previousTaskRevision: number;
|
|
25
50
|
actor: string;
|
|
@@ -27,3 +52,17 @@ export declare function prepareDownstreamProofInvalidation(store: FileStateStore
|
|
|
27
52
|
}, now: Date): PreparedDownstreamProofInvalidation;
|
|
28
53
|
export declare function readDownstreamProofInvalidations(store: FileStateStore, task: TaskState): DownstreamProofInvalidationEvent[];
|
|
29
54
|
export declare function downstreamProofInvalidationSidecar(): string;
|
|
55
|
+
export declare function assessDownstreamProofReplanRecovery(store: FileStateStore, repositoryRoot: string, task: TaskState, options?: {
|
|
56
|
+
historyBoundary?: string;
|
|
57
|
+
preservedHeadCommit?: string;
|
|
58
|
+
currentPlanRiskAuditExists?: boolean;
|
|
59
|
+
}): DownstreamProofReplanRecoveryAssessment;
|
|
60
|
+
export declare function prepareDownstreamProofReplanRecovery(store: FileStateStore, resultTask: TaskState, assessment: DownstreamProofReplanRecoveryAssessment, input: {
|
|
61
|
+
previousTaskRevision: number;
|
|
62
|
+
actor: string;
|
|
63
|
+
reason: string;
|
|
64
|
+
}, now: Date): PreparedDownstreamProofReplanRecovery;
|
|
65
|
+
export declare function readDownstreamProofReplanRecoveries(store: FileStateStore, task: TaskState): DownstreamProofReplanRecoveryEvent[];
|
|
66
|
+
export declare function downstreamProofReplanRecoverySidecar(): string;
|
|
67
|
+
export declare function assessDownstreamProofCarryover(store: FileStateStore, repositoryRoot: string, task: TaskState, stepId: string): DownstreamProofCarryoverAssessment;
|
|
68
|
+
export declare function hashDirtyWorktree(repositoryRoot: string, dirtyFiles: readonly string[]): string;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
1
4
|
import { WorkflowError } from '../errors.js';
|
|
2
5
|
import { pathAllowed } from '../domain/validation.js';
|
|
3
6
|
import { changedFiles, currentBranch, headCommit, } from '../repository.js';
|
|
@@ -6,21 +9,37 @@ import { createUlid } from '../ulid.js';
|
|
|
6
9
|
import { PACKAGE_VERSION } from '../version.js';
|
|
7
10
|
import { prepareSidecarAppend } from './store-sidecars.js';
|
|
8
11
|
const SIDECAR = 'downstream-proof-invalidations.jsonl';
|
|
12
|
+
const REPLAN_RECOVERY_SIDECAR = 'downstream-proof-replan-recoveries.jsonl';
|
|
9
13
|
const HASH_64 = /^[a-f0-9]{64}$/;
|
|
10
14
|
const GIT_HASH = /^[a-f0-9]{40}$/;
|
|
11
15
|
const EVENT_ID = /^DPI-[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
12
|
-
|
|
16
|
+
const REPLAN_EVENT_ID = /^DPR-[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
17
|
+
export function assessDownstreamProofRecovery(store, repositoryRoot, task, options = {}) {
|
|
13
18
|
readDownstreamProofInvalidations(store, task);
|
|
14
19
|
const activeStep = task.steps.find((step) => step.status === 'in_progress') ?? null;
|
|
15
20
|
if (task.status !== 'in_progress' || !activeStep)
|
|
16
21
|
return emptyAssessment();
|
|
17
22
|
const dirtyFiles = changedFiles(repositoryRoot).sort();
|
|
23
|
+
let dirtyWorktreeHash = null;
|
|
24
|
+
try {
|
|
25
|
+
dirtyWorktreeHash = hashDirtyWorktree(repositoryRoot, dirtyFiles);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return {
|
|
29
|
+
...emptyAssessment(),
|
|
30
|
+
applicable: true,
|
|
31
|
+
activeStepId: activeStep.id,
|
|
32
|
+
dirtyFiles,
|
|
33
|
+
blockers: [error instanceof Error ? error.message : String(error)],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
18
36
|
const outsideActiveStepFiles = dirtyFiles.filter((file) => !pathAllowed(file, activeStep.allowedWrites));
|
|
19
37
|
if (outsideActiveStepFiles.length === 0) {
|
|
20
38
|
return {
|
|
21
39
|
...emptyAssessment(),
|
|
22
40
|
activeStepId: activeStep.id,
|
|
23
41
|
dirtyFiles,
|
|
42
|
+
dirtyWorktreeHash,
|
|
24
43
|
};
|
|
25
44
|
}
|
|
26
45
|
const blockers = [];
|
|
@@ -64,7 +83,7 @@ export function assessDownstreamProofRecovery(store, repositoryRoot, task) {
|
|
|
64
83
|
if (invalidatedSteps.length === 0)
|
|
65
84
|
blockers.push('No completed predecessor completion authority can be invalidated.');
|
|
66
85
|
try {
|
|
67
|
-
validateTaskHistory(repositoryRoot, task);
|
|
86
|
+
validateTaskHistory(repositoryRoot, task, options.historyBoundary);
|
|
68
87
|
}
|
|
69
88
|
catch (error) {
|
|
70
89
|
blockers.push(error instanceof Error ? error.message : String(error));
|
|
@@ -76,7 +95,8 @@ export function assessDownstreamProofRecovery(store, repositoryRoot, task) {
|
|
|
76
95
|
dirtyFiles,
|
|
77
96
|
outsideActiveStepFiles,
|
|
78
97
|
invalidatedSteps,
|
|
79
|
-
preservedHeadCommit: headCommit(repositoryRoot),
|
|
98
|
+
preservedHeadCommit: options.preservedHeadCommit ?? headCommit(repositoryRoot),
|
|
99
|
+
dirtyWorktreeHash,
|
|
80
100
|
blockers,
|
|
81
101
|
};
|
|
82
102
|
}
|
|
@@ -108,6 +128,7 @@ export function prepareDownstreamProofInvalidation(store, resultTask, assessment
|
|
|
108
128
|
triggerFiles: [...step.triggerFiles],
|
|
109
129
|
})),
|
|
110
130
|
dirtyFiles: [...assessment.dirtyFiles],
|
|
131
|
+
dirtyWorktreeHash: assessment.dirtyWorktreeHash,
|
|
111
132
|
preservedHeadCommit: assessment.preservedHeadCommit,
|
|
112
133
|
actor,
|
|
113
134
|
reason,
|
|
@@ -123,6 +144,254 @@ export function readDownstreamProofInvalidations(store, task) {
|
|
|
123
144
|
export function downstreamProofInvalidationSidecar() {
|
|
124
145
|
return SIDECAR;
|
|
125
146
|
}
|
|
147
|
+
export function assessDownstreamProofReplanRecovery(store, repositoryRoot, task, options = {}) {
|
|
148
|
+
const invalidations = readDownstreamProofInvalidations(store, task);
|
|
149
|
+
const recoveries = readDownstreamProofReplanRecoveries(store, task);
|
|
150
|
+
const invalidation = invalidations.at(-1) ?? null;
|
|
151
|
+
const rebind = task.knowledgeRebinds.at(-1) ?? null;
|
|
152
|
+
if (invalidation && recoveries.some((event) => event.invalidationEventId === invalidation.eventId
|
|
153
|
+
&& event.resultTaskRevision <= task.revision)) {
|
|
154
|
+
return emptyReplanAssessment();
|
|
155
|
+
}
|
|
156
|
+
if (!invalidation || !rebind
|
|
157
|
+
|| rebind.previousPlanHash !== invalidation.planHash
|
|
158
|
+
|| rebind.reboundPlanHash !== task.planHash
|
|
159
|
+
|| Date.parse(rebind.recordedAt) <= Date.parse(invalidation.recordedAt)) {
|
|
160
|
+
return emptyReplanAssessment();
|
|
161
|
+
}
|
|
162
|
+
const blockers = [];
|
|
163
|
+
const dirtyFiles = changedFiles(repositoryRoot).sort();
|
|
164
|
+
let dirtyWorktreeHash = null;
|
|
165
|
+
try {
|
|
166
|
+
dirtyWorktreeHash = hashDirtyWorktree(repositoryRoot, dirtyFiles);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
blockers.push(error instanceof Error ? error.message : String(error));
|
|
170
|
+
}
|
|
171
|
+
const sourceBinding = [...(task.dependencyProvenanceRecoveries ?? [])]
|
|
172
|
+
.reverse()
|
|
173
|
+
.map((record) => record.activeDownstreamProof)
|
|
174
|
+
.find((binding) => binding
|
|
175
|
+
&& binding.planHash === invalidation.planHash
|
|
176
|
+
&& binding.activeStepId === invalidation.activeStepId
|
|
177
|
+
&& JSON.stringify([...binding.dirtyFiles].sort()) === JSON.stringify(invalidation.dirtyFiles)
|
|
178
|
+
&& JSON.stringify([...binding.invalidatedStepIds].sort())
|
|
179
|
+
=== JSON.stringify(invalidation.invalidatedSteps.map((step) => step.stepId).sort()))
|
|
180
|
+
?? null;
|
|
181
|
+
if (!sourceBinding) {
|
|
182
|
+
blockers.push('No dependency provenance record preserves the exact active downstream-proof dirty-set binding.');
|
|
183
|
+
}
|
|
184
|
+
else if (dirtyWorktreeHash !== sourceBinding.dirtyWorktreeHash) {
|
|
185
|
+
blockers.push('Dirty file content changed after the downstream-proof invalidation boundary.');
|
|
186
|
+
}
|
|
187
|
+
if (JSON.stringify(dirtyFiles) !== JSON.stringify(invalidation.dirtyFiles)) {
|
|
188
|
+
blockers.push('Dirty files changed after the downstream-proof invalidation boundary.');
|
|
189
|
+
}
|
|
190
|
+
const registeredCompatibility = [...(task.dependencyProvenanceRecoveries ?? [])]
|
|
191
|
+
.reverse()
|
|
192
|
+
.find((record) => record.downstreamProofReplan?.invalidationEventId === invalidation.eventId
|
|
193
|
+
&& record.downstreamProofReplan.reboundPlanHash === task.planHash) ?? null;
|
|
194
|
+
const sourceHead = options.preservedHeadCommit
|
|
195
|
+
?? registeredCompatibility?.parentCommitSha
|
|
196
|
+
?? headCommit(repositoryRoot);
|
|
197
|
+
if (sourceHead !== invalidation.preservedHeadCommit) {
|
|
198
|
+
blockers.push('The recovery history boundary no longer matches the downstream-proof preserved HEAD.');
|
|
199
|
+
}
|
|
200
|
+
if (task.workspaceOwner !== 'local')
|
|
201
|
+
blockers.push('Recovery requires the recorded local Task workspace.');
|
|
202
|
+
if (!task.taskBranch || currentBranch(repositoryRoot) !== task.taskBranch) {
|
|
203
|
+
blockers.push('Current branch must equal the recorded Task branch.');
|
|
204
|
+
}
|
|
205
|
+
if (task.status !== 'awaiting_execution_authorization') {
|
|
206
|
+
blockers.push('The legacy stranded Task must still be awaiting execution authorization.');
|
|
207
|
+
}
|
|
208
|
+
if (!task.planHash || task.planHash !== rebind.reboundPlanHash) {
|
|
209
|
+
blockers.push('The current Plan must be the exact mechanically rebound legacy Plan.');
|
|
210
|
+
}
|
|
211
|
+
if (task.knowledgeMapRevision !== rebind.toKnowledgeMapRevision
|
|
212
|
+
|| task.knowledgeMapHash !== rebind.toKnowledgeMapHash) {
|
|
213
|
+
blockers.push('The current Project Knowledge binding must match the legacy rebind record.');
|
|
214
|
+
}
|
|
215
|
+
for (const invalidated of invalidation.invalidatedSteps) {
|
|
216
|
+
const step = task.steps.find((candidate) => candidate.id === invalidated.stepId);
|
|
217
|
+
if (!step || step.status !== 'planned' || step.evidence !== null
|
|
218
|
+
|| !(task.invalidatedStepCommits ?? []).includes(invalidated.evidenceCommit)) {
|
|
219
|
+
blockers.push(`Invalidated predecessor ${invalidated.stepId} no longer has the preserved planned/no-evidence posture.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const activeStep = task.steps.find((step) => step.id === invalidation.activeStepId);
|
|
223
|
+
if (!activeStep || activeStep.status !== 'blocked' || activeStep.evidence !== null) {
|
|
224
|
+
blockers.push(`Former proof Step ${invalidation.activeStepId} no longer has the preserved blocked/no-evidence posture.`);
|
|
225
|
+
}
|
|
226
|
+
if (task.authorizations.some((authorization) => authorization.kind === 'execution'
|
|
227
|
+
&& authorization.decision === 'approved'
|
|
228
|
+
&& authorization.planHash === task.planHash)) {
|
|
229
|
+
blockers.push('The rebound Plan has already received execution authorization.');
|
|
230
|
+
}
|
|
231
|
+
if (options.currentPlanRiskAuditExists) {
|
|
232
|
+
blockers.push('The rebound Plan already has a recorded current Plan Risk Audit.');
|
|
233
|
+
}
|
|
234
|
+
const expectedRevision = invalidation.resultTaskRevision + (registeredCompatibility ? 2 : 1);
|
|
235
|
+
if (task.revision !== expectedRevision) {
|
|
236
|
+
blockers.push('Task revision contains changes beyond the exact invalidation, accidental rebind, and optional dependency registration sequence.');
|
|
237
|
+
}
|
|
238
|
+
if (registeredCompatibility) {
|
|
239
|
+
if (registeredCompatibility.commitSha !== headCommit(repositoryRoot)
|
|
240
|
+
|| registeredCompatibility.downstreamProofReplan?.dirtyWorktreeHash !== dirtyWorktreeHash) {
|
|
241
|
+
blockers.push('The registered dependency compatibility record does not bind the current HEAD and dirty worktree.');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (!options.historyBoundary && headCommit(repositoryRoot) !== invalidation.preservedHeadCommit) {
|
|
245
|
+
blockers.push('Unregistered Git history exists after the downstream-proof invalidation boundary.');
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
validateTaskHistory(repositoryRoot, task, options.historyBoundary);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
blockers.push(error instanceof Error ? error.message : String(error));
|
|
252
|
+
}
|
|
253
|
+
const compatibility = sourceBinding && dirtyWorktreeHash
|
|
254
|
+
? {
|
|
255
|
+
invalidationEventId: invalidation.eventId,
|
|
256
|
+
activeStepId: invalidation.activeStepId,
|
|
257
|
+
sourcePlanHash: invalidation.planHash,
|
|
258
|
+
reboundPlanHash: rebind.reboundPlanHash,
|
|
259
|
+
knowledgeRebindRecordedAt: rebind.recordedAt,
|
|
260
|
+
dirtyFiles: [...invalidation.dirtyFiles],
|
|
261
|
+
dirtyWorktreeHash,
|
|
262
|
+
invalidatedStepIds: invalidation.invalidatedSteps.map((step) => step.stepId).sort(),
|
|
263
|
+
}
|
|
264
|
+
: null;
|
|
265
|
+
return {
|
|
266
|
+
applicable: true,
|
|
267
|
+
eligible: blockers.length === 0 && compatibility !== null,
|
|
268
|
+
invalidation,
|
|
269
|
+
rebind,
|
|
270
|
+
compatibility,
|
|
271
|
+
dependencyRecoveryCommit: registeredCompatibility?.commitSha ?? null,
|
|
272
|
+
blockers,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
export function prepareDownstreamProofReplanRecovery(store, resultTask, assessment, input, now) {
|
|
276
|
+
if (!assessment.eligible || !assessment.invalidation || !assessment.rebind || !assessment.compatibility) {
|
|
277
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Downstream-proof replan compatibility assessment is not eligible.');
|
|
278
|
+
}
|
|
279
|
+
const actor = normalizeRequired(input.actor, 'Recovery actor');
|
|
280
|
+
const reason = normalizeRequired(input.reason, 'Recovery reason');
|
|
281
|
+
const events = readDownstreamProofReplanRecoveries(store, resultTask);
|
|
282
|
+
const prepared = prepareSidecarAppend(events, {
|
|
283
|
+
eventId: `DPR-${createUlid(now.getTime())}`,
|
|
284
|
+
recordedAt: now.toISOString(),
|
|
285
|
+
taskId: resultTask.id,
|
|
286
|
+
previousTaskRevision: input.previousTaskRevision,
|
|
287
|
+
resultTaskRevision: resultTask.revision,
|
|
288
|
+
invalidationEventId: assessment.invalidation.eventId,
|
|
289
|
+
invalidationPlanHash: assessment.invalidation.planHash,
|
|
290
|
+
reboundPlanHash: assessment.rebind.reboundPlanHash,
|
|
291
|
+
knowledgeRebindRecordedAt: assessment.rebind.recordedAt,
|
|
292
|
+
dirtyFiles: [...assessment.compatibility.dirtyFiles],
|
|
293
|
+
dirtyWorktreeHash: assessment.compatibility.dirtyWorktreeHash,
|
|
294
|
+
preservedHeadCommit: headCommit(resultTask.repositoryRoot),
|
|
295
|
+
dependencyRecoveryCommit: assessment.dependencyRecoveryCommit,
|
|
296
|
+
actor,
|
|
297
|
+
reason,
|
|
298
|
+
packageVersion: PACKAGE_VERSION,
|
|
299
|
+
}, {
|
|
300
|
+
validateEvent: (event) => validateReplanRecoveryEvent(event, resultTask),
|
|
301
|
+
});
|
|
302
|
+
return { event: prepared.event, postimage: prepared.content };
|
|
303
|
+
}
|
|
304
|
+
export function readDownstreamProofReplanRecoveries(store, task) {
|
|
305
|
+
return store.readSidecarEvents(store.taskRoot(task.projectId, task.id), REPLAN_RECOVERY_SIDECAR, { validateEvent: (event) => validateReplanRecoveryEvent(event, task) });
|
|
306
|
+
}
|
|
307
|
+
export function downstreamProofReplanRecoverySidecar() {
|
|
308
|
+
return REPLAN_RECOVERY_SIDECAR;
|
|
309
|
+
}
|
|
310
|
+
export function assessDownstreamProofCarryover(store, repositoryRoot, task, stepId) {
|
|
311
|
+
const invalidation = readDownstreamProofInvalidations(store, task).at(-1) ?? null;
|
|
312
|
+
if (!invalidation || !task.planHash || task.planHash === invalidation.planHash) {
|
|
313
|
+
return {
|
|
314
|
+
applicable: false,
|
|
315
|
+
eligible: false,
|
|
316
|
+
dirtyFiles: [],
|
|
317
|
+
dirtyWorktreeHash: null,
|
|
318
|
+
invalidationEventId: null,
|
|
319
|
+
blockers: [],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const recovery = [...readDownstreamProofReplanRecoveries(store, task)]
|
|
323
|
+
.reverse()
|
|
324
|
+
.find((event) => event.invalidationEventId === invalidation.eventId) ?? null;
|
|
325
|
+
const sourceBinding = [...(task.dependencyProvenanceRecoveries ?? [])]
|
|
326
|
+
.reverse()
|
|
327
|
+
.map((record) => record.activeDownstreamProof)
|
|
328
|
+
.find((binding) => binding?.planHash === invalidation.planHash
|
|
329
|
+
&& JSON.stringify([...binding.dirtyFiles].sort()) === JSON.stringify(invalidation.dirtyFiles)) ?? null;
|
|
330
|
+
const expectedDirtyHash = recovery?.dirtyWorktreeHash
|
|
331
|
+
?? invalidation.dirtyWorktreeHash
|
|
332
|
+
?? sourceBinding?.dirtyWorktreeHash
|
|
333
|
+
?? null;
|
|
334
|
+
const expectedHead = recovery?.preservedHeadCommit ?? invalidation.preservedHeadCommit;
|
|
335
|
+
const dirtyFiles = changedFiles(repositoryRoot).sort();
|
|
336
|
+
let dirtyWorktreeHash = null;
|
|
337
|
+
const blockers = [];
|
|
338
|
+
try {
|
|
339
|
+
dirtyWorktreeHash = hashDirtyWorktree(repositoryRoot, dirtyFiles);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
blockers.push(error instanceof Error ? error.message : String(error));
|
|
343
|
+
}
|
|
344
|
+
const step = task.steps.find((candidate) => candidate.id === stepId) ?? null;
|
|
345
|
+
if (!step || step.status !== 'planned' || step.evidence !== null) {
|
|
346
|
+
blockers.push('The replacement carryover Step must be planned and have no completion evidence.');
|
|
347
|
+
}
|
|
348
|
+
if (task.steps.some((candidate) => candidate.status === 'in_progress' || candidate.evidence !== null)) {
|
|
349
|
+
blockers.push('No replacement Plan Step may already be running or completed.');
|
|
350
|
+
}
|
|
351
|
+
if (JSON.stringify(dirtyFiles) !== JSON.stringify(invalidation.dirtyFiles)) {
|
|
352
|
+
blockers.push('Dirty files changed after downstream-proof recovery.');
|
|
353
|
+
}
|
|
354
|
+
if (!expectedDirtyHash) {
|
|
355
|
+
blockers.push('No hash-bound downstream-proof dirty-worktree evidence is available.');
|
|
356
|
+
}
|
|
357
|
+
else if (dirtyWorktreeHash !== expectedDirtyHash) {
|
|
358
|
+
blockers.push('Dirty file content changed after downstream-proof recovery.');
|
|
359
|
+
}
|
|
360
|
+
if (step && dirtyFiles.some((file) => !pathAllowed(file, step.allowedWrites))) {
|
|
361
|
+
blockers.push('The replacement Step does not own the complete preserved dirty set.');
|
|
362
|
+
}
|
|
363
|
+
if (headCommit(repositoryRoot) !== expectedHead) {
|
|
364
|
+
blockers.push('Git HEAD changed after downstream-proof recovery.');
|
|
365
|
+
}
|
|
366
|
+
if (task.workspaceOwner !== 'local' || !task.taskBranch || currentBranch(repositoryRoot) !== task.taskBranch) {
|
|
367
|
+
blockers.push('Carryover execution requires the unchanged recorded local Task branch.');
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
validateTaskHistory(repositoryRoot, task);
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
blockers.push(error instanceof Error ? error.message : String(error));
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
applicable: true,
|
|
377
|
+
eligible: blockers.length === 0,
|
|
378
|
+
dirtyFiles,
|
|
379
|
+
dirtyWorktreeHash,
|
|
380
|
+
invalidationEventId: invalidation.eventId,
|
|
381
|
+
blockers,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function emptyReplanAssessment() {
|
|
385
|
+
return {
|
|
386
|
+
applicable: false,
|
|
387
|
+
eligible: false,
|
|
388
|
+
invalidation: null,
|
|
389
|
+
rebind: null,
|
|
390
|
+
compatibility: null,
|
|
391
|
+
dependencyRecoveryCommit: null,
|
|
392
|
+
blockers: [],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
126
395
|
function emptyAssessment() {
|
|
127
396
|
return {
|
|
128
397
|
applicable: false,
|
|
@@ -132,9 +401,38 @@ function emptyAssessment() {
|
|
|
132
401
|
outsideActiveStepFiles: [],
|
|
133
402
|
invalidatedSteps: [],
|
|
134
403
|
preservedHeadCommit: null,
|
|
404
|
+
dirtyWorktreeHash: null,
|
|
135
405
|
blockers: [],
|
|
136
406
|
};
|
|
137
407
|
}
|
|
408
|
+
export function hashDirtyWorktree(repositoryRoot, dirtyFiles) {
|
|
409
|
+
const hash = createHash('sha256');
|
|
410
|
+
for (const file of [...dirtyFiles].sort()) {
|
|
411
|
+
const absolute = path.resolve(repositoryRoot, file);
|
|
412
|
+
const relative = path.relative(repositoryRoot, absolute);
|
|
413
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
414
|
+
throw new WorkflowError('GIT_PRECONDITION_FAILED', `Dirty path escapes the repository boundary: ${file}`);
|
|
415
|
+
}
|
|
416
|
+
hash.update(`path\0${file}\0`);
|
|
417
|
+
if (!existsSync(absolute)) {
|
|
418
|
+
hash.update('deleted\0');
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const stat = lstatSync(absolute);
|
|
422
|
+
if (stat.isSymbolicLink()) {
|
|
423
|
+
hash.update(`symlink\0${readlinkSync(absolute)}\0`);
|
|
424
|
+
}
|
|
425
|
+
else if (stat.isFile()) {
|
|
426
|
+
hash.update('file\0');
|
|
427
|
+
hash.update(readFileSync(absolute));
|
|
428
|
+
hash.update('\0');
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
throw new WorkflowError('GIT_PRECONDITION_FAILED', `Dirty path is not a regular file or symlink: ${file}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return hash.digest('hex');
|
|
435
|
+
}
|
|
138
436
|
function transitivePredecessors(task, stepId, blockers) {
|
|
139
437
|
const byId = new Map(task.steps.map((step) => [step.id, step]));
|
|
140
438
|
const result = new Set();
|
|
@@ -179,13 +477,17 @@ function completedDependentClosure(task, seedIds, predecessorIds) {
|
|
|
179
477
|
return result;
|
|
180
478
|
}
|
|
181
479
|
function validateInvalidationEvent(event, task) {
|
|
182
|
-
const
|
|
480
|
+
const requiredKeys = [
|
|
183
481
|
'activeStepId', 'actor', 'dirtyFiles', 'eventHash', 'eventId', 'invalidatedSteps', 'packageVersion',
|
|
184
482
|
'planHash', 'preservedHeadCommit', 'previousEventHash', 'previousTaskRevision', 'reason', 'recordedAt',
|
|
185
483
|
'resultTaskRevision', 'taskId',
|
|
186
484
|
];
|
|
485
|
+
const actualKeys = Object.keys(event).sort();
|
|
486
|
+
const expectedLegacyKeys = [...requiredKeys].sort();
|
|
487
|
+
const expectedCurrentKeys = [...requiredKeys, 'dirtyWorktreeHash'].sort();
|
|
187
488
|
if (!event || typeof event !== 'object' || Array.isArray(event)
|
|
188
|
-
|| JSON.stringify(
|
|
489
|
+
|| (JSON.stringify(actualKeys) !== JSON.stringify(expectedLegacyKeys)
|
|
490
|
+
&& JSON.stringify(actualKeys) !== JSON.stringify(expectedCurrentKeys))) {
|
|
189
491
|
throw new WorkflowError('STATE_CORRUPT', 'Downstream proof invalidation event contains unexpected fields.');
|
|
190
492
|
}
|
|
191
493
|
if (typeof event.eventId !== 'string' || !EVENT_ID.test(event.eventId)) {
|
|
@@ -220,6 +522,10 @@ function validateInvalidationEvent(event, task) {
|
|
|
220
522
|
|| JSON.stringify(event.dirtyFiles) !== JSON.stringify([...event.dirtyFiles].sort())) {
|
|
221
523
|
throw new WorkflowError('STATE_CORRUPT', 'Downstream proof invalidation dirtyFiles are invalid.');
|
|
222
524
|
}
|
|
525
|
+
if (event.dirtyWorktreeHash !== undefined
|
|
526
|
+
&& (typeof event.dirtyWorktreeHash !== 'string' || !HASH_64.test(event.dirtyWorktreeHash))) {
|
|
527
|
+
throw new WorkflowError('STATE_CORRUPT', 'Downstream proof invalidation dirtyWorktreeHash is invalid.');
|
|
528
|
+
}
|
|
223
529
|
if (!Array.isArray(event.invalidatedSteps) || event.invalidatedSteps.length === 0) {
|
|
224
530
|
throw new WorkflowError('STATE_CORRUPT', 'Downstream proof invalidation must identify invalidated Steps.');
|
|
225
531
|
}
|
|
@@ -240,6 +546,55 @@ function validateInvalidationEvent(event, task) {
|
|
|
240
546
|
stepIds.add(step.stepId);
|
|
241
547
|
}
|
|
242
548
|
}
|
|
549
|
+
function validateReplanRecoveryEvent(event, task) {
|
|
550
|
+
const expectedKeys = [
|
|
551
|
+
'actor', 'dependencyRecoveryCommit', 'dirtyFiles', 'dirtyWorktreeHash', 'eventHash', 'eventId',
|
|
552
|
+
'invalidationEventId', 'invalidationPlanHash', 'knowledgeRebindRecordedAt', 'packageVersion',
|
|
553
|
+
'preservedHeadCommit', 'previousEventHash', 'previousTaskRevision', 'reason', 'reboundPlanHash',
|
|
554
|
+
'recordedAt', 'resultTaskRevision', 'taskId',
|
|
555
|
+
];
|
|
556
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)
|
|
557
|
+
|| JSON.stringify(Object.keys(event).sort()) !== JSON.stringify(expectedKeys.sort())) {
|
|
558
|
+
throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery event contains unexpected fields.');
|
|
559
|
+
}
|
|
560
|
+
if (typeof event.eventId !== 'string' || !REPLAN_EVENT_ID.test(event.eventId)) {
|
|
561
|
+
throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery eventId is invalid.');
|
|
562
|
+
}
|
|
563
|
+
if (event.taskId !== task.id
|
|
564
|
+
|| !Number.isInteger(event.previousTaskRevision)
|
|
565
|
+
|| event.previousTaskRevision < 1
|
|
566
|
+
|| event.resultTaskRevision !== event.previousTaskRevision + 1
|
|
567
|
+
|| event.resultTaskRevision > task.revision) {
|
|
568
|
+
throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery Task/revision binding is invalid.');
|
|
569
|
+
}
|
|
570
|
+
if (typeof event.invalidationEventId !== 'string' || !EVENT_ID.test(event.invalidationEventId)
|
|
571
|
+
|| typeof event.invalidationPlanHash !== 'string' || !HASH_64.test(event.invalidationPlanHash)
|
|
572
|
+
|| typeof event.reboundPlanHash !== 'string' || !HASH_64.test(event.reboundPlanHash)
|
|
573
|
+
|| typeof event.dirtyWorktreeHash !== 'string' || !HASH_64.test(event.dirtyWorktreeHash)
|
|
574
|
+
|| typeof event.preservedHeadCommit !== 'string' || !GIT_HASH.test(event.preservedHeadCommit)
|
|
575
|
+
|| (event.dependencyRecoveryCommit !== null
|
|
576
|
+
&& (typeof event.dependencyRecoveryCommit !== 'string' || !GIT_HASH.test(event.dependencyRecoveryCommit)))) {
|
|
577
|
+
throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery hash binding is invalid.');
|
|
578
|
+
}
|
|
579
|
+
if (!Number.isFinite(Date.parse(event.knowledgeRebindRecordedAt))) {
|
|
580
|
+
throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery knowledge rebind timestamp is invalid.');
|
|
581
|
+
}
|
|
582
|
+
if (!Array.isArray(event.dirtyFiles) || event.dirtyFiles.length === 0
|
|
583
|
+
|| event.dirtyFiles.some((file) => typeof file !== 'string' || !file.trim())
|
|
584
|
+
|| new Set(event.dirtyFiles).size !== event.dirtyFiles.length
|
|
585
|
+
|| JSON.stringify(event.dirtyFiles) !== JSON.stringify([...event.dirtyFiles].sort())) {
|
|
586
|
+
throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery dirtyFiles are invalid.');
|
|
587
|
+
}
|
|
588
|
+
for (const [value, label] of [
|
|
589
|
+
[event.actor, 'actor'],
|
|
590
|
+
[event.reason, 'reason'],
|
|
591
|
+
[event.packageVersion, 'packageVersion'],
|
|
592
|
+
]) {
|
|
593
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
594
|
+
throw new WorkflowError('STATE_CORRUPT', `Downstream-proof replan recovery ${label} is required.`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
243
598
|
function normalizeRequired(value, label) {
|
|
244
599
|
if (typeof value !== 'string' || !value.trim()) {
|
|
245
600
|
throw new WorkflowError('INVALID_ARGUMENT', `${label} is required.`);
|