codex-workflow-v2 2.0.0-alpha.4 → 2.0.0-alpha.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 +114 -10
- package/dist/src/alpha6/adoption.d.ts +55 -0
- package/dist/src/alpha6/adoption.js +920 -0
- package/dist/src/alpha6/adoption.js.map +1 -0
- package/dist/src/alpha6/handoff.d.ts +40 -0
- package/dist/src/alpha6/handoff.js +981 -0
- package/dist/src/alpha6/handoff.js.map +1 -0
- package/dist/src/alpha6/journal.d.ts +30 -0
- package/dist/src/alpha6/journal.js +369 -0
- package/dist/src/alpha6/journal.js.map +1 -0
- package/dist/src/alpha6/milestone.d.ts +52 -0
- package/dist/src/alpha6/milestone.js +1103 -0
- package/dist/src/alpha6/milestone.js.map +1 -0
- package/dist/src/alpha6/plan-risk.d.ts +32 -0
- package/dist/src/alpha6/plan-risk.js +908 -0
- package/dist/src/alpha6/plan-risk.js.map +1 -0
- package/dist/src/alpha6/remediation.d.ts +21 -0
- package/dist/src/alpha6/remediation.js +754 -0
- package/dist/src/alpha6/remediation.js.map +1 -0
- package/dist/src/alpha6/review.d.ts +46 -0
- package/dist/src/alpha6/review.js +785 -0
- package/dist/src/alpha6/review.js.map +1 -0
- package/dist/src/alpha6/store-sidecars.d.ts +35 -0
- package/dist/src/alpha6/store-sidecars.js +281 -0
- package/dist/src/alpha6/store-sidecars.js.map +1 -0
- package/dist/src/alpha7/autonomy.d.ts +61 -0
- package/dist/src/alpha7/autonomy.js +256 -0
- package/dist/src/alpha7/autonomy.js.map +1 -0
- package/dist/src/cli.js +109 -19
- package/dist/src/cli.js.map +1 -1
- package/dist/src/contracts.d.ts +268 -0
- package/dist/src/git.js +2 -1
- package/dist/src/git.js.map +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/reviewer.d.ts +6 -1
- package/dist/src/reviewer.js +145 -32
- package/dist/src/reviewer.js.map +1 -1
- package/dist/src/state/lock.d.ts +1 -0
- package/dist/src/state/lock.js +7 -1
- package/dist/src/state/lock.js.map +1 -1
- package/dist/src/state/store.d.ts +39 -1
- package/dist/src/state/store.js +127 -1
- package/dist/src/state/store.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 +113 -8
- package/dist/src/workflow.js +1635 -78
- package/dist/src/workflow.js.map +1 -1
- package/docs/autonomy-guardrails.md +164 -0
- package/docs/decisions.md +39 -0
- package/docs/delegated-approval.md +37 -5
- package/docs/development-flow.md +45 -1
- package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +155 -136
- package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +236 -223
- package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +225 -206
- package/docs/project-memory.md +7 -0
- package/docs/release.md +9 -0
- package/docs/updating-existing-project.md +63 -5
- package/docs/validation-report.md +52 -34
- package/package.json +1 -1
- package/plugins/codex-workflow-gateway/references/protocol.md +182 -6
- package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +35 -1
- package/references/git-policy.md +5 -2
- package/references/state-machine.md +43 -0
- package/references/validation-and-review.md +28 -1
- package/roles/delivery-coordinator.md +21 -1
- package/roles/independent-reviewer.md +3 -0
- package/roles/technical-planner.md +10 -0
- package/roles/worker.md +4 -0
- package/schemas/adoption-posture-event.schema.json +129 -0
- package/schemas/corrective-decision-event.schema.json +55 -0
- package/schemas/corrective-plan-audit.schema.json +20 -0
- package/schemas/milestone-autonomy-event.schema.json +45 -0
- package/schemas/milestone-scope-change-event.schema.json +70 -0
- package/schemas/milestone-transaction-journal.schema.json +95 -0
- package/schemas/plan-risk-audit-event.schema.json +107 -0
- package/schemas/remediation-event.schema.json +53 -0
- package/schemas/reviewer-attestation-event.schema.json +49 -0
- package/schemas/step-review-event.schema.json +74 -0
- package/schemas/task-handoff-event.schema.json +116 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { WorkflowError } from '../errors.js';
|
|
4
|
+
import { createEntityId } from '../ulid.js';
|
|
5
|
+
import { createJournalSnapshotFromContent, captureJournalTargetSnapshot, listMilestoneTransactionJournals, readJournalSnapshotContent, removeMilestoneTransactionJournal, resolveMilestoneTransactionTargetPath, updateMilestoneTransactionJournalPhase, writeMilestoneTransactionJournal, } from './journal.js';
|
|
6
|
+
import { assertHash64, assertNoSymlinkPathComponents, canonicalJsonStringify, computeSidecarEventHash, prepareSidecarAppend, sha256Hex, } from './store-sidecars.js';
|
|
7
|
+
const SCOPE_CHANGE_SIDECAR = 'scope-change-events.jsonl';
|
|
8
|
+
const MILESTONE_TARGETS = ['state.json', 'plan.json', SCOPE_CHANGE_SIDECAR];
|
|
9
|
+
const SCOPE_CHANGE_ALLOWED_STATUSES = new Set([
|
|
10
|
+
'planning',
|
|
11
|
+
'awaiting_execution_authorization',
|
|
12
|
+
'active',
|
|
13
|
+
'blocked',
|
|
14
|
+
]);
|
|
15
|
+
export function normalizeMilestonePlan(plan) {
|
|
16
|
+
return {
|
|
17
|
+
outcome: plan.outcome.trim(),
|
|
18
|
+
successSignal: plan.successSignal.trim(),
|
|
19
|
+
acceptance: [...plan.acceptance],
|
|
20
|
+
memberships: plan.memberships.map((membership) => ({
|
|
21
|
+
...membership,
|
|
22
|
+
reason: membership.reason.trim(),
|
|
23
|
+
})),
|
|
24
|
+
checks: [...plan.checks],
|
|
25
|
+
taskIds: plan.memberships.map((membership) => membership.taskId),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function readMilestoneWithIntegrity(store, projectId, milestoneId, readTask, listTasks) {
|
|
29
|
+
recoverMilestoneScopeChangeTransactions(store, projectId, milestoneId, readTask);
|
|
30
|
+
const milestone = store.readMilestone(projectId, milestoneId);
|
|
31
|
+
assertMilestonePlanAgreement(store, milestone);
|
|
32
|
+
assertMilestoneMembershipIntegrity(milestone, readTask, {}, listTasks);
|
|
33
|
+
return milestone;
|
|
34
|
+
}
|
|
35
|
+
export function readMilestoneForScopeChange(store, projectId, milestoneId, readTask, listTasks) {
|
|
36
|
+
recoverMilestoneScopeChangeTransactions(store, projectId, milestoneId, readTask);
|
|
37
|
+
const milestone = store.readMilestone(projectId, milestoneId);
|
|
38
|
+
assertMilestonePlanAgreement(store, milestone);
|
|
39
|
+
assertMilestoneMembershipIntegrity(milestone, readTask, { allowRepairableRequiredCancelled: true, allowRepairableReverseOrphans: true }, listTasks);
|
|
40
|
+
return milestone;
|
|
41
|
+
}
|
|
42
|
+
export function listMilestonesWithIntegrity(store, projectId, readTask, listTasks) {
|
|
43
|
+
const milestonesRoot = path.join(store.projectRoot(projectId), 'milestones');
|
|
44
|
+
if (!existsSync(milestonesRoot))
|
|
45
|
+
return [];
|
|
46
|
+
return readdirSync(milestonesRoot, { withFileTypes: true })
|
|
47
|
+
.filter((entry) => entry.isDirectory())
|
|
48
|
+
.map((entry) => readMilestoneWithIntegrity(store, projectId, entry.name, readTask, listTasks))
|
|
49
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
|
50
|
+
}
|
|
51
|
+
export function assertMilestoneMembershipIntegrity(milestone, readTask, options = {}, listTasks) {
|
|
52
|
+
if (!Number.isInteger(milestone.membershipRevision) || milestone.membershipRevision < 0) {
|
|
53
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} has invalid membershipRevision.`, {
|
|
54
|
+
membershipRevision: milestone.membershipRevision,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const membershipTaskIds = milestone.memberships.map((membership) => membership.taskId);
|
|
58
|
+
const uniqueMembershipTaskIds = new Set(membershipTaskIds);
|
|
59
|
+
const uniqueTaskIds = new Set(milestone.taskIds);
|
|
60
|
+
if (uniqueMembershipTaskIds.size !== membershipTaskIds.length) {
|
|
61
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} memberships contain duplicate Task IDs.`, {
|
|
62
|
+
taskIds: membershipTaskIds,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (uniqueTaskIds.size !== milestone.taskIds.length) {
|
|
66
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} taskIds contain duplicates.`, {
|
|
67
|
+
taskIds: milestone.taskIds,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (membershipTaskIds.length !== milestone.taskIds.length
|
|
71
|
+
|| membershipTaskIds.some((taskId) => !uniqueTaskIds.has(taskId))
|
|
72
|
+
|| milestone.taskIds.some((taskId) => !uniqueMembershipTaskIds.has(taskId))) {
|
|
73
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} taskIds and memberships diverged.`, {
|
|
74
|
+
taskIds: milestone.taskIds,
|
|
75
|
+
membershipTaskIds,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
for (const membership of milestone.memberships) {
|
|
79
|
+
const task = readTask(membership.taskId);
|
|
80
|
+
if (task.milestoneId !== milestone.id) {
|
|
81
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} is no longer linked to Milestone ${milestone.id}.`, {
|
|
82
|
+
milestoneId: milestone.id,
|
|
83
|
+
taskId: task.id,
|
|
84
|
+
taskMilestoneId: task.milestoneId,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (membership.disposition === 'required' && task.status === 'cancelled' && !options.allowRepairableRequiredCancelled) {
|
|
88
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Required Milestone Task ${task.id} is cancelled.`, {
|
|
89
|
+
milestoneId: milestone.id,
|
|
90
|
+
taskId: task.id,
|
|
91
|
+
taskStatus: task.status,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (membership.disposition === 'cancelled' && task.status !== 'cancelled') {
|
|
95
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Cancelled Milestone membership requires Task ${task.id} to be cancelled.`, {
|
|
96
|
+
milestoneId: milestone.id,
|
|
97
|
+
taskId: task.id,
|
|
98
|
+
disposition: membership.disposition,
|
|
99
|
+
taskStatus: task.status,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (listTasks && !options.allowRepairableReverseOrphans) {
|
|
104
|
+
const reverseOrphans = listTasks()
|
|
105
|
+
.filter((task) => task.milestoneId === milestone.id)
|
|
106
|
+
.filter((task) => task.status !== 'cancelled')
|
|
107
|
+
.filter((task) => !uniqueMembershipTaskIds.has(task.id));
|
|
108
|
+
if (reverseOrphans.length > 0) {
|
|
109
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestone.id} has linked Tasks that are missing from its classified membership.`, {
|
|
110
|
+
diagnosticCode: 'MILESTONE_SCOPE_INCOMPLETE',
|
|
111
|
+
milestoneId: milestone.id,
|
|
112
|
+
orphanTaskIds: reverseOrphans.map((task) => task.id),
|
|
113
|
+
requiredAction: 'Add every linked Task as required, waived, or cancelled through a scope change.',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function assertMilestoneScopeChangeStatusAllowed(milestone) {
|
|
119
|
+
if (!SCOPE_CHANGE_ALLOWED_STATUSES.has(milestone.status)) {
|
|
120
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestone.id} cannot change scope while ${milestone.status}.`, {
|
|
121
|
+
milestoneId: milestone.id,
|
|
122
|
+
status: milestone.status,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function prepareMilestoneScopeChangeCandidate(options) {
|
|
127
|
+
const { milestone, plan, readTask, actor } = options;
|
|
128
|
+
const trimmedActor = actor.trim();
|
|
129
|
+
if (!trimmedActor) {
|
|
130
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Milestone scope change requires an explicit actor.');
|
|
131
|
+
}
|
|
132
|
+
if (trimmedActor.startsWith('agent:') && !options.autonomyContractEventHash) {
|
|
133
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone scope change is human-only and rejects delegated agent actors.', {
|
|
134
|
+
actor: trimmedActor,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (options.autonomyContractEventHash) {
|
|
138
|
+
assertHash64(options.autonomyContractEventHash, 'Milestone autonomy contract event hash');
|
|
139
|
+
}
|
|
140
|
+
const authorizationMode = options.autonomyContractEventHash ? 'milestone-autonomy' : 'human';
|
|
141
|
+
const autonomyContractEventHash = options.autonomyContractEventHash ?? null;
|
|
142
|
+
const normalizedPlan = normalizeMilestonePlan(plan);
|
|
143
|
+
const before = milestoneScopeSnapshotFromState(milestone);
|
|
144
|
+
const after = milestoneScopeSnapshotFromPlan(normalizedPlan);
|
|
145
|
+
if (canonicalJsonStringify(before) === canonicalJsonStringify(after)) {
|
|
146
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone scope change is a no-op.', {
|
|
147
|
+
milestoneId: milestone.id,
|
|
148
|
+
revision: milestone.revision,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const taskIds = normalizedPlan.taskIds;
|
|
152
|
+
if (new Set(taskIds).size !== taskIds.length) {
|
|
153
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Milestone memberships must have unique Task IDs.');
|
|
154
|
+
}
|
|
155
|
+
const repairableCancelledRequired = new Set(milestone.memberships
|
|
156
|
+
.filter((membership) => membership.disposition === 'required')
|
|
157
|
+
.map((membership) => membership.taskId)
|
|
158
|
+
.filter((taskId) => readTask(taskId).status === 'cancelled'));
|
|
159
|
+
for (const membership of normalizedPlan.memberships) {
|
|
160
|
+
const task = readTask(membership.taskId);
|
|
161
|
+
if (task.milestoneId !== milestone.id) {
|
|
162
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} is not linked to Milestone ${milestone.id}.`);
|
|
163
|
+
}
|
|
164
|
+
if (repairableCancelledRequired.has(task.id) && membership.disposition !== 'cancelled') {
|
|
165
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Milestone scope change must repair cancelled required Task ${task.id} by marking its membership cancelled.`, {
|
|
166
|
+
milestoneId: milestone.id,
|
|
167
|
+
taskId: task.id,
|
|
168
|
+
currentDisposition: 'required',
|
|
169
|
+
candidateDisposition: membership.disposition,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const nextIds = new Set(taskIds);
|
|
174
|
+
for (const existing of milestone.memberships) {
|
|
175
|
+
if (nextIds.has(existing.taskId))
|
|
176
|
+
continue;
|
|
177
|
+
const task = readTask(existing.taskId);
|
|
178
|
+
if (taskHasStarted(task)) {
|
|
179
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Started Task ${task.id} cannot be removed from a Milestone.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const authorizations = milestone.authorizations.map((authorization) => authorization.kind === 'execution' && authorization.decision === 'approved'
|
|
183
|
+
? { ...authorization, decision: 'superseded' }
|
|
184
|
+
: authorization);
|
|
185
|
+
const candidatePlanHash = sha256Hex(renderMilestonePlanArtifact(normalizedPlan));
|
|
186
|
+
const candidateMembershipRevision = milestone.membershipRevision + 1;
|
|
187
|
+
const confirmationCodeBindingHash = computeMilestoneScopeChangeBindingHash({
|
|
188
|
+
milestoneId: milestone.id,
|
|
189
|
+
expectedRevision: milestone.revision,
|
|
190
|
+
previousPlanHash: milestone.planHash,
|
|
191
|
+
previousMembershipRevision: milestone.membershipRevision,
|
|
192
|
+
candidatePlanHash,
|
|
193
|
+
candidateMembershipRevision,
|
|
194
|
+
actor: trimmedActor,
|
|
195
|
+
after,
|
|
196
|
+
authorizationMode,
|
|
197
|
+
autonomyContractEventHash,
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
milestoneId: milestone.id,
|
|
201
|
+
expectedRevision: milestone.revision,
|
|
202
|
+
actor: trimmedActor,
|
|
203
|
+
normalizedPlan,
|
|
204
|
+
authorizations,
|
|
205
|
+
candidatePlanHash,
|
|
206
|
+
candidateMembershipRevision,
|
|
207
|
+
after,
|
|
208
|
+
confirmationCodeBindingHash,
|
|
209
|
+
confirmationCode: milestoneScopeChangeConfirmationCode(confirmationCodeBindingHash),
|
|
210
|
+
authorizationMode,
|
|
211
|
+
autonomyContractEventHash,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export function applyMilestoneScopeChangeTransaction(options) {
|
|
215
|
+
const { store, milestone, expectedRevision, prepared, now, readTask } = options;
|
|
216
|
+
const milestoneRoot = store.milestoneRoot(milestone.projectId, milestone.id);
|
|
217
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, milestone.projectId, milestone.id);
|
|
218
|
+
const planText = renderMilestonePlanArtifact(prepared.normalizedPlan);
|
|
219
|
+
const stagedPlan = store.stageArtifact(milestoneRoot, 'plan.json', planText);
|
|
220
|
+
if (stagedPlan.hash !== prepared.candidatePlanHash) {
|
|
221
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone scope change candidate drifted before apply.', {
|
|
222
|
+
expectedPlanHash: prepared.candidatePlanHash,
|
|
223
|
+
actualPlanHash: stagedPlan.hash,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const currentEvents = readMilestoneScopeChangeEvents(store, milestone.projectId, milestone.id);
|
|
227
|
+
const preparedSidecar = prepareSidecarAppend(currentEvents, createMilestoneScopeChangeEvent({
|
|
228
|
+
milestone,
|
|
229
|
+
normalizedPlan: prepared.normalizedPlan,
|
|
230
|
+
actor: prepared.actor,
|
|
231
|
+
now,
|
|
232
|
+
previousEventHash: currentEvents.at(-1)?.eventHash ?? null,
|
|
233
|
+
candidatePlanHash: stagedPlan.hash,
|
|
234
|
+
candidateMembershipRevision: prepared.candidateMembershipRevision,
|
|
235
|
+
confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
|
|
236
|
+
authorizationMode: prepared.authorizationMode,
|
|
237
|
+
autonomyContractEventHash: prepared.autonomyContractEventHash,
|
|
238
|
+
}), { validateEvent: (candidate) => validateMilestoneScopeChangeEvent(candidate, milestone.id) });
|
|
239
|
+
if (preparedSidecar.event.actor !== prepared.actor) {
|
|
240
|
+
throw new WorkflowError('STATE_CORRUPT', 'Prepared Milestone scope-change actor drifted before journal apply.', {
|
|
241
|
+
expectedActor: prepared.actor,
|
|
242
|
+
actualActor: preparedSidecar.event.actor,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (preparedSidecar.event.confirmationCodeBindingHash !== prepared.confirmationCodeBindingHash) {
|
|
246
|
+
throw new WorkflowError('STATE_CORRUPT', 'Prepared Milestone scope-change binding drifted before journal apply.', {
|
|
247
|
+
expectedBindingHash: prepared.confirmationCodeBindingHash,
|
|
248
|
+
actualBindingHash: preparedSidecar.event.confirmationCodeBindingHash,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const stagedSidecar = store.stageArtifact(milestoneRoot, SCOPE_CHANGE_SIDECAR, preparedSidecar.content);
|
|
252
|
+
const candidateState = {
|
|
253
|
+
...milestone,
|
|
254
|
+
revision: milestone.revision + 1,
|
|
255
|
+
updatedAt: now.toISOString(),
|
|
256
|
+
status: 'awaiting_execution_authorization',
|
|
257
|
+
outcome: prepared.normalizedPlan.outcome,
|
|
258
|
+
successSignal: prepared.normalizedPlan.successSignal,
|
|
259
|
+
acceptance: prepared.normalizedPlan.acceptance,
|
|
260
|
+
taskIds: prepared.normalizedPlan.taskIds,
|
|
261
|
+
memberships: prepared.normalizedPlan.memberships,
|
|
262
|
+
checks: prepared.normalizedPlan.checks,
|
|
263
|
+
membershipRevision: prepared.candidateMembershipRevision,
|
|
264
|
+
authorizations: prepared.authorizations,
|
|
265
|
+
planHash: stagedPlan.hash,
|
|
266
|
+
resultHash: null,
|
|
267
|
+
evidenceHash: null,
|
|
268
|
+
acceptedHead: null,
|
|
269
|
+
cancellationReason: null,
|
|
270
|
+
};
|
|
271
|
+
assertMilestoneMembershipIntegrity(candidateState, readTask);
|
|
272
|
+
const stagedState = store.stageArtifact(milestoneRoot, 'state.json', `${JSON.stringify(candidateState, null, 2)}\n`);
|
|
273
|
+
const transactionId = `tx-${createEntityId('MS', now.getTime())}`;
|
|
274
|
+
const journal = {
|
|
275
|
+
transactionId,
|
|
276
|
+
milestoneId: milestone.id,
|
|
277
|
+
expectedMilestoneRevision: milestone.revision,
|
|
278
|
+
actor: prepared.actor,
|
|
279
|
+
confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
|
|
280
|
+
phase: 'prepared',
|
|
281
|
+
targetFiles: [
|
|
282
|
+
{
|
|
283
|
+
path: 'state.json',
|
|
284
|
+
preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, 'state.json'),
|
|
285
|
+
staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedState.stagedFile), { mode: 'inline' }),
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
path: 'plan.json',
|
|
289
|
+
preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, 'plan.json'),
|
|
290
|
+
staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedPlan.stagedFile), { mode: 'inline' }),
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
path: SCOPE_CHANGE_SIDECAR,
|
|
294
|
+
preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, SCOPE_CHANGE_SIDECAR),
|
|
295
|
+
staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedSidecar.stagedFile), { mode: 'inline' }),
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
preparedAt: now.toISOString(),
|
|
299
|
+
updatedAt: now.toISOString(),
|
|
300
|
+
};
|
|
301
|
+
if (journal.actor !== preparedSidecar.event.actor) {
|
|
302
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone transaction journal actor does not match latest staged scope-change event.', {
|
|
303
|
+
journalActor: journal.actor,
|
|
304
|
+
eventActor: preparedSidecar.event.actor,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (journal.confirmationCodeBindingHash !== preparedSidecar.event.confirmationCodeBindingHash) {
|
|
308
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone transaction journal binding does not match latest staged scope-change event.', {
|
|
309
|
+
journalBindingHash: journal.confirmationCodeBindingHash,
|
|
310
|
+
eventBindingHash: preparedSidecar.event.confirmationCodeBindingHash,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
writeMilestoneTransactionJournal(alpha6MilestoneRoot, journal);
|
|
314
|
+
try {
|
|
315
|
+
updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, transactionId, 'applying', now.toISOString(), {
|
|
316
|
+
expectedMilestoneId: milestone.id,
|
|
317
|
+
});
|
|
318
|
+
store.publishArtifact(stagedPlan);
|
|
319
|
+
store.publishArtifact({ ...stagedSidecar, hash: preparedSidecar.contentHash });
|
|
320
|
+
store.publishArtifact(stagedState);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
rollbackMilestoneTransaction(store, milestone.projectId, milestone.id, journal, readTask);
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
return finalizeCompletedMilestoneTransaction(store, milestone.projectId, milestone.id, journal, readTask, {
|
|
327
|
+
expectedRevisionAfterApply: expectedRevision + 1,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
function recoverMilestoneScopeChangeTransactions(store, projectId, milestoneId, readTask) {
|
|
331
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
332
|
+
const transactionDir = path.join(alpha6MilestoneRoot, '.transactions');
|
|
333
|
+
if (!existsSync(transactionDir))
|
|
334
|
+
return;
|
|
335
|
+
const journals = listMilestoneTransactionJournals(alpha6MilestoneRoot, { expectedMilestoneId: milestoneId });
|
|
336
|
+
for (const journal of journals) {
|
|
337
|
+
validateMilestoneTransactionTargets(journal);
|
|
338
|
+
try {
|
|
339
|
+
if (shouldCompleteMilestoneTransaction(store, projectId, milestoneId, journal, readTask)) {
|
|
340
|
+
completeMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
rollbackMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
rollbackMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function assertMilestonePlanAgreement(store, milestone) {
|
|
352
|
+
if (milestone.planHash === null)
|
|
353
|
+
return;
|
|
354
|
+
const milestoneRoot = store.milestoneRoot(milestone.projectId, milestone.id);
|
|
355
|
+
const currentPlanHash = store.hashArtifact(milestoneRoot, 'plan.json');
|
|
356
|
+
if (currentPlanHash !== milestone.planHash) {
|
|
357
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} planHash does not match plan.json.`, {
|
|
358
|
+
expectedPlanHash: milestone.planHash,
|
|
359
|
+
actualPlanHash: currentPlanHash,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
const plan = parseMilestonePlanArtifact(readFileSync(path.join(milestoneRoot, 'plan.json'), 'utf8'), milestone.id);
|
|
363
|
+
const expectedScope = milestoneScopeSnapshotFromState(milestone);
|
|
364
|
+
const actualScope = milestoneScopeSnapshotFromPlan(plan);
|
|
365
|
+
if (canonicalJsonStringify(expectedScope) !== canonicalJsonStringify(actualScope)) {
|
|
366
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} state.json and plan.json diverged.`, {
|
|
367
|
+
milestoneId: milestone.id,
|
|
368
|
+
stateScope: expectedScope,
|
|
369
|
+
planScope: actualScope,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const scopeEvents = readMilestoneScopeChangeEvents(store, milestone.projectId, milestone.id);
|
|
373
|
+
if (scopeEvents.length === 0)
|
|
374
|
+
return;
|
|
375
|
+
const latest = scopeEvents.at(-1);
|
|
376
|
+
if (latest.candidatePlanHash !== milestone.planHash
|
|
377
|
+
|| latest.candidateMembershipRevision !== milestone.membershipRevision
|
|
378
|
+
|| canonicalJsonStringify(latest.after) !== canonicalJsonStringify(expectedScope)) {
|
|
379
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} scope-change sidecar diverged from current state.`, {
|
|
380
|
+
milestoneId: milestone.id,
|
|
381
|
+
latestEventId: latest.eventId,
|
|
382
|
+
latestCandidatePlanHash: latest.candidatePlanHash,
|
|
383
|
+
milestonePlanHash: milestone.planHash,
|
|
384
|
+
latestCandidateMembershipRevision: latest.candidateMembershipRevision,
|
|
385
|
+
milestoneMembershipRevision: milestone.membershipRevision,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function readMilestoneScopeChangeEvents(store, projectId, milestoneId) {
|
|
390
|
+
const sidecarPath = path.join(resolveAlpha6MilestoneRoot(store, projectId, milestoneId), SCOPE_CHANGE_SIDECAR);
|
|
391
|
+
assertNoSymlinkPathComponents(sidecarPath, { label: 'Milestone scope-change sidecar path', requireLeaf: true });
|
|
392
|
+
if (!existsSync(sidecarPath))
|
|
393
|
+
return [];
|
|
394
|
+
return parseMilestoneScopeChangeEvents(readFileSync(sidecarPath, 'utf8'), milestoneId);
|
|
395
|
+
}
|
|
396
|
+
function validateMilestoneScopeChangeEvent(event, expectedMilestoneId) {
|
|
397
|
+
if (event.milestoneId !== expectedMilestoneId) {
|
|
398
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change sidecar milestone mismatch.', {
|
|
399
|
+
expectedMilestoneId,
|
|
400
|
+
actualMilestoneId: event.milestoneId,
|
|
401
|
+
eventId: event.eventId,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (!Number.isInteger(event.previousMilestoneRevision) || event.previousMilestoneRevision < 1) {
|
|
405
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change previousMilestoneRevision is invalid.', {
|
|
406
|
+
eventId: event.eventId,
|
|
407
|
+
previousMilestoneRevision: event.previousMilestoneRevision,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (!Number.isInteger(event.previousMembershipRevision) || event.previousMembershipRevision < 0) {
|
|
411
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change previousMembershipRevision is invalid.', {
|
|
412
|
+
eventId: event.eventId,
|
|
413
|
+
previousMembershipRevision: event.previousMembershipRevision,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (!Number.isInteger(event.candidateMembershipRevision) || event.candidateMembershipRevision < 1) {
|
|
417
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change candidateMembershipRevision is invalid.', {
|
|
418
|
+
eventId: event.eventId,
|
|
419
|
+
candidateMembershipRevision: event.candidateMembershipRevision,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (event.candidateMembershipRevision !== event.previousMembershipRevision + 1) {
|
|
423
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change membershipRevision must advance by exactly one.', {
|
|
424
|
+
eventId: event.eventId,
|
|
425
|
+
previousMembershipRevision: event.previousMembershipRevision,
|
|
426
|
+
candidateMembershipRevision: event.candidateMembershipRevision,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (!event.actor.trim()) {
|
|
430
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change actor is required.', { eventId: event.eventId });
|
|
431
|
+
}
|
|
432
|
+
if (event.actor.startsWith('agent:') && event.authorizationMode !== 'milestone-autonomy') {
|
|
433
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change actor must be human-only.', {
|
|
434
|
+
eventId: event.eventId,
|
|
435
|
+
actor: event.actor,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (event.authorizationMode === 'milestone-autonomy') {
|
|
439
|
+
if (!event.actor.startsWith('agent:') || !event.autonomyContractEventHash) {
|
|
440
|
+
throw new WorkflowError('STATE_CORRUPT', 'Autonomous Milestone scope change requires an agent actor and contract evidence.', {
|
|
441
|
+
eventId: event.eventId,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
assertHash64(event.autonomyContractEventHash, 'Milestone scope-change autonomyContractEventHash');
|
|
445
|
+
}
|
|
446
|
+
else if (event.autonomyContractEventHash !== undefined && event.autonomyContractEventHash !== null) {
|
|
447
|
+
throw new WorkflowError('STATE_CORRUPT', 'Human Milestone scope change cannot carry autonomy contract evidence.');
|
|
448
|
+
}
|
|
449
|
+
if (!event.confirmationCodeBindingHash.trim()) {
|
|
450
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change confirmationCodeBindingHash is required.', {
|
|
451
|
+
eventId: event.eventId,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
assertHash64(event.confirmationCodeBindingHash, 'Milestone scope-change confirmationCodeBindingHash');
|
|
455
|
+
const expectedBindingHash = computeMilestoneScopeChangeBindingHash({
|
|
456
|
+
milestoneId: event.milestoneId,
|
|
457
|
+
expectedRevision: event.previousMilestoneRevision,
|
|
458
|
+
previousPlanHash: event.previousPlanHash,
|
|
459
|
+
previousMembershipRevision: event.previousMembershipRevision,
|
|
460
|
+
candidatePlanHash: event.candidatePlanHash,
|
|
461
|
+
candidateMembershipRevision: event.candidateMembershipRevision,
|
|
462
|
+
actor: event.actor,
|
|
463
|
+
after: event.after,
|
|
464
|
+
...(event.authorizationMode === undefined ? {} : { authorizationMode: event.authorizationMode }),
|
|
465
|
+
...(event.autonomyContractEventHash === undefined
|
|
466
|
+
? {}
|
|
467
|
+
: { autonomyContractEventHash: event.autonomyContractEventHash }),
|
|
468
|
+
});
|
|
469
|
+
if (event.confirmationCodeBindingHash !== expectedBindingHash) {
|
|
470
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change confirmationCodeBindingHash is inconsistent.', {
|
|
471
|
+
eventId: event.eventId,
|
|
472
|
+
expectedBindingHash,
|
|
473
|
+
actualBindingHash: event.confirmationCodeBindingHash,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function validateMilestoneTransactionTargets(journal) {
|
|
478
|
+
const paths = journal.targetFiles.map((target) => target.path);
|
|
479
|
+
const unique = new Set(paths);
|
|
480
|
+
if (unique.size !== MILESTONE_TARGETS.length || paths.length !== MILESTONE_TARGETS.length) {
|
|
481
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} target set is invalid.`, {
|
|
482
|
+
paths,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
for (const target of MILESTONE_TARGETS) {
|
|
486
|
+
if (!unique.has(target)) {
|
|
487
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} is missing ${target}.`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function shouldCompleteMilestoneTransaction(store, projectId, milestoneId, journal, readTask) {
|
|
492
|
+
const staged = readStagedMilestoneTransactionBundle(store, projectId, milestoneId, journal);
|
|
493
|
+
const currentStates = journal.targetFiles.map((target) => {
|
|
494
|
+
const current = captureJournalTargetSnapshot(resolveAlpha6MilestoneRoot(store, projectId, milestoneId), target.path);
|
|
495
|
+
const matchesPreimage = canonicalJsonStringify(current) === canonicalJsonStringify(target.preimage);
|
|
496
|
+
const matchesStaged = canonicalJsonStringify(current) === canonicalJsonStringify(target.staged);
|
|
497
|
+
return { target: target.path, matchesPreimage, matchesStaged };
|
|
498
|
+
});
|
|
499
|
+
if (currentStates.some((state) => !state.matchesPreimage && !state.matchesStaged))
|
|
500
|
+
return false;
|
|
501
|
+
if (staged.state.id !== journal.milestoneId || staged.state.revision !== journal.expectedMilestoneRevision + 1) {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
assertMilestoneMembershipIntegrity(staged.state, readTask);
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
function completeMilestoneTransaction(store, projectId, milestoneId, journal, readTask) {
|
|
508
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
509
|
+
try {
|
|
510
|
+
updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, journal.transactionId, 'applying', new Date().toISOString(), {
|
|
511
|
+
expectedMilestoneId: milestoneId,
|
|
512
|
+
});
|
|
513
|
+
for (const target of journal.targetFiles) {
|
|
514
|
+
const content = readJournalSnapshotContent(alpha6MilestoneRoot, target.staged, {
|
|
515
|
+
expectedMilestoneId: milestoneId,
|
|
516
|
+
transactionId: journal.transactionId,
|
|
517
|
+
});
|
|
518
|
+
if (content === null) {
|
|
519
|
+
rmSync(resolveMilestoneTransactionTargetPath(alpha6MilestoneRoot, target.path), { force: true });
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const staged = store.stageArtifact(store.milestoneRoot(projectId, milestoneId), target.path, content.toString('utf8'));
|
|
523
|
+
store.publishArtifact(staged);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
rollbackMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
finalizeCompletedMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
531
|
+
}
|
|
532
|
+
function rollbackMilestoneTransaction(store, projectId, milestoneId, journal, readTask) {
|
|
533
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
534
|
+
for (const target of journal.targetFiles) {
|
|
535
|
+
const content = readJournalSnapshotContent(alpha6MilestoneRoot, target.preimage, {
|
|
536
|
+
expectedMilestoneId: milestoneId,
|
|
537
|
+
transactionId: journal.transactionId,
|
|
538
|
+
});
|
|
539
|
+
if (content === null) {
|
|
540
|
+
rmSync(resolveMilestoneTransactionTargetPath(alpha6MilestoneRoot, target.path), { force: true });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const staged = store.stageArtifact(store.milestoneRoot(projectId, milestoneId), target.path, content.toString('utf8'));
|
|
544
|
+
store.publishArtifact(staged);
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const restored = store.readMilestone(projectId, milestoneId);
|
|
548
|
+
if (restored.revision !== journal.expectedMilestoneRevision) {
|
|
549
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} rollback restored an unexpected revision.`, {
|
|
550
|
+
expectedRevision: journal.expectedMilestoneRevision,
|
|
551
|
+
actualRevision: restored.revision,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
assertMilestonePlanAgreement(store, restored);
|
|
555
|
+
assertMilestoneMembershipIntegrity(restored, readTask, { allowRepairableRequiredCancelled: true });
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} rollback validation failed.`, {
|
|
559
|
+
transactionId: journal.transactionId,
|
|
560
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, journal.transactionId, 'rolled_back', new Date().toISOString(), {
|
|
564
|
+
expectedMilestoneId: milestoneId,
|
|
565
|
+
});
|
|
566
|
+
removeMilestoneTransactionJournal(alpha6MilestoneRoot, journal.transactionId);
|
|
567
|
+
}
|
|
568
|
+
function finalizeCompletedMilestoneTransaction(store, projectId, milestoneId, journal, readTask, options = {}) {
|
|
569
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
570
|
+
let saved;
|
|
571
|
+
try {
|
|
572
|
+
saved = store.readMilestone(projectId, milestoneId);
|
|
573
|
+
if (options.expectedRevisionAfterApply !== undefined && saved.revision !== options.expectedRevisionAfterApply) {
|
|
574
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope change wrote an unexpected revision.`, {
|
|
575
|
+
expectedRevision: options.expectedRevisionAfterApply,
|
|
576
|
+
actualRevision: saved.revision,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
assertMilestonePlanAgreement(store, saved);
|
|
580
|
+
assertMilestoneMembershipIntegrity(saved, readTask);
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
rollbackMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, journal.transactionId, 'applied', new Date().toISOString(), {
|
|
587
|
+
expectedMilestoneId: milestoneId,
|
|
588
|
+
});
|
|
589
|
+
removeMilestoneTransactionJournal(alpha6MilestoneRoot, journal.transactionId);
|
|
590
|
+
return saved;
|
|
591
|
+
}
|
|
592
|
+
function readStagedMilestoneTransactionBundle(store, projectId, milestoneId, journal) {
|
|
593
|
+
const stateSnapshot = journal.targetFiles.find((target) => target.path === 'state.json')?.staged;
|
|
594
|
+
const planSnapshot = journal.targetFiles.find((target) => target.path === 'plan.json')?.staged;
|
|
595
|
+
const sidecarSnapshot = journal.targetFiles.find((target) => target.path === SCOPE_CHANGE_SIDECAR)?.staged;
|
|
596
|
+
if (!stateSnapshot || !planSnapshot || !sidecarSnapshot) {
|
|
597
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} is missing a staged bundle.`);
|
|
598
|
+
}
|
|
599
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
600
|
+
const stateBuffer = readJournalSnapshotContent(alpha6MilestoneRoot, stateSnapshot, {
|
|
601
|
+
expectedMilestoneId: milestoneId,
|
|
602
|
+
transactionId: journal.transactionId,
|
|
603
|
+
});
|
|
604
|
+
const planBuffer = readJournalSnapshotContent(alpha6MilestoneRoot, planSnapshot, {
|
|
605
|
+
expectedMilestoneId: milestoneId,
|
|
606
|
+
transactionId: journal.transactionId,
|
|
607
|
+
});
|
|
608
|
+
const sidecarBuffer = readJournalSnapshotContent(alpha6MilestoneRoot, sidecarSnapshot, {
|
|
609
|
+
expectedMilestoneId: milestoneId,
|
|
610
|
+
transactionId: journal.transactionId,
|
|
611
|
+
});
|
|
612
|
+
if (!stateBuffer || !planBuffer || !sidecarBuffer) {
|
|
613
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} has an incomplete staged bundle.`);
|
|
614
|
+
}
|
|
615
|
+
const state = parseStagedMilestoneStateArtifact(stateBuffer.toString('utf8'), projectId, journal.milestoneId, journal.transactionId);
|
|
616
|
+
const plan = parseMilestonePlanArtifact(planBuffer.toString('utf8'), journal.milestoneId);
|
|
617
|
+
const events = parseMilestoneScopeChangeEvents(sidecarBuffer.toString('utf8'), journal.milestoneId);
|
|
618
|
+
const latestEvent = events.at(-1);
|
|
619
|
+
if (!latestEvent) {
|
|
620
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} has no staged scope-change event.`);
|
|
621
|
+
}
|
|
622
|
+
if (latestEvent.candidatePlanHash !== state.planHash
|
|
623
|
+
|| latestEvent.candidateMembershipRevision !== state.membershipRevision
|
|
624
|
+
|| canonicalJsonStringify(latestEvent.after) !== canonicalJsonStringify(milestoneScopeSnapshotFromState(state))
|
|
625
|
+
|| canonicalJsonStringify(milestoneScopeSnapshotFromPlan(plan)) !== canonicalJsonStringify(milestoneScopeSnapshotFromState(state))
|
|
626
|
+
|| latestEvent.actor !== journal.actor
|
|
627
|
+
|| latestEvent.confirmationCodeBindingHash !== journal.confirmationCodeBindingHash) {
|
|
628
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} staged bundle is inconsistent.`);
|
|
629
|
+
}
|
|
630
|
+
if (state.revision !== journal.expectedMilestoneRevision + 1) {
|
|
631
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} staged state revision is inconsistent.`, {
|
|
632
|
+
expectedRevision: journal.expectedMilestoneRevision + 1,
|
|
633
|
+
actualRevision: state.revision,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
if (latestEvent.previousMilestoneRevision !== journal.expectedMilestoneRevision) {
|
|
637
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} latest event revision does not match journal precondition.`, {
|
|
638
|
+
expectedPreviousMilestoneRevision: journal.expectedMilestoneRevision,
|
|
639
|
+
actualPreviousMilestoneRevision: latestEvent.previousMilestoneRevision,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return { state, plan, events };
|
|
643
|
+
}
|
|
644
|
+
function parseMilestonePlanArtifact(content, milestoneId) {
|
|
645
|
+
let parsed;
|
|
646
|
+
try {
|
|
647
|
+
parsed = JSON.parse(content);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json is not valid JSON.`, {
|
|
651
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
655
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json must be an object.`);
|
|
656
|
+
}
|
|
657
|
+
const record = parsed;
|
|
658
|
+
const allowedKeys = new Set(['outcome', 'successSignal', 'acceptance', 'memberships', 'checks']);
|
|
659
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
660
|
+
if (extraKeys.length > 0) {
|
|
661
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json contains unexpected fields.`, {
|
|
662
|
+
extraKeys,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
if (typeof record.outcome !== 'string' || !record.outcome.trim()) {
|
|
666
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json outcome must be a non-empty string.`);
|
|
667
|
+
}
|
|
668
|
+
if (typeof record.successSignal !== 'string' || !record.successSignal.trim()) {
|
|
669
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json successSignal must be a non-empty string.`);
|
|
670
|
+
}
|
|
671
|
+
const acceptance = parseStringArray(record.acceptance, `Milestone ${milestoneId} plan.json acceptance`);
|
|
672
|
+
const checks = parseStringArray(record.checks, `Milestone ${milestoneId} plan.json checks`);
|
|
673
|
+
const memberships = parseMilestoneMembershipArray(record.memberships, milestoneId);
|
|
674
|
+
return normalizeMilestonePlan({
|
|
675
|
+
outcome: record.outcome,
|
|
676
|
+
successSignal: record.successSignal,
|
|
677
|
+
acceptance,
|
|
678
|
+
memberships,
|
|
679
|
+
checks,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
function parseMilestoneScopeChangeEvents(content, milestoneId) {
|
|
683
|
+
if (content.length === 0) {
|
|
684
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar must not be empty.`);
|
|
685
|
+
}
|
|
686
|
+
const rawLines = content.split('\n');
|
|
687
|
+
if (rawLines.at(-1) === '')
|
|
688
|
+
rawLines.pop();
|
|
689
|
+
if (rawLines.some((line) => line.length === 0)) {
|
|
690
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar contains a blank line.`);
|
|
691
|
+
}
|
|
692
|
+
const events = [];
|
|
693
|
+
const eventIds = new Set();
|
|
694
|
+
let previousEventHash = null;
|
|
695
|
+
for (const [index, rawLine] of rawLines.entries()) {
|
|
696
|
+
let parsed;
|
|
697
|
+
try {
|
|
698
|
+
parsed = JSON.parse(rawLine);
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar has invalid JSONL.`, {
|
|
702
|
+
line: index + 1,
|
|
703
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
const event = parseMilestoneScopeChangeEventRecord(parsed, milestoneId, index + 1);
|
|
707
|
+
validateMilestoneScopeChangeEvent(event, milestoneId);
|
|
708
|
+
if (eventIds.has(event.eventId)) {
|
|
709
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar contains duplicate event id.`, {
|
|
710
|
+
line: index + 1,
|
|
711
|
+
eventId: event.eventId,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
eventIds.add(event.eventId);
|
|
715
|
+
if (event.previousEventHash !== previousEventHash) {
|
|
716
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar chain is broken.`, {
|
|
717
|
+
line: index + 1,
|
|
718
|
+
expectedPreviousEventHash: previousEventHash,
|
|
719
|
+
actualPreviousEventHash: event.previousEventHash,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
const expectedEventHash = computeSidecarEventHash(event);
|
|
723
|
+
if (event.eventHash !== expectedEventHash) {
|
|
724
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change eventHash mismatch.`, {
|
|
725
|
+
line: index + 1,
|
|
726
|
+
expectedEventHash,
|
|
727
|
+
actualEventHash: event.eventHash,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
previousEventHash = event.eventHash;
|
|
731
|
+
const previous = events.at(-1);
|
|
732
|
+
if (previous) {
|
|
733
|
+
if (event.previousMilestoneRevision <= previous.previousMilestoneRevision
|
|
734
|
+
|| event.previousPlanHash !== previous.candidatePlanHash
|
|
735
|
+
|| event.previousMembershipRevision !== previous.candidateMembershipRevision
|
|
736
|
+
|| canonicalJsonStringify(event.before) !== canonicalJsonStringify(previous.after)) {
|
|
737
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change semantic continuity is broken.`, {
|
|
738
|
+
line: index + 1,
|
|
739
|
+
previousEventId: previous.eventId,
|
|
740
|
+
eventId: event.eventId,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
events.push(event);
|
|
745
|
+
}
|
|
746
|
+
return events;
|
|
747
|
+
}
|
|
748
|
+
function createMilestoneScopeChangeEvent(options) {
|
|
749
|
+
const { milestone, normalizedPlan, actor, now, previousEventHash, candidatePlanHash, candidateMembershipRevision, confirmationCodeBindingHash, authorizationMode, autonomyContractEventHash, } = options;
|
|
750
|
+
return {
|
|
751
|
+
eventId: createEntityId('MS', now.getTime()),
|
|
752
|
+
previousEventHash,
|
|
753
|
+
recordedAt: now.toISOString(),
|
|
754
|
+
milestoneId: milestone.id,
|
|
755
|
+
previousMilestoneRevision: milestone.revision,
|
|
756
|
+
previousPlanHash: milestone.planHash,
|
|
757
|
+
previousMembershipRevision: milestone.membershipRevision,
|
|
758
|
+
candidatePlanHash,
|
|
759
|
+
candidateMembershipRevision,
|
|
760
|
+
before: milestoneScopeSnapshotFromState(milestone),
|
|
761
|
+
after: milestoneScopeSnapshotFromPlan(normalizedPlan),
|
|
762
|
+
actor: actor.trim(),
|
|
763
|
+
confirmationCodeBindingHash,
|
|
764
|
+
...(authorizationMode === 'milestone-autonomy'
|
|
765
|
+
? { authorizationMode, autonomyContractEventHash }
|
|
766
|
+
: {}),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function milestoneScopeSnapshotFromState(milestone) {
|
|
770
|
+
return {
|
|
771
|
+
outcome: milestone.outcome,
|
|
772
|
+
successSignal: milestone.successSignal,
|
|
773
|
+
acceptance: milestone.acceptance,
|
|
774
|
+
checks: milestone.checks,
|
|
775
|
+
taskIds: milestone.taskIds,
|
|
776
|
+
memberships: milestone.memberships,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
function parseMilestoneScopeChangeEventRecord(parsed, milestoneId, line) {
|
|
780
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
781
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change event must be an object.`, { line });
|
|
782
|
+
}
|
|
783
|
+
const record = parsed;
|
|
784
|
+
const allowedKeys = new Set([
|
|
785
|
+
'eventId',
|
|
786
|
+
'recordedAt',
|
|
787
|
+
'previousEventHash',
|
|
788
|
+
'milestoneId',
|
|
789
|
+
'previousMilestoneRevision',
|
|
790
|
+
'previousPlanHash',
|
|
791
|
+
'previousMembershipRevision',
|
|
792
|
+
'candidatePlanHash',
|
|
793
|
+
'candidateMembershipRevision',
|
|
794
|
+
'before',
|
|
795
|
+
'after',
|
|
796
|
+
'actor',
|
|
797
|
+
'confirmationCodeBindingHash',
|
|
798
|
+
'authorizationMode',
|
|
799
|
+
'autonomyContractEventHash',
|
|
800
|
+
'eventHash',
|
|
801
|
+
]);
|
|
802
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
803
|
+
if (extraKeys.length > 0) {
|
|
804
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change event contains unexpected fields.`, {
|
|
805
|
+
line,
|
|
806
|
+
extraKeys,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
const eventId = requireNonEmptyString(record.eventId, `Milestone ${milestoneId} scope-change eventId`, { line });
|
|
810
|
+
const recordedAt = requireIsoTimestamp(record.recordedAt, `Milestone ${milestoneId} scope-change recordedAt`, { line });
|
|
811
|
+
const previousEventHash = requireNullableHash64(record.previousEventHash, `Milestone ${milestoneId} scope-change previousEventHash`, { line });
|
|
812
|
+
const actualMilestoneId = requireNonEmptyString(record.milestoneId, `Milestone ${milestoneId} scope-change milestoneId`, { line });
|
|
813
|
+
const previousMilestoneRevision = requireInteger(record.previousMilestoneRevision, `Milestone ${milestoneId} scope-change previousMilestoneRevision`, { line });
|
|
814
|
+
const previousPlanHash = requireNullableHash64(record.previousPlanHash, `Milestone ${milestoneId} scope-change previousPlanHash`, { line });
|
|
815
|
+
const previousMembershipRevision = requireInteger(record.previousMembershipRevision, `Milestone ${milestoneId} scope-change previousMembershipRevision`, { line });
|
|
816
|
+
const candidatePlanHash = requireHash64(record.candidatePlanHash, `Milestone ${milestoneId} scope-change candidatePlanHash`, { line });
|
|
817
|
+
const candidateMembershipRevision = requireInteger(record.candidateMembershipRevision, `Milestone ${milestoneId} scope-change candidateMembershipRevision`, { line });
|
|
818
|
+
const before = parseMilestoneScopeSnapshot(record.before, milestoneId, line, 'before');
|
|
819
|
+
const after = parseMilestoneScopeSnapshot(record.after, milestoneId, line, 'after');
|
|
820
|
+
const actor = requireNonEmptyString(record.actor, `Milestone ${milestoneId} scope-change actor`, { line });
|
|
821
|
+
const confirmationCodeBindingHash = requireHash64(record.confirmationCodeBindingHash, `Milestone ${milestoneId} scope-change confirmationCodeBindingHash`, { line });
|
|
822
|
+
const authorizationMode = record.authorizationMode === undefined
|
|
823
|
+
? undefined
|
|
824
|
+
: requireMilestoneScopeChangeAuthorizationMode(record.authorizationMode, milestoneId, line);
|
|
825
|
+
const autonomyContractEventHash = record.autonomyContractEventHash === undefined
|
|
826
|
+
? undefined
|
|
827
|
+
: requireNullableHash64(record.autonomyContractEventHash, `Milestone ${milestoneId} scope-change autonomyContractEventHash`, { line });
|
|
828
|
+
const eventHash = requireHash64(record.eventHash, `Milestone ${milestoneId} scope-change eventHash`, { line });
|
|
829
|
+
return {
|
|
830
|
+
eventId,
|
|
831
|
+
recordedAt,
|
|
832
|
+
previousEventHash,
|
|
833
|
+
milestoneId: actualMilestoneId,
|
|
834
|
+
previousMilestoneRevision,
|
|
835
|
+
previousPlanHash,
|
|
836
|
+
previousMembershipRevision,
|
|
837
|
+
candidatePlanHash,
|
|
838
|
+
candidateMembershipRevision,
|
|
839
|
+
before,
|
|
840
|
+
after,
|
|
841
|
+
actor,
|
|
842
|
+
confirmationCodeBindingHash,
|
|
843
|
+
...(authorizationMode === undefined ? {} : { authorizationMode }),
|
|
844
|
+
...(autonomyContractEventHash === undefined ? {} : { autonomyContractEventHash }),
|
|
845
|
+
eventHash,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function requireMilestoneScopeChangeAuthorizationMode(value, milestoneId, line) {
|
|
849
|
+
if (value !== 'human' && value !== 'milestone-autonomy') {
|
|
850
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change authorizationMode is invalid.`, {
|
|
851
|
+
line,
|
|
852
|
+
value,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
return value;
|
|
856
|
+
}
|
|
857
|
+
function parseMilestoneScopeSnapshot(value, milestoneId, line, label) {
|
|
858
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
859
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change ${label} snapshot must be an object.`, { line });
|
|
860
|
+
}
|
|
861
|
+
const record = value;
|
|
862
|
+
const allowedKeys = new Set(['outcome', 'successSignal', 'acceptance', 'checks', 'taskIds', 'memberships']);
|
|
863
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
864
|
+
if (extraKeys.length > 0) {
|
|
865
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change ${label} snapshot contains unexpected fields.`, {
|
|
866
|
+
line,
|
|
867
|
+
extraKeys,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
const snapshot = {
|
|
871
|
+
outcome: requireNonEmptyString(record.outcome, `Milestone ${milestoneId} scope-change ${label}.outcome`, { line }),
|
|
872
|
+
successSignal: requireNonEmptyString(record.successSignal, `Milestone ${milestoneId} scope-change ${label}.successSignal`, { line }),
|
|
873
|
+
acceptance: parseStringArray(record.acceptance, `Milestone ${milestoneId} scope-change ${label}.acceptance`, { line }),
|
|
874
|
+
checks: parseStringArray(record.checks, `Milestone ${milestoneId} scope-change ${label}.checks`, { line }),
|
|
875
|
+
taskIds: parseTaskIdArray(record.taskIds, `Milestone ${milestoneId} scope-change ${label}.taskIds`, { line }),
|
|
876
|
+
memberships: parseMilestoneMembershipArray(record.memberships, milestoneId, { line, label }),
|
|
877
|
+
};
|
|
878
|
+
assertSnapshotTaskIdsMatchMemberships(snapshot, milestoneId, { line, label });
|
|
879
|
+
return snapshot;
|
|
880
|
+
}
|
|
881
|
+
function parseMilestoneMembershipArray(value, milestoneId, options = {}) {
|
|
882
|
+
const label = options.label ? `${options.label}.memberships` : 'memberships';
|
|
883
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
884
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} ${label} must be a non-empty array.`, options.line !== undefined ? { line: options.line } : {});
|
|
885
|
+
}
|
|
886
|
+
const memberships = value.map((entry, index) => parseMilestoneMembership(entry, milestoneId, index, options.line));
|
|
887
|
+
const taskIds = memberships.map((membership) => membership.taskId);
|
|
888
|
+
if (new Set(taskIds).size !== taskIds.length) {
|
|
889
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} ${label} contains duplicate taskIds.`, options.line !== undefined ? { line: options.line, taskIds } : { taskIds });
|
|
890
|
+
}
|
|
891
|
+
return memberships;
|
|
892
|
+
}
|
|
893
|
+
function parseMilestoneMembership(value, milestoneId, index, line) {
|
|
894
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
895
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership at index ${index} must be an object.`, line !== undefined ? { line, index } : { index });
|
|
896
|
+
}
|
|
897
|
+
const record = value;
|
|
898
|
+
const allowedKeys = new Set(['taskId', 'disposition', 'reason']);
|
|
899
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
900
|
+
if (extraKeys.length > 0) {
|
|
901
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership contains unexpected fields.`, line !== undefined ? { line, index, extraKeys } : { index, extraKeys });
|
|
902
|
+
}
|
|
903
|
+
const taskId = requireNonEmptyString(record.taskId, `Milestone ${milestoneId} membership.taskId`, line !== undefined ? { line, index } : { index });
|
|
904
|
+
const dispositionValue = requireNonEmptyString(record.disposition, `Milestone ${milestoneId} membership.disposition`, line !== undefined ? { line, index } : { index });
|
|
905
|
+
if (!['required', 'waived', 'cancelled'].includes(dispositionValue)) {
|
|
906
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership disposition is invalid.`, line !== undefined ? { line, index, disposition: dispositionValue } : { index, disposition: dispositionValue });
|
|
907
|
+
}
|
|
908
|
+
const disposition = dispositionValue;
|
|
909
|
+
const reason = typeof record.reason === 'string' ? record.reason.trim() : '';
|
|
910
|
+
if (disposition !== 'required' && reason.length === 0) {
|
|
911
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership ${disposition} requires a reason.`, line !== undefined ? { line, index } : { index });
|
|
912
|
+
}
|
|
913
|
+
return { taskId, disposition, reason };
|
|
914
|
+
}
|
|
915
|
+
function parseStringArray(value, label, details = {}) {
|
|
916
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
917
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be a non-empty array.`, details);
|
|
918
|
+
}
|
|
919
|
+
return value.map((entry, index) => {
|
|
920
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
921
|
+
throw new WorkflowError('STATE_CORRUPT', `${label}[${index}] must be a non-empty string.`, { ...details, index });
|
|
922
|
+
}
|
|
923
|
+
return entry;
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
function parseTaskIdArray(value, label, details = {}) {
|
|
927
|
+
const taskIds = parseStringArray(value, label, details);
|
|
928
|
+
if (new Set(taskIds).size !== taskIds.length) {
|
|
929
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} contains duplicate taskIds.`, { ...details, taskIds });
|
|
930
|
+
}
|
|
931
|
+
return taskIds;
|
|
932
|
+
}
|
|
933
|
+
function requireNonEmptyString(value, label, details = {}) {
|
|
934
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
935
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be a non-empty string.`, details);
|
|
936
|
+
}
|
|
937
|
+
return value;
|
|
938
|
+
}
|
|
939
|
+
function requireInteger(value, label, details = {}) {
|
|
940
|
+
if (!Number.isInteger(value)) {
|
|
941
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be an integer.`, details);
|
|
942
|
+
}
|
|
943
|
+
return value;
|
|
944
|
+
}
|
|
945
|
+
function requireHash64(value, label, details = {}) {
|
|
946
|
+
if (typeof value !== 'string') {
|
|
947
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be a sha256 hex digest.`, details);
|
|
948
|
+
}
|
|
949
|
+
assertHash64(value, label);
|
|
950
|
+
return value;
|
|
951
|
+
}
|
|
952
|
+
function requireNullableHash64(value, label, details = {}) {
|
|
953
|
+
if (value === null)
|
|
954
|
+
return null;
|
|
955
|
+
return requireHash64(value, label, details);
|
|
956
|
+
}
|
|
957
|
+
function requireIsoTimestamp(value, label, details = {}) {
|
|
958
|
+
if (typeof value !== 'string' || !value.trim() || Number.isNaN(Date.parse(value))) {
|
|
959
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be an ISO timestamp string.`, details);
|
|
960
|
+
}
|
|
961
|
+
return value;
|
|
962
|
+
}
|
|
963
|
+
function milestoneScopeSnapshotFromPlan(plan) {
|
|
964
|
+
return {
|
|
965
|
+
outcome: plan.outcome,
|
|
966
|
+
successSignal: plan.successSignal,
|
|
967
|
+
acceptance: plan.acceptance,
|
|
968
|
+
checks: plan.checks,
|
|
969
|
+
taskIds: plan.taskIds,
|
|
970
|
+
memberships: plan.memberships,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
function renderMilestonePlanArtifact(plan) {
|
|
974
|
+
return `${JSON.stringify({
|
|
975
|
+
outcome: plan.outcome,
|
|
976
|
+
successSignal: plan.successSignal,
|
|
977
|
+
acceptance: plan.acceptance,
|
|
978
|
+
memberships: plan.memberships,
|
|
979
|
+
checks: plan.checks,
|
|
980
|
+
}, null, 2)}\n`;
|
|
981
|
+
}
|
|
982
|
+
function milestoneScopeChangeConfirmationCode(bindingHash) {
|
|
983
|
+
return `MSC-${bindingHash.slice(0, 24).toUpperCase()}`;
|
|
984
|
+
}
|
|
985
|
+
function computeMilestoneScopeChangeBindingHash(input) {
|
|
986
|
+
return sha256Hex(canonicalJsonStringify({
|
|
987
|
+
kind: 'milestone-scope-change-v1',
|
|
988
|
+
milestoneId: input.milestoneId,
|
|
989
|
+
expectedRevision: input.expectedRevision,
|
|
990
|
+
previousPlanHash: input.previousPlanHash,
|
|
991
|
+
previousMembershipRevision: input.previousMembershipRevision,
|
|
992
|
+
candidatePlanHash: input.candidatePlanHash,
|
|
993
|
+
candidateMembershipRevision: input.candidateMembershipRevision,
|
|
994
|
+
actor: input.actor,
|
|
995
|
+
after: input.after,
|
|
996
|
+
...(input.authorizationMode === 'milestone-autonomy'
|
|
997
|
+
? {
|
|
998
|
+
authorizationMode: input.authorizationMode,
|
|
999
|
+
autonomyContractEventHash: input.autonomyContractEventHash,
|
|
1000
|
+
}
|
|
1001
|
+
: {}),
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
function assertSnapshotTaskIdsMatchMemberships(snapshot, milestoneId, details = {}) {
|
|
1005
|
+
const membershipTaskIds = snapshot.memberships.map((membership) => membership.taskId);
|
|
1006
|
+
const taskIds = [...snapshot.taskIds];
|
|
1007
|
+
if (membershipTaskIds.length !== taskIds.length
|
|
1008
|
+
|| membershipTaskIds.some((taskId) => !taskIds.includes(taskId))
|
|
1009
|
+
|| taskIds.some((taskId) => !membershipTaskIds.includes(taskId))) {
|
|
1010
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope snapshot taskIds and memberships diverged.`, {
|
|
1011
|
+
...details,
|
|
1012
|
+
taskIds,
|
|
1013
|
+
membershipTaskIds,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function parseStagedMilestoneStateArtifact(content, expectedProjectId, milestoneId, transactionId) {
|
|
1018
|
+
let parsed;
|
|
1019
|
+
try {
|
|
1020
|
+
parsed = JSON.parse(content);
|
|
1021
|
+
}
|
|
1022
|
+
catch (error) {
|
|
1023
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state.json is not valid JSON.`, {
|
|
1024
|
+
milestoneId,
|
|
1025
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1029
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state.json must be an object.`, {
|
|
1030
|
+
milestoneId,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
const record = parsed;
|
|
1034
|
+
if (record.kind !== 'milestone') {
|
|
1035
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state kind is invalid.`, {
|
|
1036
|
+
milestoneId,
|
|
1037
|
+
kind: record.kind,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
if (record.id !== milestoneId || record.projectId !== expectedProjectId) {
|
|
1041
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state identity is invalid.`, {
|
|
1042
|
+
milestoneId,
|
|
1043
|
+
expectedProjectId,
|
|
1044
|
+
id: record.id,
|
|
1045
|
+
projectId: record.projectId,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
if (!Number.isInteger(record.revision)) {
|
|
1049
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state revision is invalid.`, {
|
|
1050
|
+
milestoneId,
|
|
1051
|
+
revision: record.revision,
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
if (typeof record.status !== 'string'
|
|
1055
|
+
|| !['planning', 'awaiting_execution_authorization', 'active', 'validating', 'accepted', 'blocked', 'cancelled'].includes(record.status)) {
|
|
1056
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state status is invalid.`, {
|
|
1057
|
+
milestoneId,
|
|
1058
|
+
status: record.status,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
if (!Array.isArray(record.taskIds) || !Array.isArray(record.memberships) || !Array.isArray(record.authorizations)) {
|
|
1062
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state arrays are invalid.`, {
|
|
1063
|
+
milestoneId,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
return parsed;
|
|
1067
|
+
}
|
|
1068
|
+
function resolveAlpha6MilestoneRoot(store, projectId, milestoneId) {
|
|
1069
|
+
const projectRootLogical = store.projectRoot(projectId);
|
|
1070
|
+
const trustedProjectRoot = existsSync(projectRootLogical) ? realpathSync(projectRootLogical) : path.resolve(projectRootLogical);
|
|
1071
|
+
const milestonesDirLogical = path.join(projectRootLogical, 'milestones');
|
|
1072
|
+
const trustedMilestonesDir = existsSync(milestonesDirLogical)
|
|
1073
|
+
? realpathSync(milestonesDirLogical)
|
|
1074
|
+
: path.join(trustedProjectRoot, 'milestones');
|
|
1075
|
+
const milestoneRootLogical = store.milestoneRoot(projectId, milestoneId);
|
|
1076
|
+
assertNoSymlinkPathComponents(trustedMilestonesDir, { label: 'Trusted milestones directory', requireLeaf: true });
|
|
1077
|
+
if (existsSync(milestoneRootLogical) && lstatSync(milestoneRootLogical).isSymbolicLink()) {
|
|
1078
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone root must not be a symlink: ${milestoneRootLogical}`);
|
|
1079
|
+
}
|
|
1080
|
+
const resolvedMilestoneRoot = existsSync(milestoneRootLogical)
|
|
1081
|
+
? realpathSync(milestoneRootLogical)
|
|
1082
|
+
: path.resolve(trustedMilestonesDir, milestoneId);
|
|
1083
|
+
if (resolvedMilestoneRoot !== trustedMilestonesDir
|
|
1084
|
+
&& !resolvedMilestoneRoot.startsWith(`${trustedMilestonesDir}${path.sep}`)) {
|
|
1085
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone root escaped the trusted milestones directory: ${milestoneRootLogical}`, {
|
|
1086
|
+
trustedMilestonesDir,
|
|
1087
|
+
resolvedMilestoneRoot,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
return resolvedMilestoneRoot;
|
|
1091
|
+
}
|
|
1092
|
+
function taskHasStarted(task) {
|
|
1093
|
+
return task.workspaceOwner !== null || [
|
|
1094
|
+
'in_progress',
|
|
1095
|
+
'validating',
|
|
1096
|
+
'needs_fix',
|
|
1097
|
+
'awaiting_final_acceptance',
|
|
1098
|
+
'merging',
|
|
1099
|
+
'ready_to_merge',
|
|
1100
|
+
'merged',
|
|
1101
|
+
].includes(task.status);
|
|
1102
|
+
}
|
|
1103
|
+
//# sourceMappingURL=milestone.js.map
|