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,981 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { WorkflowError } from '../errors.js';
|
|
6
|
+
import { createUlid } from '../ulid.js';
|
|
7
|
+
import { assertNoSymlinkPathComponents, computeSidecarEventHash, sha256Hex, } from './store-sidecars.js';
|
|
8
|
+
const HANDOFF_SIDECAR = 'handoffs.jsonl';
|
|
9
|
+
const NON_TERMINAL_TASK_STATUSES = new Set([
|
|
10
|
+
'planning',
|
|
11
|
+
'awaiting_execution_authorization',
|
|
12
|
+
'ready',
|
|
13
|
+
'in_progress',
|
|
14
|
+
'validating',
|
|
15
|
+
'blocked',
|
|
16
|
+
'needs_fix',
|
|
17
|
+
'awaiting_final_acceptance',
|
|
18
|
+
'ready_to_merge',
|
|
19
|
+
'merging',
|
|
20
|
+
]);
|
|
21
|
+
const HANDOFF_EVENT_TYPES = new Set(['handoff', 'claim', 'handback']);
|
|
22
|
+
const COORDINATION_LEVEL = 'C1';
|
|
23
|
+
export function defaultTaskWorkerActor(taskId) {
|
|
24
|
+
if (!/^TASK-[0-9A-HJKMNP-TV-Z]{26}$/.test(taskId)) {
|
|
25
|
+
throw new WorkflowError('INVALID_ARGUMENT', `Cannot derive a C1 Worker actor from invalid Task ID ${taskId}.`);
|
|
26
|
+
}
|
|
27
|
+
return `agent:worker:${taskId.toLowerCase()}`;
|
|
28
|
+
}
|
|
29
|
+
export function readTaskHandoffEvents(store, task) {
|
|
30
|
+
const sidecarPath = path.join(resolveAlpha6TaskRoot(store, task), HANDOFF_SIDECAR);
|
|
31
|
+
assertNoSymlinkPathComponents(sidecarPath, { label: 'Task handoff sidecar path', requireLeaf: true });
|
|
32
|
+
if (!existsSync(sidecarPath))
|
|
33
|
+
return [];
|
|
34
|
+
const events = parseTaskHandoffEvents(readFileSync(sidecarPath, 'utf8'), task, sidecarPath);
|
|
35
|
+
validateTaskHandoffHistory(events, task);
|
|
36
|
+
return events;
|
|
37
|
+
}
|
|
38
|
+
export function readTaskC1Posture(store, task) {
|
|
39
|
+
const events = readTaskHandoffEvents(store, task);
|
|
40
|
+
const latestEvent = events.at(-1) ?? null;
|
|
41
|
+
if (!latestEvent)
|
|
42
|
+
return { state: 'none', latestEvent: null };
|
|
43
|
+
if (latestEvent.eventType === 'handoff') {
|
|
44
|
+
return {
|
|
45
|
+
state: 'pending',
|
|
46
|
+
latestEvent,
|
|
47
|
+
targetActor: latestEvent.targetActor,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (latestEvent.eventType === 'claim') {
|
|
51
|
+
return {
|
|
52
|
+
state: 'claimed',
|
|
53
|
+
latestEvent,
|
|
54
|
+
claimant: latestEvent.actor.trim(),
|
|
55
|
+
writerLeaseTokenHash: latestEvent.writerLeaseTokenHash,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { state: 'none', latestEvent };
|
|
59
|
+
}
|
|
60
|
+
export function appendTaskHandoff(store, task, input, now) {
|
|
61
|
+
assertTaskSupportsC1Handoff(task);
|
|
62
|
+
const normalizedActor = normalizeActor(input.actor, 'Handoff actor');
|
|
63
|
+
const normalizedTargetActor = normalizeActor(input.targetActor, 'Handoff target actor');
|
|
64
|
+
if (normalizedActor === normalizedTargetActor) {
|
|
65
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'C1 handoff target must differ from the current actor.', {
|
|
66
|
+
taskId: task.id,
|
|
67
|
+
actor: normalizedActor,
|
|
68
|
+
targetActor: normalizedTargetActor,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const normalizedReason = normalizeReason(input.reason);
|
|
72
|
+
const expiresAt = normalizeFutureExpiry(input.expiresAt, now);
|
|
73
|
+
const posture = readTaskC1Posture(store, task);
|
|
74
|
+
if (posture.state === 'pending') {
|
|
75
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} already has a pending C1 handoff.`, {
|
|
76
|
+
taskId: task.id,
|
|
77
|
+
targetActor: posture.targetActor,
|
|
78
|
+
eventId: posture.latestEvent.eventId,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (posture.state === 'claimed' && posture.claimant !== normalizedActor) {
|
|
82
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Only claimant ${posture.claimant} may initiate a new handoff for Task ${task.id}.`, {
|
|
83
|
+
taskId: task.id,
|
|
84
|
+
claimant: posture.claimant,
|
|
85
|
+
actor: normalizedActor,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const leaseTokenHash = input.writerToken ? sha256Hex(input.writerToken) : null;
|
|
89
|
+
if (posture.state === 'claimed' && posture.writerLeaseTokenHash !== leaseTokenHash) {
|
|
90
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} handoff requires the claimant's bound writer lease token.`, {
|
|
91
|
+
taskId: task.id,
|
|
92
|
+
claimant: posture.claimant,
|
|
93
|
+
expectedWriterLeaseTokenHash: posture.writerLeaseTokenHash,
|
|
94
|
+
actualWriterLeaseTokenHash: leaseTokenHash,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const claimToken = randomBytes(32).toString('base64url');
|
|
98
|
+
const handoffId = `THO-${createUlid(now.getTime())}`;
|
|
99
|
+
const context = {
|
|
100
|
+
handoffId,
|
|
101
|
+
milestoneId: task.milestoneId,
|
|
102
|
+
milestoneDisplayNumber: input.milestoneDisplayNumber,
|
|
103
|
+
taskDisplayNumber: input.taskDisplayNumber,
|
|
104
|
+
briefHash: task.briefHash,
|
|
105
|
+
planHash: task.planHash,
|
|
106
|
+
knowledgeMapRevision: task.knowledgeMapRevision,
|
|
107
|
+
knowledgeMapHash: task.knowledgeMapHash,
|
|
108
|
+
expectedNext: normalizeReason(input.expectedNext),
|
|
109
|
+
delegate: normalizedTargetActor,
|
|
110
|
+
grantId: input.grantId,
|
|
111
|
+
claimTokenHash: sha256Hex(claimToken),
|
|
112
|
+
expiresAt,
|
|
113
|
+
};
|
|
114
|
+
const event = appendTaskHandoffEvent(store, task, {
|
|
115
|
+
eventType: 'handoff',
|
|
116
|
+
actor: normalizedActor,
|
|
117
|
+
targetActor: normalizedTargetActor,
|
|
118
|
+
writerLeaseTokenHash: leaseTokenHash,
|
|
119
|
+
reason: normalizedReason,
|
|
120
|
+
context,
|
|
121
|
+
handback: null,
|
|
122
|
+
}, now);
|
|
123
|
+
return {
|
|
124
|
+
event,
|
|
125
|
+
bundle: {
|
|
126
|
+
handoffId,
|
|
127
|
+
milestoneId: task.milestoneId,
|
|
128
|
+
milestoneDisplayNumber: input.milestoneDisplayNumber,
|
|
129
|
+
taskId: task.id,
|
|
130
|
+
taskDisplayNumber: input.taskDisplayNumber,
|
|
131
|
+
taskRevision: task.revision,
|
|
132
|
+
briefHash: task.briefHash,
|
|
133
|
+
planHash: task.planHash,
|
|
134
|
+
knowledgeMapRevision: task.knowledgeMapRevision,
|
|
135
|
+
knowledgeMapHash: task.knowledgeMapHash,
|
|
136
|
+
expectedNext: context.expectedNext,
|
|
137
|
+
delegate: normalizedTargetActor,
|
|
138
|
+
grantId: input.grantId,
|
|
139
|
+
claimToken,
|
|
140
|
+
createdAt: now.toISOString(),
|
|
141
|
+
expiresAt,
|
|
142
|
+
prompt: buildTaskHandoffPrompt(task, context.expectedNext, normalizedTargetActor),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export function appendTaskClaim(store, task, actor, claimToken, writerToken, now) {
|
|
147
|
+
assertTaskSupportsC1Handoff(task);
|
|
148
|
+
const normalizedActor = normalizeActor(actor, 'Claim actor');
|
|
149
|
+
if (writerToken === null) {
|
|
150
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} claim requires a bound writer lease token.`, {
|
|
151
|
+
taskId: task.id,
|
|
152
|
+
actor: normalizedActor,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const posture = readTaskC1Posture(store, task);
|
|
156
|
+
if (posture.state !== 'pending') {
|
|
157
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} does not have a pending C1 handoff to claim.`, {
|
|
158
|
+
taskId: task.id,
|
|
159
|
+
posture: posture.state,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (posture.targetActor !== normalizedActor) {
|
|
163
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Only handoff target ${posture.targetActor} may claim Task ${task.id}.`, {
|
|
164
|
+
taskId: task.id,
|
|
165
|
+
targetActor: posture.targetActor,
|
|
166
|
+
actor: normalizedActor,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (posture.latestEvent.taskRevision !== task.revision
|
|
170
|
+
|| posture.latestEvent.briefHash !== task.briefHash
|
|
171
|
+
|| posture.latestEvent.planHash !== task.planHash
|
|
172
|
+
|| posture.latestEvent.knowledgeMapRevision !== task.knowledgeMapRevision
|
|
173
|
+
|| posture.latestEvent.knowledgeMapHash !== task.knowledgeMapHash) {
|
|
174
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `C1 handoff ${posture.latestEvent.handoffId} is stale for the current Task context.`, {
|
|
175
|
+
taskId: task.id,
|
|
176
|
+
handoffId: posture.latestEvent.handoffId,
|
|
177
|
+
handoffTaskRevision: posture.latestEvent.taskRevision,
|
|
178
|
+
currentTaskRevision: task.revision,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (Date.parse(posture.latestEvent.expiresAt) <= now.getTime()) {
|
|
182
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `C1 handoff ${posture.latestEvent.handoffId} has expired.`, {
|
|
183
|
+
taskId: task.id,
|
|
184
|
+
handoffId: posture.latestEvent.handoffId,
|
|
185
|
+
expiresAt: posture.latestEvent.expiresAt,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (!claimToken || sha256Hex(claimToken) !== posture.latestEvent.claimTokenHash) {
|
|
189
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `C1 handoff ${posture.latestEvent.handoffId} claim token is invalid.`, {
|
|
190
|
+
taskId: task.id,
|
|
191
|
+
handoffId: posture.latestEvent.handoffId,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return appendTaskHandoffEvent(store, task, {
|
|
195
|
+
eventType: 'claim',
|
|
196
|
+
actor: normalizedActor,
|
|
197
|
+
targetActor: null,
|
|
198
|
+
writerLeaseTokenHash: writerToken ? sha256Hex(writerToken) : null,
|
|
199
|
+
reason: `Claimed pending C1 handoff from ${posture.latestEvent.actor}.`,
|
|
200
|
+
context: contextFromEvent(posture.latestEvent),
|
|
201
|
+
handback: null,
|
|
202
|
+
}, now);
|
|
203
|
+
}
|
|
204
|
+
export function appendTaskHandback(store, task, actor, reason, writerToken, limitations, recommendedNext, now) {
|
|
205
|
+
const normalizedActor = normalizeActor(actor, 'Handback actor');
|
|
206
|
+
const normalizedReason = normalizeReason(reason);
|
|
207
|
+
const posture = readTaskC1Posture(store, task);
|
|
208
|
+
if (posture.state !== 'claimed') {
|
|
209
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} does not have an active C1 claim to hand back.`, {
|
|
210
|
+
taskId: task.id,
|
|
211
|
+
posture: posture.state,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (posture.claimant !== normalizedActor) {
|
|
215
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Only claimant ${posture.claimant} may hand back Task ${task.id}.`, {
|
|
216
|
+
taskId: task.id,
|
|
217
|
+
claimant: posture.claimant,
|
|
218
|
+
actor: normalizedActor,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const leaseTokenHash = writerToken ? sha256Hex(writerToken) : null;
|
|
222
|
+
if (posture.writerLeaseTokenHash !== leaseTokenHash) {
|
|
223
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} handback requires the claimant's bound writer lease token.`, {
|
|
224
|
+
taskId: task.id,
|
|
225
|
+
claimant: posture.claimant,
|
|
226
|
+
expectedWriterLeaseTokenHash: posture.writerLeaseTokenHash,
|
|
227
|
+
actualWriterLeaseTokenHash: leaseTokenHash,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (task.status !== 'merged' && task.status !== 'cancelled') {
|
|
231
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} handback requires a terminal Task status.`, {
|
|
232
|
+
taskId: task.id,
|
|
233
|
+
taskStatus: task.status,
|
|
234
|
+
terminalStatuses: ['merged', 'cancelled'],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const handback = {
|
|
238
|
+
status: task.status,
|
|
239
|
+
mergedCommit: task.mergedCommit,
|
|
240
|
+
planHash: task.planHash,
|
|
241
|
+
resultHash: task.resultHash,
|
|
242
|
+
evidenceHash: task.evidenceHash,
|
|
243
|
+
reviewAttestationHash: task.review.status === 'pending'
|
|
244
|
+
? null
|
|
245
|
+
: sha256Hex(JSON.stringify(task.review)),
|
|
246
|
+
knowledgeMapRevision: task.knowledgeMapRevision,
|
|
247
|
+
knowledgeMapHash: task.knowledgeMapHash,
|
|
248
|
+
limitations: normalizeStringList(limitations, 'C1 handback limitations'),
|
|
249
|
+
recommendedNext: normalizeReason(recommendedNext),
|
|
250
|
+
};
|
|
251
|
+
return appendTaskHandoffEvent(store, task, {
|
|
252
|
+
eventType: 'handback',
|
|
253
|
+
actor: normalizedActor,
|
|
254
|
+
targetActor: null,
|
|
255
|
+
writerLeaseTokenHash: leaseTokenHash,
|
|
256
|
+
reason: normalizedReason,
|
|
257
|
+
context: contextFromEvent(posture.latestEvent),
|
|
258
|
+
handback,
|
|
259
|
+
}, now);
|
|
260
|
+
}
|
|
261
|
+
export function assertTaskMutationAllowedByC1(store, task, authority) {
|
|
262
|
+
const posture = readTaskC1Posture(store, task);
|
|
263
|
+
if (posture.state === 'none')
|
|
264
|
+
return;
|
|
265
|
+
if (posture.state === 'pending') {
|
|
266
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has a pending C1 handoff and cannot be mutated until claim or handback.`, {
|
|
267
|
+
taskId: task.id,
|
|
268
|
+
targetActor: posture.targetActor,
|
|
269
|
+
eventId: posture.latestEvent.eventId,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const normalizedActor = normalizeActor(authority.actor, 'Mutation actor');
|
|
273
|
+
if (normalizedActor !== posture.claimant) {
|
|
274
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Only active claimant ${posture.claimant} may mutate Task ${task.id}.`, {
|
|
275
|
+
taskId: task.id,
|
|
276
|
+
claimant: posture.claimant,
|
|
277
|
+
actor: normalizedActor,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (posture.writerLeaseTokenHash === null) {
|
|
281
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} claimed C1 posture is missing a bound writer lease token.`, {
|
|
282
|
+
taskId: task.id,
|
|
283
|
+
claimant: posture.claimant,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const actualLeaseTokenHash = authority.writerToken ? sha256Hex(authority.writerToken) : null;
|
|
287
|
+
if (posture.writerLeaseTokenHash !== actualLeaseTokenHash) {
|
|
288
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} mutation requires the claimant's bound writer lease token.`, {
|
|
289
|
+
taskId: task.id,
|
|
290
|
+
claimant: posture.claimant,
|
|
291
|
+
expectedWriterLeaseTokenHash: posture.writerLeaseTokenHash,
|
|
292
|
+
actualWriterLeaseTokenHash: actualLeaseTokenHash,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function appendTaskHandoffEvent(store, task, input, now) {
|
|
297
|
+
return store.appendSidecarEvent(store.taskRoot(task.projectId, task.id), HANDOFF_SIDECAR, {
|
|
298
|
+
eventId: `HOF-${createUlid(now.getTime())}`,
|
|
299
|
+
recordedAt: now.toISOString(),
|
|
300
|
+
...input.context,
|
|
301
|
+
taskId: task.id,
|
|
302
|
+
taskRevision: task.revision,
|
|
303
|
+
eventType: input.eventType,
|
|
304
|
+
coordinationLevel: COORDINATION_LEVEL,
|
|
305
|
+
actor: input.actor,
|
|
306
|
+
targetActor: input.targetActor,
|
|
307
|
+
machineEvidence: {
|
|
308
|
+
hostname: os.hostname(),
|
|
309
|
+
pid: process.pid,
|
|
310
|
+
platform: process.platform,
|
|
311
|
+
arch: process.arch,
|
|
312
|
+
nodeVersion: process.version,
|
|
313
|
+
},
|
|
314
|
+
writerLeaseTokenHash: input.writerLeaseTokenHash,
|
|
315
|
+
reason: input.reason,
|
|
316
|
+
handback: input.handback,
|
|
317
|
+
}, {
|
|
318
|
+
validateEvent: (event, context) => validateStoredTaskHandoffEvent(event, task, context.file, context.line),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
function validateStoredTaskHandoffEvent(event, task, file, line) {
|
|
322
|
+
assertExactTaskHandoffEventShape(event, task, file, line);
|
|
323
|
+
if (event.taskId !== task.id) {
|
|
324
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff event taskId does not match the current Task.', {
|
|
325
|
+
file,
|
|
326
|
+
line,
|
|
327
|
+
expectedTaskId: task.id,
|
|
328
|
+
actualTaskId: event.taskId,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (!Number.isInteger(event.taskRevision) || event.taskRevision < 1) {
|
|
332
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff event taskRevision must be a positive integer.', {
|
|
333
|
+
file,
|
|
334
|
+
line,
|
|
335
|
+
taskId: task.id,
|
|
336
|
+
taskRevision: event.taskRevision,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
assertNonEmptyStringField(event.handoffId, 'Task handoff handoffId is required.', { file, line, taskId: task.id });
|
|
340
|
+
if (!/^THO-[0-9A-HJKMNP-TV-Z]{26}$/u.test(event.handoffId)) {
|
|
341
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff handoffId is invalid.', { file, line, taskId: task.id, handoffId: event.handoffId });
|
|
342
|
+
}
|
|
343
|
+
if (event.milestoneId !== task.milestoneId) {
|
|
344
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff milestone binding does not match the Task.', {
|
|
345
|
+
file, line, taskId: task.id, eventMilestoneId: event.milestoneId, taskMilestoneId: task.milestoneId,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
assertStateCorruptHash64(event.briefHash, 'Task handoff briefHash is invalid.', { file, line, taskId: task.id });
|
|
349
|
+
if (event.planHash !== null)
|
|
350
|
+
assertStateCorruptHash64(event.planHash, 'Task handoff planHash is invalid.', { file, line, taskId: task.id });
|
|
351
|
+
if (event.knowledgeMapHash !== null) {
|
|
352
|
+
assertStateCorruptHash64(event.knowledgeMapHash, 'Task handoff knowledgeMapHash is invalid.', { file, line, taskId: task.id });
|
|
353
|
+
}
|
|
354
|
+
assertNullablePositiveInteger(event.milestoneDisplayNumber, 'Task handoff milestoneDisplayNumber is invalid.', { file, line, taskId: task.id });
|
|
355
|
+
assertNullablePositiveInteger(event.taskDisplayNumber, 'Task handoff taskDisplayNumber is invalid.', { file, line, taskId: task.id });
|
|
356
|
+
assertNullablePositiveInteger(event.knowledgeMapRevision, 'Task handoff knowledgeMapRevision is invalid.', { file, line, taskId: task.id });
|
|
357
|
+
if (event.milestoneId === null && (event.milestoneDisplayNumber !== null || event.taskDisplayNumber !== null)) {
|
|
358
|
+
throw new WorkflowError('STATE_CORRUPT', 'Standalone Task handoff cannot declare Milestone display numbers.', {
|
|
359
|
+
file, line, taskId: task.id,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
assertNonEmptyStringField(event.expectedNext, 'Task handoff expectedNext is required.', { file, line, taskId: task.id });
|
|
363
|
+
assertNonEmptyStringField(event.delegate, 'Task handoff delegate is required.', { file, line, taskId: task.id });
|
|
364
|
+
if (event.grantId !== null && (typeof event.grantId !== 'string' || !event.grantId.trim())) {
|
|
365
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff grantId must be null or a non-empty string.', { file, line, taskId: task.id });
|
|
366
|
+
}
|
|
367
|
+
assertStateCorruptHash64(event.claimTokenHash, 'Task handoff claimTokenHash is invalid.', { file, line, taskId: task.id });
|
|
368
|
+
if (!event.expiresAt || Number.isNaN(Date.parse(event.expiresAt))) {
|
|
369
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff expiresAt must be an ISO timestamp.', { file, line, taskId: task.id });
|
|
370
|
+
}
|
|
371
|
+
if (!HANDOFF_EVENT_TYPES.has(event.eventType)) {
|
|
372
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff eventType is invalid.', {
|
|
373
|
+
file,
|
|
374
|
+
line,
|
|
375
|
+
taskId: task.id,
|
|
376
|
+
eventType: event.eventType,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (event.coordinationLevel !== COORDINATION_LEVEL) {
|
|
380
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff coordinationLevel is invalid.', {
|
|
381
|
+
file,
|
|
382
|
+
line,
|
|
383
|
+
taskId: task.id,
|
|
384
|
+
coordinationLevel: event.coordinationLevel,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (!event.actor.trim()) {
|
|
388
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff actor is required.', {
|
|
389
|
+
file,
|
|
390
|
+
line,
|
|
391
|
+
taskId: task.id,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (event.targetActor !== null && !event.targetActor.trim()) {
|
|
395
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff targetActor must be non-empty when present.', {
|
|
396
|
+
file,
|
|
397
|
+
line,
|
|
398
|
+
taskId: task.id,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (!event.reason.trim()) {
|
|
402
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff reason is required.', {
|
|
403
|
+
file,
|
|
404
|
+
line,
|
|
405
|
+
taskId: task.id,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (event.writerLeaseTokenHash !== null) {
|
|
409
|
+
assertStateCorruptHash64(event.writerLeaseTokenHash, 'Task handoff writerLeaseTokenHash is invalid.', {
|
|
410
|
+
file,
|
|
411
|
+
line,
|
|
412
|
+
taskId: task.id,
|
|
413
|
+
writerLeaseTokenHash: event.writerLeaseTokenHash,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (event.eventType === 'handoff' && event.targetActor === null) {
|
|
417
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff event must record targetActor.', {
|
|
418
|
+
file,
|
|
419
|
+
line,
|
|
420
|
+
taskId: task.id,
|
|
421
|
+
eventType: event.eventType,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (event.eventType === 'handoff' && event.targetActor !== event.delegate) {
|
|
425
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff targetActor must equal its bound delegate.', {
|
|
426
|
+
file, line, taskId: task.id, targetActor: event.targetActor, delegate: event.delegate,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if ((event.eventType === 'claim' || event.eventType === 'handback') && event.targetActor !== null) {
|
|
430
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task claim/handback events must not record targetActor.', {
|
|
431
|
+
file,
|
|
432
|
+
line,
|
|
433
|
+
taskId: task.id,
|
|
434
|
+
eventType: event.eventType,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (event.eventType === 'handback' && event.handback === null) {
|
|
438
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handback event must contain the terminal handback bundle.', {
|
|
439
|
+
file, line, taskId: task.id,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if (event.handback !== null)
|
|
443
|
+
validateHandbackRecord(event.handback, task, file, line);
|
|
444
|
+
if (event.eventType === 'handback' && event.handback) {
|
|
445
|
+
const expectedReviewAttestationHash = task.review.status === 'pending'
|
|
446
|
+
? null
|
|
447
|
+
: sha256Hex(JSON.stringify(task.review));
|
|
448
|
+
const currentBindings = {
|
|
449
|
+
status: task.status,
|
|
450
|
+
mergedCommit: task.mergedCommit,
|
|
451
|
+
planHash: task.planHash,
|
|
452
|
+
resultHash: task.resultHash,
|
|
453
|
+
evidenceHash: task.evidenceHash,
|
|
454
|
+
reviewAttestationHash: expectedReviewAttestationHash,
|
|
455
|
+
knowledgeMapRevision: task.knowledgeMapRevision,
|
|
456
|
+
knowledgeMapHash: task.knowledgeMapHash,
|
|
457
|
+
};
|
|
458
|
+
const eventBindings = {
|
|
459
|
+
status: event.handback.status,
|
|
460
|
+
mergedCommit: event.handback.mergedCommit,
|
|
461
|
+
planHash: event.handback.planHash,
|
|
462
|
+
resultHash: event.handback.resultHash,
|
|
463
|
+
evidenceHash: event.handback.evidenceHash,
|
|
464
|
+
reviewAttestationHash: event.handback.reviewAttestationHash,
|
|
465
|
+
knowledgeMapRevision: event.handback.knowledgeMapRevision,
|
|
466
|
+
knowledgeMapHash: event.handback.knowledgeMapHash,
|
|
467
|
+
};
|
|
468
|
+
if (JSON.stringify(currentBindings) !== JSON.stringify(eventBindings)) {
|
|
469
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handback bundle is stale or inconsistent with terminal Task state.', {
|
|
470
|
+
file, line, taskId: task.id, currentBindings, eventBindings,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (event.eventType !== 'handback' && event.handback !== null) {
|
|
475
|
+
throw new WorkflowError('STATE_CORRUPT', 'Only handback events may contain a handback bundle.', {
|
|
476
|
+
file, line, taskId: task.id, eventType: event.eventType,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function validateTaskHandoffHistory(events, task) {
|
|
481
|
+
let posture = { state: 'none', latestEvent: null };
|
|
482
|
+
let previousTaskRevision = 0;
|
|
483
|
+
for (const [index, event] of events.entries()) {
|
|
484
|
+
const previous = posture;
|
|
485
|
+
if (event.taskRevision > task.revision) {
|
|
486
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff event references a future Task revision.', {
|
|
487
|
+
taskId: task.id,
|
|
488
|
+
eventId: event.eventId,
|
|
489
|
+
taskRevision: task.revision,
|
|
490
|
+
eventTaskRevision: event.taskRevision,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (event.taskRevision < previousTaskRevision) {
|
|
494
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff event taskRevision must be nondecreasing through history.', {
|
|
495
|
+
taskId: task.id,
|
|
496
|
+
eventId: event.eventId,
|
|
497
|
+
previousTaskRevision,
|
|
498
|
+
eventTaskRevision: event.taskRevision,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
switch (event.eventType) {
|
|
502
|
+
case 'handoff':
|
|
503
|
+
if (previous.state === 'pending') {
|
|
504
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff sidecar cannot stack pending handoffs.', {
|
|
505
|
+
taskId: task.id,
|
|
506
|
+
eventId: event.eventId,
|
|
507
|
+
previousEventId: previous.latestEvent.eventId,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
if (previous.state === 'claimed' && previous.claimant !== event.actor.trim()) {
|
|
511
|
+
throw new WorkflowError('STATE_CORRUPT', 'Only the active claimant may issue a new handoff.', {
|
|
512
|
+
taskId: task.id,
|
|
513
|
+
eventId: event.eventId,
|
|
514
|
+
claimant: previous.claimant,
|
|
515
|
+
actor: event.actor,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
if (previous.state === 'claimed' && previous.writerLeaseTokenHash !== event.writerLeaseTokenHash) {
|
|
519
|
+
throw new WorkflowError('STATE_CORRUPT', 'Claimed handoff rewrite must preserve the bound lease token hash.', {
|
|
520
|
+
taskId: task.id,
|
|
521
|
+
eventId: event.eventId,
|
|
522
|
+
claimant: previous.claimant,
|
|
523
|
+
expectedWriterLeaseTokenHash: previous.writerLeaseTokenHash,
|
|
524
|
+
actualWriterLeaseTokenHash: event.writerLeaseTokenHash,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
posture = { state: 'pending', latestEvent: event, targetActor: event.targetActor };
|
|
528
|
+
break;
|
|
529
|
+
case 'claim':
|
|
530
|
+
if (previous.state !== 'pending') {
|
|
531
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task claim requires a pending handoff.', {
|
|
532
|
+
taskId: task.id,
|
|
533
|
+
eventId: event.eventId,
|
|
534
|
+
previousState: previous.state,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
if (previous.targetActor !== event.actor.trim()) {
|
|
538
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task claim actor must equal the pending handoff target.', {
|
|
539
|
+
taskId: task.id,
|
|
540
|
+
eventId: event.eventId,
|
|
541
|
+
targetActor: previous.targetActor,
|
|
542
|
+
actor: event.actor,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
assertSameHandoffContext(previous.latestEvent, event, task);
|
|
546
|
+
posture = {
|
|
547
|
+
state: 'claimed',
|
|
548
|
+
latestEvent: event,
|
|
549
|
+
claimant: event.actor.trim(),
|
|
550
|
+
writerLeaseTokenHash: event.writerLeaseTokenHash,
|
|
551
|
+
};
|
|
552
|
+
break;
|
|
553
|
+
case 'handback':
|
|
554
|
+
if (previous.state !== 'claimed') {
|
|
555
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handback requires an active claim.', {
|
|
556
|
+
taskId: task.id,
|
|
557
|
+
eventId: event.eventId,
|
|
558
|
+
previousState: previous.state,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
if (previous.claimant !== event.actor.trim()) {
|
|
562
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handback actor must equal the active claimant.', {
|
|
563
|
+
taskId: task.id,
|
|
564
|
+
eventId: event.eventId,
|
|
565
|
+
claimant: previous.claimant,
|
|
566
|
+
actor: event.actor,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (previous.writerLeaseTokenHash !== event.writerLeaseTokenHash) {
|
|
570
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handback must preserve the claimant lease-token binding.', {
|
|
571
|
+
taskId: task.id,
|
|
572
|
+
eventId: event.eventId,
|
|
573
|
+
expectedWriterLeaseTokenHash: previous.writerLeaseTokenHash,
|
|
574
|
+
actualWriterLeaseTokenHash: event.writerLeaseTokenHash,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
assertSameHandoffContext(previous.latestEvent, event, task);
|
|
578
|
+
posture = { state: 'none', latestEvent: event };
|
|
579
|
+
break;
|
|
580
|
+
default:
|
|
581
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff eventType is unknown during history validation.', {
|
|
582
|
+
taskId: task.id,
|
|
583
|
+
eventId: event.eventId,
|
|
584
|
+
eventType: event.eventType,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
const expectedHash = computeSidecarEventHash(event);
|
|
588
|
+
if (expectedHash !== event.eventHash) {
|
|
589
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff eventHash mismatch.', {
|
|
590
|
+
taskId: task.id,
|
|
591
|
+
eventId: event.eventId,
|
|
592
|
+
expectedEventHash: expectedHash,
|
|
593
|
+
actualEventHash: event.eventHash,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
previousTaskRevision = event.taskRevision;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function assertSameHandoffContext(expected, actual, task) {
|
|
600
|
+
const expectedContext = contextFromEvent(expected);
|
|
601
|
+
const actualContext = contextFromEvent(actual);
|
|
602
|
+
if (JSON.stringify(expectedContext) !== JSON.stringify(actualContext)) {
|
|
603
|
+
throw new WorkflowError('STATE_CORRUPT', 'C1 claim/handback changed its prepared handoff context.', {
|
|
604
|
+
taskId: task.id,
|
|
605
|
+
handoffId: expected.handoffId,
|
|
606
|
+
eventId: actual.eventId,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function parseTaskHandoffEvents(content, task, file) {
|
|
611
|
+
if (content.length === 0) {
|
|
612
|
+
throw new WorkflowError('STATE_CORRUPT', `Task handoff sidecar must not be empty: ${file}`);
|
|
613
|
+
}
|
|
614
|
+
const rawLines = content.split('\n');
|
|
615
|
+
if (rawLines.at(-1) === '')
|
|
616
|
+
rawLines.pop();
|
|
617
|
+
if (rawLines.some((line) => line.length === 0)) {
|
|
618
|
+
throw new WorkflowError('STATE_CORRUPT', `Task handoff sidecar contains a blank line: ${file}`);
|
|
619
|
+
}
|
|
620
|
+
const events = [];
|
|
621
|
+
const eventIds = new Set();
|
|
622
|
+
let previousEventHash = null;
|
|
623
|
+
for (const [index, rawLine] of rawLines.entries()) {
|
|
624
|
+
let parsed;
|
|
625
|
+
try {
|
|
626
|
+
parsed = JSON.parse(rawLine);
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
throw new WorkflowError('STATE_CORRUPT', `Invalid task handoff JSONL: ${file}`, {
|
|
630
|
+
line: index + 1,
|
|
631
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
const event = parsed;
|
|
635
|
+
validateStoredTaskHandoffEvent(event, task, file, index + 1);
|
|
636
|
+
if (eventIds.has(event.eventId)) {
|
|
637
|
+
throw new WorkflowError('STATE_CORRUPT', `Duplicate task handoff event id in ${file}`, {
|
|
638
|
+
line: index + 1,
|
|
639
|
+
eventId: event.eventId,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
eventIds.add(event.eventId);
|
|
643
|
+
if (event.previousEventHash !== previousEventHash) {
|
|
644
|
+
throw new WorkflowError('STATE_CORRUPT', `Broken task handoff chain in ${file}`, {
|
|
645
|
+
line: index + 1,
|
|
646
|
+
expectedPreviousEventHash: previousEventHash,
|
|
647
|
+
actualPreviousEventHash: event.previousEventHash,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
const expectedEventHash = computeSidecarEventHash(event);
|
|
651
|
+
if (event.eventHash !== expectedEventHash) {
|
|
652
|
+
throw new WorkflowError('STATE_CORRUPT', `Task handoff event hash mismatch in ${file}`, {
|
|
653
|
+
line: index + 1,
|
|
654
|
+
eventId: event.eventId,
|
|
655
|
+
expectedEventHash,
|
|
656
|
+
actualEventHash: event.eventHash,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
previousEventHash = event.eventHash;
|
|
660
|
+
events.push(event);
|
|
661
|
+
}
|
|
662
|
+
return events;
|
|
663
|
+
}
|
|
664
|
+
function assertExactTaskHandoffEventShape(event, task, file, line) {
|
|
665
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
|
666
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff event must be an object.', {
|
|
667
|
+
file,
|
|
668
|
+
line,
|
|
669
|
+
taskId: task.id,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
const allowedKeys = new Set([
|
|
673
|
+
'eventId',
|
|
674
|
+
'previousEventHash',
|
|
675
|
+
'recordedAt',
|
|
676
|
+
'eventHash',
|
|
677
|
+
'handoffId',
|
|
678
|
+
'taskId',
|
|
679
|
+
'taskRevision',
|
|
680
|
+
'milestoneId',
|
|
681
|
+
'milestoneDisplayNumber',
|
|
682
|
+
'taskDisplayNumber',
|
|
683
|
+
'briefHash',
|
|
684
|
+
'planHash',
|
|
685
|
+
'knowledgeMapRevision',
|
|
686
|
+
'knowledgeMapHash',
|
|
687
|
+
'expectedNext',
|
|
688
|
+
'delegate',
|
|
689
|
+
'grantId',
|
|
690
|
+
'claimTokenHash',
|
|
691
|
+
'expiresAt',
|
|
692
|
+
'eventType',
|
|
693
|
+
'coordinationLevel',
|
|
694
|
+
'actor',
|
|
695
|
+
'targetActor',
|
|
696
|
+
'machineEvidence',
|
|
697
|
+
'writerLeaseTokenHash',
|
|
698
|
+
'reason',
|
|
699
|
+
'handback',
|
|
700
|
+
]);
|
|
701
|
+
const extraKeys = Object.keys(event).filter((key) => !allowedKeys.has(key));
|
|
702
|
+
if (extraKeys.length > 0) {
|
|
703
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff event contains unexpected fields.', {
|
|
704
|
+
file,
|
|
705
|
+
line,
|
|
706
|
+
taskId: task.id,
|
|
707
|
+
extraKeys,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
assertNonEmptyStringField(event.eventId, 'Task handoff eventId must be a non-empty string.', {
|
|
711
|
+
file,
|
|
712
|
+
line,
|
|
713
|
+
taskId: task.id,
|
|
714
|
+
eventId: event.eventId,
|
|
715
|
+
});
|
|
716
|
+
if (event.previousEventHash !== null && typeof event.previousEventHash !== 'string') {
|
|
717
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff previousEventHash must be null or a SHA-256 hash string.', {
|
|
718
|
+
file,
|
|
719
|
+
line,
|
|
720
|
+
taskId: task.id,
|
|
721
|
+
previousEventHash: event.previousEventHash,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
if (event.previousEventHash !== null) {
|
|
725
|
+
assertStateCorruptHash64(event.previousEventHash, 'Task handoff previousEventHash must be a SHA-256 hash string.', {
|
|
726
|
+
file,
|
|
727
|
+
line,
|
|
728
|
+
taskId: task.id,
|
|
729
|
+
previousEventHash: event.previousEventHash,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
assertNonEmptyStringField(event.eventHash, 'Task handoff eventHash must be a SHA-256 hash string.', {
|
|
733
|
+
file,
|
|
734
|
+
line,
|
|
735
|
+
taskId: task.id,
|
|
736
|
+
eventHash: event.eventHash,
|
|
737
|
+
});
|
|
738
|
+
assertStateCorruptHash64(event.eventHash, 'Task handoff eventHash must be a SHA-256 hash string.', {
|
|
739
|
+
file,
|
|
740
|
+
line,
|
|
741
|
+
taskId: task.id,
|
|
742
|
+
eventHash: event.eventHash,
|
|
743
|
+
});
|
|
744
|
+
assertNonEmptyStringField(event.taskId, 'Task handoff taskId must be a non-empty string.', {
|
|
745
|
+
file,
|
|
746
|
+
line,
|
|
747
|
+
taskId: task.id,
|
|
748
|
+
eventTaskId: event.taskId,
|
|
749
|
+
});
|
|
750
|
+
if (typeof event.eventType !== 'string') {
|
|
751
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff eventType must be a string.', {
|
|
752
|
+
file,
|
|
753
|
+
line,
|
|
754
|
+
taskId: task.id,
|
|
755
|
+
eventType: event.eventType,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
if (typeof event.coordinationLevel !== 'string') {
|
|
759
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff coordinationLevel must be a string.', {
|
|
760
|
+
file,
|
|
761
|
+
line,
|
|
762
|
+
taskId: task.id,
|
|
763
|
+
coordinationLevel: event.coordinationLevel,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
assertNonEmptyStringField(event.actor, 'Task handoff actor must be a non-empty string.', {
|
|
767
|
+
file,
|
|
768
|
+
line,
|
|
769
|
+
taskId: task.id,
|
|
770
|
+
actor: event.actor,
|
|
771
|
+
});
|
|
772
|
+
if (event.targetActor !== null && typeof event.targetActor !== 'string') {
|
|
773
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff targetActor must be null or a non-empty string.', {
|
|
774
|
+
file,
|
|
775
|
+
line,
|
|
776
|
+
taskId: task.id,
|
|
777
|
+
targetActor: event.targetActor,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
if (event.targetActor !== null && !event.targetActor.trim()) {
|
|
781
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff targetActor must be non-empty when present.', {
|
|
782
|
+
file,
|
|
783
|
+
line,
|
|
784
|
+
taskId: task.id,
|
|
785
|
+
targetActor: event.targetActor,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
if (event.writerLeaseTokenHash !== null && typeof event.writerLeaseTokenHash !== 'string') {
|
|
789
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff writerLeaseTokenHash must be null or a SHA-256 hash string.', {
|
|
790
|
+
file,
|
|
791
|
+
line,
|
|
792
|
+
taskId: task.id,
|
|
793
|
+
writerLeaseTokenHash: event.writerLeaseTokenHash,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
assertNonEmptyStringField(event.reason, 'Task handoff reason must be a non-empty string.', {
|
|
797
|
+
file,
|
|
798
|
+
line,
|
|
799
|
+
taskId: task.id,
|
|
800
|
+
reason: event.reason,
|
|
801
|
+
});
|
|
802
|
+
if (typeof event.recordedAt !== 'string' || !event.recordedAt.trim() || Number.isNaN(Date.parse(event.recordedAt))) {
|
|
803
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff recordedAt must be an ISO timestamp string.', {
|
|
804
|
+
file,
|
|
805
|
+
line,
|
|
806
|
+
taskId: task.id,
|
|
807
|
+
recordedAt: event.recordedAt,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
if (!event.machineEvidence || typeof event.machineEvidence !== 'object' || Array.isArray(event.machineEvidence)) {
|
|
811
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff machineEvidence is invalid.', {
|
|
812
|
+
file,
|
|
813
|
+
line,
|
|
814
|
+
taskId: task.id,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
const machineEvidence = event.machineEvidence;
|
|
818
|
+
const allowedMachineKeys = new Set(['hostname', 'pid', 'platform', 'arch', 'nodeVersion']);
|
|
819
|
+
const extraMachineKeys = Object.keys(machineEvidence).filter((key) => !allowedMachineKeys.has(key));
|
|
820
|
+
if (extraMachineKeys.length > 0) {
|
|
821
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff machineEvidence contains unexpected fields.', {
|
|
822
|
+
file,
|
|
823
|
+
line,
|
|
824
|
+
taskId: task.id,
|
|
825
|
+
extraMachineKeys,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
if (typeof machineEvidence.hostname !== 'string'
|
|
829
|
+
|| !machineEvidence.hostname.trim()
|
|
830
|
+
|| !Number.isInteger(machineEvidence.pid)
|
|
831
|
+
|| machineEvidence.pid < 1
|
|
832
|
+
|| typeof machineEvidence.platform !== 'string'
|
|
833
|
+
|| !machineEvidence.platform.trim()
|
|
834
|
+
|| typeof machineEvidence.arch !== 'string'
|
|
835
|
+
|| !machineEvidence.arch.trim()
|
|
836
|
+
|| typeof machineEvidence.nodeVersion !== 'string'
|
|
837
|
+
|| !machineEvidence.nodeVersion.trim()) {
|
|
838
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handoff machineEvidence is invalid.', {
|
|
839
|
+
file,
|
|
840
|
+
line,
|
|
841
|
+
taskId: task.id,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function assertTaskSupportsC1Handoff(task) {
|
|
846
|
+
if (!NON_TERMINAL_TASK_STATUSES.has(task.status)) {
|
|
847
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} does not support C1 handoff while ${task.status}.`, {
|
|
848
|
+
taskId: task.id,
|
|
849
|
+
taskStatus: task.status,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
function normalizeActor(value, label) {
|
|
854
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
855
|
+
throw new WorkflowError('INVALID_ARGUMENT', `${label} is required.`);
|
|
856
|
+
}
|
|
857
|
+
return value.trim();
|
|
858
|
+
}
|
|
859
|
+
function normalizeReason(value) {
|
|
860
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
861
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'C1 handoff reason is required.');
|
|
862
|
+
}
|
|
863
|
+
return value.trim();
|
|
864
|
+
}
|
|
865
|
+
function normalizeFutureExpiry(value, now) {
|
|
866
|
+
const parsed = new Date(value);
|
|
867
|
+
if (!Number.isFinite(parsed.getTime()) || parsed.getTime() <= now.getTime()) {
|
|
868
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'C1 handoff expiresAt must be a future ISO timestamp.');
|
|
869
|
+
}
|
|
870
|
+
return parsed.toISOString();
|
|
871
|
+
}
|
|
872
|
+
function normalizeStringList(value, label) {
|
|
873
|
+
if (!Array.isArray(value))
|
|
874
|
+
throw new WorkflowError('INVALID_ARGUMENT', `${label} must be an array.`);
|
|
875
|
+
const normalized = value.map((entry) => normalizeReason(entry));
|
|
876
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
877
|
+
throw new WorkflowError('INVALID_ARGUMENT', `${label} must contain unique values.`);
|
|
878
|
+
}
|
|
879
|
+
return normalized;
|
|
880
|
+
}
|
|
881
|
+
function contextFromEvent(event) {
|
|
882
|
+
return {
|
|
883
|
+
handoffId: event.handoffId,
|
|
884
|
+
milestoneId: event.milestoneId,
|
|
885
|
+
milestoneDisplayNumber: event.milestoneDisplayNumber,
|
|
886
|
+
taskDisplayNumber: event.taskDisplayNumber,
|
|
887
|
+
briefHash: event.briefHash,
|
|
888
|
+
planHash: event.planHash,
|
|
889
|
+
knowledgeMapRevision: event.knowledgeMapRevision,
|
|
890
|
+
knowledgeMapHash: event.knowledgeMapHash,
|
|
891
|
+
expectedNext: event.expectedNext,
|
|
892
|
+
delegate: event.delegate,
|
|
893
|
+
grantId: event.grantId,
|
|
894
|
+
claimTokenHash: event.claimTokenHash,
|
|
895
|
+
expiresAt: event.expiresAt,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
function buildTaskHandoffPrompt(task, expectedNext, delegate) {
|
|
899
|
+
return [
|
|
900
|
+
`You are ${delegate}, the claimed executor for Task ${task.id}.`,
|
|
901
|
+
`Work only from Task revision ${task.revision} and Plan ${task.planHash ?? '<unset>'}.`,
|
|
902
|
+
`Expected next action: ${expectedNext}.`,
|
|
903
|
+
'Use the claim-bound writer lease for every mutation.',
|
|
904
|
+
'On completion, create a handback containing terminal status, result/evidence bindings, limitations, and recommended next action.',
|
|
905
|
+
].join('\n');
|
|
906
|
+
}
|
|
907
|
+
function assertNonEmptyStringField(value, message, context) {
|
|
908
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
909
|
+
throw new WorkflowError('STATE_CORRUPT', message, context);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
function assertStateCorruptHash64(value, message, context) {
|
|
913
|
+
if (!/^[0-9a-f]{64}$/u.test(value)) {
|
|
914
|
+
throw new WorkflowError('STATE_CORRUPT', message, context);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function assertNullablePositiveInteger(value, message, context) {
|
|
918
|
+
if (value !== null && (!Number.isInteger(value) || Number(value) < 1)) {
|
|
919
|
+
throw new WorkflowError('STATE_CORRUPT', message, { ...context, value });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
function validateHandbackRecord(record, task, file, line) {
|
|
923
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
924
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handback bundle must be an object.', { file, line, taskId: task.id });
|
|
925
|
+
}
|
|
926
|
+
const allowedKeys = new Set([
|
|
927
|
+
'status', 'mergedCommit', 'planHash', 'resultHash', 'evidenceHash', 'reviewAttestationHash',
|
|
928
|
+
'knowledgeMapRevision', 'knowledgeMapHash', 'limitations', 'recommendedNext',
|
|
929
|
+
]);
|
|
930
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
931
|
+
if (extraKeys.length > 0 || (record.status !== 'merged' && record.status !== 'cancelled')) {
|
|
932
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handback bundle has invalid shape or status.', {
|
|
933
|
+
file, line, taskId: task.id, extraKeys, status: record.status,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
if (record.mergedCommit !== null && !/^[0-9a-f]{40,64}$/u.test(record.mergedCommit)) {
|
|
937
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handback mergedCommit is invalid.', {
|
|
938
|
+
file, line, taskId: task.id, mergedCommit: record.mergedCommit,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
for (const [label, value] of [
|
|
942
|
+
['planHash', record.planHash],
|
|
943
|
+
['resultHash', record.resultHash],
|
|
944
|
+
['evidenceHash', record.evidenceHash],
|
|
945
|
+
['reviewAttestationHash', record.reviewAttestationHash],
|
|
946
|
+
['knowledgeMapHash', record.knowledgeMapHash],
|
|
947
|
+
]) {
|
|
948
|
+
if (value !== null)
|
|
949
|
+
assertStateCorruptHash64(value, `Task handback ${label} is invalid.`, { file, line, taskId: task.id });
|
|
950
|
+
}
|
|
951
|
+
assertNullablePositiveInteger(record.knowledgeMapRevision, 'Task handback knowledgeMapRevision is invalid.', { file, line, taskId: task.id });
|
|
952
|
+
if (!Array.isArray(record.limitations) || record.limitations.some((value) => typeof value !== 'string')) {
|
|
953
|
+
throw new WorkflowError('STATE_CORRUPT', 'Task handback limitations must be a string array.', { file, line, taskId: task.id });
|
|
954
|
+
}
|
|
955
|
+
assertNonEmptyStringField(record.recommendedNext, 'Task handback recommendedNext is required.', { file, line, taskId: task.id });
|
|
956
|
+
}
|
|
957
|
+
function resolveAlpha6TaskRoot(store, task) {
|
|
958
|
+
const projectRootLogical = store.projectRoot(task.projectId);
|
|
959
|
+
const trustedProjectRoot = existsSync(projectRootLogical) ? realpathSync(projectRootLogical) : path.resolve(projectRootLogical);
|
|
960
|
+
const tasksDirLogical = path.join(projectRootLogical, 'tasks');
|
|
961
|
+
const trustedTasksDir = existsSync(tasksDirLogical)
|
|
962
|
+
? realpathSync(tasksDirLogical)
|
|
963
|
+
: path.join(trustedProjectRoot, 'tasks');
|
|
964
|
+
const taskRootLogical = store.taskRoot(task.projectId, task.id);
|
|
965
|
+
assertNoSymlinkPathComponents(trustedTasksDir, { label: 'Trusted tasks directory', requireLeaf: true });
|
|
966
|
+
if (existsSync(taskRootLogical) && lstatSync(taskRootLogical).isSymbolicLink()) {
|
|
967
|
+
throw new WorkflowError('STATE_CORRUPT', `Task root must not be a symlink: ${taskRootLogical}`);
|
|
968
|
+
}
|
|
969
|
+
const resolvedTaskRoot = existsSync(taskRootLogical)
|
|
970
|
+
? realpathSync(taskRootLogical)
|
|
971
|
+
: path.resolve(trustedTasksDir, task.id);
|
|
972
|
+
if (resolvedTaskRoot !== trustedTasksDir
|
|
973
|
+
&& !resolvedTaskRoot.startsWith(`${trustedTasksDir}${path.sep}`)) {
|
|
974
|
+
throw new WorkflowError('STATE_CORRUPT', `Task root escaped the trusted tasks directory: ${taskRootLogical}`, {
|
|
975
|
+
trustedTasksDir,
|
|
976
|
+
resolvedTaskRoot,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
return resolvedTaskRoot;
|
|
980
|
+
}
|
|
981
|
+
//# sourceMappingURL=handoff.js.map
|