@hypercarrier/pi-team-durable 0.0.0-stage → 0.1.0-rc.2

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -2
  3. package/dist/authority/anchor.d.ts +10 -0
  4. package/dist/authority/anchor.js +19 -0
  5. package/dist/authority/card-types.d.ts +152 -0
  6. package/dist/authority/card-types.js +123 -0
  7. package/dist/authority/contracts.d.ts +19 -0
  8. package/dist/authority/contracts.js +45 -0
  9. package/dist/authority/delivery.d.ts +60 -0
  10. package/dist/authority/delivery.js +158 -0
  11. package/dist/authority/graph-card.d.ts +4 -0
  12. package/dist/authority/graph-card.js +39 -0
  13. package/dist/authority/graph-control.d.ts +299 -0
  14. package/dist/authority/graph-control.js +1009 -0
  15. package/dist/authority/incremental-graph-control.d.ts +156 -0
  16. package/dist/authority/incremental-graph-control.js +1051 -0
  17. package/dist/authority/index.d.ts +15 -0
  18. package/dist/authority/index.js +15 -0
  19. package/dist/authority/interrupt.d.ts +11 -0
  20. package/dist/authority/interrupt.js +26 -0
  21. package/dist/authority/limits.d.ts +7 -0
  22. package/dist/authority/limits.js +7 -0
  23. package/dist/authority/notifier.d.ts +32 -0
  24. package/dist/authority/notifier.js +89 -0
  25. package/dist/authority/observation-store.d.ts +104 -0
  26. package/dist/authority/observation-store.js +226 -0
  27. package/dist/authority/observation.d.ts +13 -0
  28. package/dist/authority/observation.js +143 -0
  29. package/dist/authority/panel.d.ts +121 -0
  30. package/dist/authority/panel.js +44 -0
  31. package/dist/authority/projection.d.ts +5 -0
  32. package/dist/authority/projection.js +15 -0
  33. package/dist/authority/rc7-contract.json +9390 -0
  34. package/dist/authority/runtime.d.ts +106 -0
  35. package/dist/authority/runtime.js +115 -0
  36. package/dist/authority/state.d.ts +165 -0
  37. package/dist/authority/state.js +28 -0
  38. package/dist/authority/store.d.ts +144 -0
  39. package/dist/authority/store.js +675 -0
  40. package/dist/authority/sync.d.ts +13 -0
  41. package/dist/authority/sync.js +78 -0
  42. package/dist/authority/task-version-ref.d.ts +3 -0
  43. package/dist/authority/task-version-ref.js +5 -0
  44. package/dist/authority/tools.d.ts +105 -0
  45. package/dist/authority/tools.js +302 -0
  46. package/dist/testing.d.ts +11 -0
  47. package/dist/testing.js +10 -0
  48. package/package.json +63 -4
@@ -0,0 +1,1009 @@
1
+ // Ported through the durable prototype; verified against Pi Team Bright 0.24.0-rc.7 (MIT).
2
+ // Changes: injected clock, local imports, explicit constructor fields.
3
+ import { createHash } from "node:crypto";
4
+ import { taskVersionRef } from "./task-version-ref.js";
5
+ /**
6
+ * Executable first contract for graph-native Task control.
7
+ *
8
+ * The upstream transition design lives in Pi Team Bright's
9
+ * docs/projects/dag-native/graph-control-transition-spec.json.
10
+ * This module has no Beads, Pi, delivery, or presentation dependency.
11
+ */
12
+ export const GRAPH_CONTROL_MAX_FAILURE_TRAVERSALS = 8;
13
+ export const GRAPH_CONTROL_TASK_STATES = [
14
+ "dependency_waiting",
15
+ "ready",
16
+ "in_progress",
17
+ "blocked",
18
+ "goal_failed",
19
+ "goal_achieved",
20
+ "cancelled",
21
+ ];
22
+ export class GraphControlRefusal extends Error {
23
+ code;
24
+ constructor(code, message) {
25
+ super(message);
26
+ this.code = code;
27
+ this.name = "GraphControlRefusal";
28
+ }
29
+ }
30
+ function canonical(value) {
31
+ if (Array.isArray(value))
32
+ return value.map(canonical);
33
+ if (!value || typeof value !== "object")
34
+ return value;
35
+ return Object.fromEntries(Object.entries(value)
36
+ .sort(([left], [right]) => left.localeCompare(right))
37
+ .map(([key, nested]) => [key, canonical(nested)]));
38
+ }
39
+ function digest(value) {
40
+ return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
41
+ }
42
+ function clone(value) {
43
+ return structuredClone(value);
44
+ }
45
+ function requireText(value, label) {
46
+ if (!value?.trim())
47
+ throw new GraphControlRefusal("evidence_required", `${label} must not be empty.`);
48
+ return value;
49
+ }
50
+ function graphVersion(value) {
51
+ return `g_${digest(value).slice(0, 16)}`;
52
+ }
53
+ function semanticDefinition(definition) {
54
+ return {
55
+ key: definition.key,
56
+ title: definition.title,
57
+ goal: definition.goal,
58
+ assignee: definition.assignee,
59
+ needs: definition.needs,
60
+ onGoalFailed: definition.onGoalFailed ?? null,
61
+ };
62
+ }
63
+ /**
64
+ * Small event-backed reference authority for deterministic Task graph control.
65
+ * External work is a transition command; all graph movement runs synchronously
66
+ * before that command returns.
67
+ */
68
+ export class GraphTaskController {
69
+ graphRevisions;
70
+ events;
71
+ receipts;
72
+ commandRecordedAt;
73
+ derivedCache;
74
+ clock;
75
+ constructor(snapshot, clock = () => new Date()) {
76
+ this.clock = clock;
77
+ if (snapshot && snapshot.schema !== "pi-team-bright-graph-control/1") {
78
+ throw new GraphControlRefusal("invalid_graph", `Unsupported snapshot schema ${String(snapshot.schema)}.`);
79
+ }
80
+ this.graphRevisions = clone(snapshot?.graphRevisions ?? []);
81
+ this.events = clone(snapshot?.events ?? []);
82
+ this.receipts = new Map((snapshot?.receipts ?? []).map(receipt => [receipt.operationId, clone(receipt)]));
83
+ }
84
+ static recover(snapshot, clock) {
85
+ try {
86
+ validateSnapshot(snapshot, 0, validateGraphRevision);
87
+ }
88
+ catch (error) {
89
+ if (error instanceof GraphControlRefusal)
90
+ throw error;
91
+ throw new GraphControlRefusal("invalid_graph", `Graph authority snapshot is malformed: ${error instanceof Error ? error.message : String(error)}`);
92
+ }
93
+ return new GraphTaskController(snapshot, clock);
94
+ }
95
+ snapshot() {
96
+ return {
97
+ schema: "pi-team-bright-graph-control/1",
98
+ graphRevisions: clone(this.graphRevisions),
99
+ events: clone(this.events),
100
+ receipts: [...this.receipts.values()].map(clone),
101
+ };
102
+ }
103
+ currentGraphVersion() {
104
+ return this.currentRevision()?.version;
105
+ }
106
+ hasOperationReceipt(operationId) {
107
+ return this.receipts.has(operationId);
108
+ }
109
+ applyGraph(input) {
110
+ return this.command(input.operationId, { command: "apply_graph", ...input }, () => {
111
+ const priorRevision = this.currentRevision();
112
+ if (!priorRevision && input.expectedGraphVersion !== undefined) {
113
+ throw new GraphControlRefusal("graph_version_conflict", "The first graph apply must not include an expected version.");
114
+ }
115
+ if (priorRevision && input.expectedGraphVersion !== priorRevision.version) {
116
+ throw new GraphControlRefusal("graph_version_conflict", `Graph changed; expected ${String(input.expectedGraphVersion)}, current ${priorRevision.version}.`);
117
+ }
118
+ const normalized = normalizeAndValidate(input.tasks);
119
+ const priorById = new Map((priorRevision?.tasks ?? []).map(task => [task.key, task]));
120
+ const revisionSequence = this.nextSequence();
121
+ const tasks = normalized.map(definition => {
122
+ const prior = priorById.get(definition.key);
123
+ const sameMeaning = prior && digest(semanticDefinition(prior)) === digest(semanticDefinition(definition));
124
+ return {
125
+ ...definition,
126
+ lineage: sameMeaning
127
+ ? prior.lineage
128
+ : digest({ task: semanticDefinition(definition), introducedAt: revisionSequence }),
129
+ };
130
+ });
131
+ const version = graphVersion({ previous: priorRevision?.version ?? null, tasks });
132
+ const attemptsBefore = this.readAttempts();
133
+ const activeBefore = new Map(attemptsBefore
134
+ .filter(attempt => attempt.state === "in_progress" || attempt.state === "blocked")
135
+ .map(attempt => [attempt.taskId, attempt.id]));
136
+ const nextById = new Map(tasks.map(task => [task.key, task]));
137
+ const directlyChanged = new Set();
138
+ for (const [taskId, prior] of priorById) {
139
+ if (nextById.get(taskId)?.lineage !== prior.lineage)
140
+ directlyChanged.add(taskId);
141
+ }
142
+ for (const [taskId, next] of nextById) {
143
+ if (priorById.get(taskId)?.lineage !== next.lineage)
144
+ directlyChanged.add(taskId);
145
+ }
146
+ const affected = new Set([
147
+ ...reverseClosure([...directlyChanged], priorRevision?.tasks ?? []),
148
+ ...reverseClosure([...directlyChanged], tasks),
149
+ ]);
150
+ this.graphRevisions.push({
151
+ sequence: revisionSequence,
152
+ version,
153
+ tasks,
154
+ ...(this.commandRecordedAt ? { recorded_at: this.commandRecordedAt } : {}),
155
+ });
156
+ for (const taskId of [...affected].sort()) {
157
+ const attemptId = activeBefore.get(taskId)
158
+ ?? attemptsBefore.findLast(attempt => attempt.taskId === taskId && attempt.current)?.id;
159
+ if (attemptId)
160
+ this.append({
161
+ kind: "attempt_superseded",
162
+ operationId: input.operationId,
163
+ attemptId,
164
+ reason: "graph_revised",
165
+ });
166
+ }
167
+ const currentTasks = this.readTasks();
168
+ return {
169
+ kind: "graph_applied",
170
+ operationId: input.operationId,
171
+ graphVersion: version,
172
+ tasks: currentTasks,
173
+ readyTaskIds: readyIds(currentTasks),
174
+ };
175
+ });
176
+ }
177
+ transition(input) {
178
+ return this.command(input.operationId, { command: "transition", ...input }, () => {
179
+ const task = this.readTask(input.taskId);
180
+ if (task.version !== input.expectedVersion) {
181
+ throw new GraphControlRefusal("task_version_conflict", `Task ${task.id} changed; expected ${input.expectedVersion}, current ${task.version}.`);
182
+ }
183
+ if (!input.transition && !input.currentContext?.trim()) {
184
+ throw new GraphControlRefusal("invalid_transition", "A Task command requires a transition or nonempty current context.");
185
+ }
186
+ let failureTraversal;
187
+ switch (input.transition) {
188
+ case "claim":
189
+ this.claim(task, input);
190
+ break;
191
+ case "block":
192
+ this.block(task, input);
193
+ break;
194
+ case "resume":
195
+ this.resume(task, input);
196
+ break;
197
+ case "goal_achieved":
198
+ case "goal_failed":
199
+ failureTraversal = this.complete(task, input);
200
+ break;
201
+ case "cancel":
202
+ this.cancel(task, input);
203
+ break;
204
+ }
205
+ if (input.currentContext?.trim()) {
206
+ this.append({
207
+ kind: "task_context_updated",
208
+ operationId: input.operationId,
209
+ taskId: task.id,
210
+ taskLineage: this.definition(task.id).lineage,
211
+ currentContext: input.currentContext,
212
+ });
213
+ }
214
+ const tasks = this.readTasks();
215
+ return {
216
+ kind: "task_transitioned",
217
+ operationId: input.operationId,
218
+ transition: input.transition ?? "context_updated",
219
+ task: tasks.find(candidate => candidate.id === input.taskId),
220
+ readyTaskIds: readyIds(tasks),
221
+ ...(failureTraversal ? { failureTraversal } : {}),
222
+ };
223
+ });
224
+ }
225
+ readTask(taskId) {
226
+ const derived = this.derivedGraph().get(taskId);
227
+ if (!derived)
228
+ throw new GraphControlRefusal("task_not_found", `Task ${taskId} is not in the current graph.`);
229
+ return this.toView(derived);
230
+ }
231
+ readTasks() {
232
+ return [...this.derivedGraph().values()].map(task => this.toView(task)).sort((left, right) => left.id.localeCompare(right.id));
233
+ }
234
+ readAttempts(taskId) {
235
+ const currentTaskIds = new Set(this.currentRevision()?.tasks.map(task => task.key) ?? []);
236
+ const currentAttemptIds = new Set(this.readTasks().flatMap(task => {
237
+ if (task.state.kind === "in_progress" || task.state.kind === "blocked" || task.state.kind === "goal_achieved") {
238
+ return [task.state.attemptId];
239
+ }
240
+ if (task.state.kind === "goal_failed" && "attemptId" in task.state)
241
+ return [task.state.attemptId];
242
+ return [];
243
+ }));
244
+ return [...this.attemptFolds().values()]
245
+ .filter(attempt => !taskId || attempt.started.taskId === taskId)
246
+ .sort((left, right) => left.started.sequence - right.started.sequence)
247
+ .map(attempt => {
248
+ const blocked = attempt.blocked && (!attempt.resumed || attempt.blocked.sequence > attempt.resumed.sequence);
249
+ const staleLineage = !currentTaskIds.has(attempt.started.taskId)
250
+ || this.definition(attempt.started.taskId).lineage !== attempt.started.taskLineage;
251
+ const state = attempt.completed
252
+ ? "completed"
253
+ : attempt.cancellation
254
+ ? "cancelled"
255
+ : attempt.superseded || staleLineage
256
+ ? "superseded"
257
+ : blocked
258
+ ? "blocked"
259
+ : "in_progress";
260
+ return {
261
+ id: attempt.started.attemptId,
262
+ taskId: attempt.started.taskId,
263
+ ordinal: attempt.started.ordinal,
264
+ graphVersion: attempt.started.graphVersion,
265
+ taskLineage: attempt.started.taskLineage,
266
+ activationKey: attempt.started.activationKey,
267
+ inputAttemptIds: clone(attempt.started.inputAttemptIds),
268
+ assignee: attempt.started.assignee,
269
+ state,
270
+ current: currentAttemptIds.has(attempt.started.attemptId),
271
+ ...(attempt.completed ? { outcome: attempt.completed.outcome, evidence: attempt.completed.evidence } : {}),
272
+ ...(blocked ? { evidence: attempt.blocked.evidence } : {}),
273
+ };
274
+ });
275
+ }
276
+ selectReadyFrontier() {
277
+ const occupied = new Set(this.readTasks().flatMap(task => task.state.kind === "in_progress" ? [task.assignee] : []));
278
+ const selected = [];
279
+ for (const task of this.readTasks()) {
280
+ if (task.state.kind !== "ready" || occupied.has(task.assignee))
281
+ continue;
282
+ selected.push(task);
283
+ occupied.add(task.assignee);
284
+ }
285
+ return selected;
286
+ }
287
+ trace() {
288
+ return {
289
+ graphRevisions: clone(this.graphRevisions),
290
+ tasks: this.readTasks(),
291
+ attempts: this.readAttempts(),
292
+ events: clone(this.events),
293
+ };
294
+ }
295
+ claim(task, input) {
296
+ if (task.state.kind !== "ready")
297
+ this.invalid(task, "claim");
298
+ this.assertWorker(task, input);
299
+ const occupied = this.readTasks().find(candidate => candidate.assignee === task.assignee && candidate.state.kind === "in_progress");
300
+ if (occupied) {
301
+ throw new GraphControlRefusal("worker_occupied", `Worker ${task.assignee} already has in-progress Task ${occupied.id}.`);
302
+ }
303
+ const derived = this.derivedGraph().get(task.id);
304
+ const revision = this.currentRevision();
305
+ const ordinal = derived.attemptsStarted + 1;
306
+ this.append({
307
+ kind: "attempt_started",
308
+ operationId: input.operationId,
309
+ attemptId: `${task.id}@${ordinal}`,
310
+ taskId: task.id,
311
+ ordinal,
312
+ graphVersion: revision.version,
313
+ taskLineage: derived.definition.lineage,
314
+ activationKey: derived.activationKey,
315
+ inputAttemptIds: clone(derived.inputAttemptIds),
316
+ assignee: task.assignee,
317
+ });
318
+ }
319
+ block(task, input) {
320
+ if (task.state.kind !== "in_progress")
321
+ this.invalid(task, "block");
322
+ this.assertWorker(task, input);
323
+ this.append({
324
+ kind: "attempt_blocked",
325
+ operationId: input.operationId,
326
+ attemptId: task.state.attemptId,
327
+ evidence: requireText(input.evidence, "Blocker evidence"),
328
+ });
329
+ }
330
+ resume(task, input) {
331
+ if (task.state.kind !== "blocked")
332
+ this.invalid(task, "resume");
333
+ this.assertWorker(task, input);
334
+ const occupied = this.readTasks().find(candidate => candidate.assignee === task.assignee && candidate.state.kind === "in_progress");
335
+ if (occupied) {
336
+ throw new GraphControlRefusal("worker_occupied", `Worker ${task.assignee} already has in-progress Task ${occupied.id}.`);
337
+ }
338
+ this.append({
339
+ kind: "attempt_resumed",
340
+ operationId: input.operationId,
341
+ attemptId: task.state.attemptId,
342
+ });
343
+ }
344
+ complete(task, input) {
345
+ if (task.state.kind !== "in_progress")
346
+ this.invalid(task, input.transition ?? "complete");
347
+ this.assertWorker(task, input);
348
+ const evidence = requireText(input.evidence, "Goal outcome evidence");
349
+ this.append({
350
+ kind: "attempt_completed",
351
+ operationId: input.operationId,
352
+ attemptId: task.state.attemptId,
353
+ outcome: input.transition,
354
+ evidence,
355
+ });
356
+ if (input.transition === "goal_achieved")
357
+ return undefined;
358
+ const definition = this.definition(task.id);
359
+ const edge = definition.onGoalFailed;
360
+ if (!edge)
361
+ return undefined;
362
+ const priorTraversals = this.events.filter((event) => event.kind === "failure_edge_traversed"
363
+ && event.sourceTaskId === task.id
364
+ && event.sourceTaskLineage === definition.lineage).length;
365
+ if (priorTraversals >= edge.maxTraversals) {
366
+ this.append({
367
+ kind: "failure_edge_exhausted",
368
+ operationId: input.operationId,
369
+ sourceTaskId: task.id,
370
+ sourceTaskLineage: definition.lineage,
371
+ sourceAttemptId: task.state.attemptId,
372
+ targetTaskId: edge.target,
373
+ traversals: priorTraversals,
374
+ reason: "limit_reached",
375
+ });
376
+ return undefined;
377
+ }
378
+ const target = this.definition(edge.target);
379
+ const targetCancelled = this.events.findLast((event) => event.kind === "task_cancelled"
380
+ && event.taskId === edge.target
381
+ && event.taskLineage === target.lineage);
382
+ if (targetCancelled) {
383
+ this.append({
384
+ kind: "failure_edge_exhausted",
385
+ operationId: input.operationId,
386
+ sourceTaskId: task.id,
387
+ sourceTaskLineage: definition.lineage,
388
+ sourceAttemptId: task.state.attemptId,
389
+ targetTaskId: edge.target,
390
+ traversals: priorTraversals,
391
+ reason: "target_cancelled",
392
+ });
393
+ return undefined;
394
+ }
395
+ const attemptsBefore = this.readAttempts();
396
+ const activeBefore = new Map(attemptsBefore
397
+ .filter(attempt => attempt.state === "in_progress" || attempt.state === "blocked")
398
+ .map(attempt => [attempt.taskId, attempt.id]));
399
+ const traversal = priorTraversals + 1;
400
+ this.append({
401
+ kind: "failure_edge_traversed",
402
+ operationId: input.operationId,
403
+ sourceTaskId: task.id,
404
+ sourceTaskLineage: definition.lineage,
405
+ sourceAttemptId: task.state.attemptId,
406
+ targetTaskId: edge.target,
407
+ targetTaskLineage: target.lineage,
408
+ traversal,
409
+ });
410
+ for (const affectedTaskId of reverseClosure([edge.target], this.currentRevision().tasks)) {
411
+ const attemptId = activeBefore.get(affectedTaskId)
412
+ ?? attemptsBefore.findLast(attempt => attempt.taskId === affectedTaskId && attempt.current)?.id;
413
+ if (attemptId && attemptId !== task.state.attemptId)
414
+ this.append({
415
+ kind: "attempt_superseded",
416
+ operationId: input.operationId,
417
+ attemptId,
418
+ reason: "repair_requested",
419
+ });
420
+ }
421
+ return { sourceTaskId: task.id, targetTaskId: edge.target, traversal };
422
+ }
423
+ cancel(task, input) {
424
+ if (!["dependency_waiting", "ready", "in_progress", "blocked"].includes(task.state.kind))
425
+ this.invalid(task, "cancel");
426
+ if (input.worker !== undefined)
427
+ this.assertWorker(task, input);
428
+ const reason = requireText(input.evidence, "Cancellation reason");
429
+ const attemptId = task.state.kind === "in_progress" || task.state.kind === "blocked" ? task.state.attemptId : undefined;
430
+ const activeBefore = new Map(this.readAttempts()
431
+ .filter(attempt => attempt.state === "in_progress" || attempt.state === "blocked")
432
+ .map(attempt => [attempt.taskId, attempt.id]));
433
+ this.append({
434
+ kind: "task_cancelled",
435
+ operationId: input.operationId,
436
+ taskId: task.id,
437
+ taskLineage: this.definition(task.id).lineage,
438
+ ...(attemptId ? { attemptId } : {}),
439
+ reason,
440
+ });
441
+ for (const affectedTaskId of reverseClosure([task.id], this.currentRevision().tasks)) {
442
+ const affectedAttemptId = activeBefore.get(affectedTaskId);
443
+ if (affectedAttemptId && affectedAttemptId !== attemptId)
444
+ this.append({
445
+ kind: "attempt_superseded",
446
+ operationId: input.operationId,
447
+ attemptId: affectedAttemptId,
448
+ reason: "dependency_cancelled",
449
+ });
450
+ }
451
+ }
452
+ assertWorker(task, input) {
453
+ if (input.worker !== task.assignee) {
454
+ throw new GraphControlRefusal("worker_mismatch", `Task ${task.id} is assigned to ${task.assignee}, not ${String(input.worker)}.`);
455
+ }
456
+ if (task.state.kind === "in_progress" || task.state.kind === "blocked") {
457
+ const attemptId = task.state.attemptId;
458
+ const attempt = this.readAttempts(task.id).find(candidate => candidate.id === attemptId);
459
+ if (attempt?.assignee !== input.worker) {
460
+ throw new GraphControlRefusal("worker_mismatch", `Attempt ${attemptId} belongs to Worker ${String(attempt?.assignee)}.`);
461
+ }
462
+ }
463
+ }
464
+ invalid(task, transition) {
465
+ throw new GraphControlRefusal("invalid_transition", `Task ${task.id} is ${task.state.kind}; ${transition} is not valid.`);
466
+ }
467
+ command(operationId, semantics, apply) {
468
+ if (!operationId.trim())
469
+ throw new GraphControlRefusal("operation_conflict", "Operation identity must not be empty.");
470
+ const fingerprint = digest(semantics);
471
+ const prior = this.receipts.get(operationId);
472
+ if (prior) {
473
+ if (prior.fingerprint !== fingerprint) {
474
+ throw new GraphControlRefusal("operation_conflict", `Operation ${operationId} was already used with different semantics.`);
475
+ }
476
+ try {
477
+ validateReceiptProjection(this.snapshot(), prior);
478
+ }
479
+ catch (error) {
480
+ if (error instanceof GraphControlRefusal)
481
+ throw error;
482
+ throw new GraphControlRefusal("invalid_graph", `Operation ${operationId} receipt is malformed: ${error instanceof Error ? error.message : String(error)}`);
483
+ }
484
+ return { ...clone(prior.result), replayed: true };
485
+ }
486
+ const graphLength = this.graphRevisions.length;
487
+ const eventLength = this.events.length;
488
+ const receiptSize = this.receipts.size;
489
+ try {
490
+ this.commandRecordedAt = this.clock().toISOString();
491
+ const result = apply();
492
+ this.receipts.set(operationId, { operationId, fingerprint, result: clone(result) });
493
+ return { ...clone(result), replayed: false };
494
+ }
495
+ catch (error) {
496
+ this.graphRevisions.length = graphLength;
497
+ this.events.length = eventLength;
498
+ if (this.receipts.size !== receiptSize)
499
+ this.receipts.delete(operationId);
500
+ throw error;
501
+ }
502
+ finally {
503
+ this.commandRecordedAt = undefined;
504
+ }
505
+ }
506
+ append(event) {
507
+ this.events.push({
508
+ ...event,
509
+ sequence: this.nextSequence(),
510
+ ...(this.commandRecordedAt ? { recorded_at: this.commandRecordedAt } : {}),
511
+ });
512
+ }
513
+ nextSequence() {
514
+ return Math.max(0, ...this.graphRevisions.map(revision => revision.sequence), ...this.events.map(event => event.sequence)) + 1;
515
+ }
516
+ currentRevision() {
517
+ return this.graphRevisions.at(-1);
518
+ }
519
+ definition(taskId) {
520
+ const definition = this.currentRevision()?.tasks.find(task => task.key === taskId);
521
+ if (!definition)
522
+ throw new GraphControlRefusal("task_not_found", `Task ${taskId} is not in the current graph.`);
523
+ return definition;
524
+ }
525
+ attemptFolds() {
526
+ const attempts = new Map();
527
+ for (const event of this.events) {
528
+ if (event.kind === "attempt_started")
529
+ attempts.set(event.attemptId, { started: event });
530
+ else if ("attemptId" in event && event.attemptId) {
531
+ const attempt = attempts.get(event.attemptId);
532
+ if (!attempt)
533
+ continue;
534
+ if (event.kind === "attempt_blocked")
535
+ attempt.blocked = event;
536
+ else if (event.kind === "attempt_resumed")
537
+ attempt.resumed = event;
538
+ else if (event.kind === "attempt_completed")
539
+ attempt.completed = event;
540
+ else if (event.kind === "attempt_superseded")
541
+ attempt.superseded = event;
542
+ else if (event.kind === "task_cancelled")
543
+ attempt.cancellation = event;
544
+ }
545
+ }
546
+ return attempts;
547
+ }
548
+ // Revisions and events are append-only. Their exact tail identities also fence
549
+ // rollback, so a failed command cannot leave a reusable speculative derivation.
550
+ derivedGraph() {
551
+ const revision = this.currentRevision();
552
+ const event = this.events.at(-1);
553
+ if (this.derivedCache && this.derivedCache.revision === revision && this.derivedCache.event === event)
554
+ return this.derivedCache.value;
555
+ const value = this.derive();
556
+ this.derivedCache = { revision, event, value };
557
+ return value;
558
+ }
559
+ derive() {
560
+ const revision = this.currentRevision();
561
+ if (!revision)
562
+ return new Map();
563
+ const order = topologicalOrder(revision.tasks);
564
+ const attempts = [...this.attemptFolds().values()];
565
+ const result = new Map();
566
+ for (const taskId of order) {
567
+ const definition = revision.tasks.find(task => task.key === taskId);
568
+ const taskAttempts = attempts.filter(attempt => attempt.started.taskId === taskId);
569
+ const cancellation = this.events.findLast((event) => event.kind === "task_cancelled"
570
+ && event.taskId === taskId
571
+ && event.taskLineage === definition.lineage);
572
+ const failedPrerequisites = [];
573
+ const cancelledPrerequisites = [];
574
+ const waitingPrerequisites = [];
575
+ const inputAttempts = [];
576
+ for (const prerequisiteId of definition.needs) {
577
+ const prerequisite = result.get(prerequisiteId);
578
+ if (prerequisite.state.kind === "goal_achieved") {
579
+ inputAttempts.push([prerequisiteId, prerequisite.state.attemptId]);
580
+ }
581
+ else if (prerequisite.state.kind === "cancelled") {
582
+ cancelledPrerequisites.push(prerequisiteId);
583
+ }
584
+ else if (prerequisite.state.kind === "goal_failed") {
585
+ failedPrerequisites.push(prerequisiteId);
586
+ }
587
+ else {
588
+ waitingPrerequisites.push(prerequisiteId);
589
+ }
590
+ }
591
+ const inputAttemptIds = Object.fromEntries(inputAttempts);
592
+ const demand = this.events.filter((event) => event.kind === "failure_edge_traversed"
593
+ && event.targetTaskId === taskId
594
+ && event.targetTaskLineage === definition.lineage).length;
595
+ let state;
596
+ let activationKey;
597
+ let acceptedAttemptId;
598
+ if (cancellation) {
599
+ state = { kind: "cancelled", reason: cancellation.reason };
600
+ }
601
+ else if (cancelledPrerequisites.length) {
602
+ state = { kind: "goal_failed", reason: "dependency_cancelled", prerequisiteTaskIds: cancelledPrerequisites.sort() };
603
+ }
604
+ else if (failedPrerequisites.length) {
605
+ state = { kind: "goal_failed", reason: "dependency_failed", prerequisiteTaskIds: failedPrerequisites.sort() };
606
+ }
607
+ else if (waitingPrerequisites.length) {
608
+ state = { kind: "dependency_waiting", prerequisiteTaskIds: waitingPrerequisites.sort() };
609
+ }
610
+ else {
611
+ activationKey = digest({ taskLineage: definition.lineage, demand, inputAttemptIds });
612
+ const current = taskAttempts.findLast(attempt => attempt.started.taskLineage === definition.lineage
613
+ && attempt.started.activationKey === activationKey
614
+ && !attempt.superseded
615
+ && !attempt.cancellation);
616
+ if (!current) {
617
+ state = { kind: "ready" };
618
+ }
619
+ else if (!current.completed) {
620
+ const blocked = current.blocked && (!current.resumed || current.blocked.sequence > current.resumed.sequence);
621
+ state = blocked
622
+ ? { kind: "blocked", attemptId: current.started.attemptId, evidence: current.blocked.evidence }
623
+ : { kind: "in_progress", attemptId: current.started.attemptId };
624
+ }
625
+ else if (current.completed.outcome === "goal_achieved") {
626
+ acceptedAttemptId = current.started.attemptId;
627
+ state = { kind: "goal_achieved", attemptId: acceptedAttemptId };
628
+ }
629
+ else {
630
+ const exhausted = this.events.findLast((event) => event.kind === "failure_edge_exhausted" && event.sourceAttemptId === current.started.attemptId);
631
+ state = exhausted
632
+ ? {
633
+ kind: "goal_failed",
634
+ reason: "failure_edge_exhausted",
635
+ attemptId: current.started.attemptId,
636
+ targetTaskId: exhausted.targetTaskId,
637
+ traversals: exhausted.traversals,
638
+ exhaustionReason: exhausted.reason,
639
+ }
640
+ : { kind: "goal_failed", reason: "criterion_failed", attemptId: current.started.attemptId };
641
+ }
642
+ }
643
+ result.set(taskId, {
644
+ definition,
645
+ state,
646
+ ...(activationKey ? { activationKey } : {}),
647
+ inputAttemptIds,
648
+ ...(acceptedAttemptId ? { acceptedAttemptId } : {}),
649
+ attemptsStarted: taskAttempts.length,
650
+ });
651
+ }
652
+ return result;
653
+ }
654
+ toView(task) {
655
+ const value = {
656
+ id: task.definition.key,
657
+ title: task.definition.title,
658
+ goal: task.definition.goal,
659
+ assignee: task.definition.assignee,
660
+ needs: clone(task.definition.needs),
661
+ ...(task.definition.onGoalFailed ? { onGoalFailed: clone(task.definition.onGoalFailed) } : {}),
662
+ state: clone(task.state),
663
+ currentContext: this.events.findLast((event) => event.kind === "task_context_updated"
664
+ && event.taskId === task.definition.key
665
+ && event.taskLineage === task.definition.lineage)?.currentContext ?? "Work has not started.",
666
+ ...(task.activationKey ? { activationKey: task.activationKey } : {}),
667
+ ...(task.acceptedAttemptId ? { acceptedAttemptId: task.acceptedAttemptId } : {}),
668
+ attemptsStarted: task.attemptsStarted,
669
+ };
670
+ return { ...value, version: taskVersionRef(JSON.stringify(canonical(value))) };
671
+ }
672
+ }
673
+ /** Read-only recovery owns revision reuse. Commit recovery always validates fully.
674
+ * Retain fingerprints only; changed prefixes and failures require full validation.
675
+ */
676
+ export class GraphObservationRecovery {
677
+ accepted;
678
+ recover(snapshot) {
679
+ try {
680
+ let fingerprints;
681
+ if (Array.isArray(snapshot?.graphRevisions)) {
682
+ try {
683
+ fingerprints = snapshot.graphRevisions.map(revision => this.revisionFingerprint(revision));
684
+ }
685
+ catch {
686
+ this.accepted = undefined;
687
+ }
688
+ }
689
+ const prior = this.accepted;
690
+ const unchangedPrefix = fingerprints && prior && fingerprints.length >= prior.length
691
+ && prior.every((fingerprint, index) => fingerprint === fingerprints[index]);
692
+ validateSnapshot(snapshot, unchangedPrefix ? prior.length : 0, (revision, previous) => this.validateRevision(revision, previous));
693
+ const controller = new GraphTaskController(snapshot);
694
+ this.accepted = fingerprints;
695
+ return {
696
+ readTasks: () => controller.readTasks(),
697
+ readAttempts: () => controller.readAttempts(),
698
+ selectReadyFrontier: () => controller.selectReadyFrontier(),
699
+ };
700
+ }
701
+ catch (error) {
702
+ this.accepted = undefined;
703
+ if (error instanceof GraphControlRefusal)
704
+ throw error;
705
+ throw new GraphControlRefusal("invalid_graph", `Graph authority snapshot is malformed: ${error instanceof Error ? error.message : String(error)}`);
706
+ }
707
+ }
708
+ revisionFingerprint(revision) {
709
+ return createHash("sha256").update(JSON.stringify(revision) ?? "undefined").digest("hex");
710
+ }
711
+ validateRevision(revision, previousRevision) {
712
+ validateGraphRevision(revision, previousRevision);
713
+ }
714
+ }
715
+ function validateGraphRevision(revision, previousRevision) {
716
+ if (!revision || typeof revision.version !== "string" || !Array.isArray(revision.tasks)) {
717
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid graph revision.");
718
+ }
719
+ const normalized = normalizeAndValidate(revision.tasks.map((task) => ({
720
+ key: task.key,
721
+ title: task.title,
722
+ goal: task.goal,
723
+ assignee: task.assignee,
724
+ needs: task.needs,
725
+ ...(task.onGoalFailed ? { onGoalFailed: task.onGoalFailed } : {}),
726
+ })));
727
+ if (normalized.length !== revision.tasks.length
728
+ || normalized.some((task) => !revision.tasks.find((stored) => stored.key === task.key)?.lineage?.trim())) {
729
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid Task lineage.");
730
+ }
731
+ const previousTasks = new Map(previousRevision?.tasks.map((task) => [task.key, task]) ?? []);
732
+ for (const task of revision.tasks) {
733
+ const prior = previousTasks.get(task.key);
734
+ const sameMeaning = prior && digest(semanticDefinition(prior)) === digest(semanticDefinition(task));
735
+ const expectedLineage = sameMeaning
736
+ ? prior.lineage
737
+ : digest({ task: semanticDefinition(task), introducedAt: revision.sequence });
738
+ if (task.lineage !== expectedLineage) {
739
+ throw new GraphControlRefusal("invalid_graph", `Graph authority snapshot has invalid lineage for Task ${task.key}.`);
740
+ }
741
+ }
742
+ if (revision.version !== graphVersion({ previous: previousRevision?.version ?? null, tasks: revision.tasks })) {
743
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid graph version.");
744
+ }
745
+ }
746
+ function validateSnapshot(snapshot, validatedPrefixLength, validateRevision) {
747
+ if (!snapshot || snapshot.schema !== "pi-team-bright-graph-control/1"
748
+ || !Array.isArray(snapshot.graphRevisions)
749
+ || !Array.isArray(snapshot.events)
750
+ || !Array.isArray(snapshot.receipts)) {
751
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid top-level shape.");
752
+ }
753
+ const sequences = [
754
+ ...snapshot.graphRevisions.map((revision) => revision?.sequence),
755
+ ...snapshot.events.map((event) => event?.sequence),
756
+ ];
757
+ if (sequences.some((sequence) => !Number.isSafeInteger(sequence) || sequence < 1)
758
+ || new Set(sequences).size !== sequences.length) {
759
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has invalid or duplicate event sequences.");
760
+ }
761
+ if (snapshot.graphRevisions.some((revision, index) => index > 0 && revision.sequence <= snapshot.graphRevisions[index - 1].sequence)
762
+ || snapshot.events.some((event, index) => index > 0 && event.sequence <= snapshot.events[index - 1].sequence)) {
763
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has out-of-order records.");
764
+ }
765
+ if (new Set(snapshot.graphRevisions.map((revision) => revision.version)).size !== snapshot.graphRevisions.length) {
766
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot repeats a graph version.");
767
+ }
768
+ let previousRevision;
769
+ for (const [index, revision] of snapshot.graphRevisions.entries()) {
770
+ if (index >= validatedPrefixLength)
771
+ validateRevision(revision, previousRevision);
772
+ previousRevision = revision;
773
+ }
774
+ const operations = new Set();
775
+ for (const receipt of snapshot.receipts) {
776
+ if (!receipt || !receipt.operationId?.trim() || !/^[0-9a-f]{64}$/.test(receipt.fingerprint) || !receipt.result
777
+ || receipt.result.operationId !== receipt.operationId) {
778
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid operation receipt.");
779
+ }
780
+ if (operations.has(receipt.operationId)) {
781
+ throw new GraphControlRefusal("invalid_graph", `Graph authority snapshot repeats operation ${receipt.operationId}.`);
782
+ }
783
+ operations.add(receipt.operationId);
784
+ }
785
+ const attempts = new Map();
786
+ const completed = new Map();
787
+ const startsPerTask = new Map();
788
+ const traversalsPerSource = new Map();
789
+ const failureEventsPerAttempt = new Set();
790
+ for (const event of snapshot.events) {
791
+ if (!event || !event.operationId?.trim() || !operations.has(event.operationId)) {
792
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an event without an operation receipt.");
793
+ }
794
+ const revision = snapshot.graphRevisions.findLast((candidate) => candidate.sequence < event.sequence);
795
+ if (!revision)
796
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an event before the first graph revision.");
797
+ if (event.kind === "attempt_started") {
798
+ const task = revision.tasks.find((candidate) => candidate.key === event.taskId);
799
+ const nextOrdinal = (startsPerTask.get(event.taskId) ?? 0) + 1;
800
+ if (!task || task.lineage !== event.taskLineage || revision.version !== event.graphVersion
801
+ || task.assignee !== event.assignee || event.ordinal !== nextOrdinal
802
+ || event.attemptId !== `${event.taskId}@${event.ordinal}` || attempts.has(event.attemptId)
803
+ || !event.activationKey?.trim() || !event.inputAttemptIds || typeof event.inputAttemptIds !== "object"
804
+ || Array.isArray(event.inputAttemptIds)) {
805
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid Attempt start.");
806
+ }
807
+ attempts.set(event.attemptId, event);
808
+ startsPerTask.set(event.taskId, nextOrdinal);
809
+ }
810
+ else if (event.kind === "attempt_blocked" || event.kind === "attempt_resumed"
811
+ || event.kind === "attempt_completed" || event.kind === "attempt_superseded") {
812
+ if (!attempts.has(event.attemptId)) {
813
+ throw new GraphControlRefusal("invalid_graph", `Graph authority snapshot refers to missing Attempt ${event.attemptId}.`);
814
+ }
815
+ if (event.kind === "attempt_completed") {
816
+ if (completed.has(event.attemptId) || !["goal_achieved", "goal_failed"].includes(event.outcome) || !event.evidence?.trim()) {
817
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid Attempt completion.");
818
+ }
819
+ completed.set(event.attemptId, event);
820
+ }
821
+ else if ((event.kind === "attempt_blocked" || event.kind === "attempt_resumed") && completed.has(event.attemptId)) {
822
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot changes a completed Attempt.");
823
+ }
824
+ }
825
+ else if (event.kind === "task_cancelled" || event.kind === "task_context_updated") {
826
+ const task = revision.tasks.find((candidate) => candidate.key === event.taskId);
827
+ if (!task || task.lineage !== event.taskLineage
828
+ || (event.kind === "task_cancelled" && event.attemptId && attempts.get(event.attemptId)?.taskId !== event.taskId)) {
829
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an event for a missing Task lineage.");
830
+ }
831
+ }
832
+ else if (event.kind === "failure_edge_traversed" || event.kind === "failure_edge_exhausted") {
833
+ const source = revision.tasks.find((candidate) => candidate.key === event.sourceTaskId);
834
+ const completion = completed.get(event.sourceAttemptId);
835
+ if (!source || source.lineage !== event.sourceTaskLineage
836
+ || attempts.get(event.sourceAttemptId)?.taskId !== event.sourceTaskId
837
+ || completion?.outcome !== "goal_failed" || source.onGoalFailed?.target !== event.targetTaskId
838
+ || failureEventsPerAttempt.has(event.sourceAttemptId)) {
839
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid failure-edge event.");
840
+ }
841
+ failureEventsPerAttempt.add(event.sourceAttemptId);
842
+ const sourceKey = `${event.sourceTaskId}\0${event.sourceTaskLineage}`;
843
+ const traversals = traversalsPerSource.get(sourceKey) ?? 0;
844
+ if (event.kind === "failure_edge_traversed"
845
+ && (revision.tasks.find((candidate) => candidate.key === event.targetTaskId)?.lineage !== event.targetTaskLineage
846
+ || event.traversal !== traversals + 1 || event.traversal > source.onGoalFailed.maxTraversals)) {
847
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid failure traversal.");
848
+ }
849
+ if (event.kind === "failure_edge_traversed")
850
+ traversalsPerSource.set(sourceKey, event.traversal);
851
+ else if (event.traversals !== traversals || !["limit_reached", "target_cancelled"].includes(event.reason)) {
852
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an invalid failure exhaustion.");
853
+ }
854
+ }
855
+ else {
856
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has an unknown event kind.");
857
+ }
858
+ }
859
+ }
860
+ function validateReceiptProjection(snapshot, receipt) {
861
+ const result = receipt.result;
862
+ const operationEvents = snapshot.events.filter((event) => event.operationId === receipt.operationId);
863
+ const appliedRevision = result.kind === "graph_applied"
864
+ ? snapshot.graphRevisions.find((revision) => revision.version === result.graphVersion)
865
+ : undefined;
866
+ const lastSequence = Math.max(appliedRevision?.sequence ?? 0, ...operationEvents.map((event) => event.sequence));
867
+ if (!lastSequence || (result.kind !== "graph_applied" && result.kind !== "task_transitioned")
868
+ || (result.kind === "graph_applied" && !appliedRevision)
869
+ || (result.kind === "task_transitioned" && !operationEvents.length)) {
870
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has a receipt without a committed command.");
871
+ }
872
+ const atCommit = new GraphTaskController({
873
+ schema: snapshot.schema,
874
+ graphRevisions: snapshot.graphRevisions.filter((revision) => revision.sequence <= lastSequence),
875
+ events: snapshot.events.filter((event) => event.sequence <= lastSequence),
876
+ receipts: [],
877
+ });
878
+ const tasks = atCommit.readTasks();
879
+ if (result.kind === "graph_applied") {
880
+ if (digest(result.tasks) !== digest(tasks) || digest(result.readyTaskIds) !== digest(readyIds(tasks))) {
881
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has a graph receipt that differs from committed state.");
882
+ }
883
+ }
884
+ else {
885
+ const directEvent = operationEvents[0];
886
+ const transition = directEvent.kind === "attempt_started" ? "claim"
887
+ : directEvent.kind === "attempt_blocked" ? "block"
888
+ : directEvent.kind === "attempt_resumed" ? "resume"
889
+ : directEvent.kind === "attempt_completed" ? directEvent.outcome
890
+ : directEvent.kind === "task_cancelled" ? "cancel"
891
+ : directEvent.kind === "task_context_updated" ? "context_updated"
892
+ : undefined;
893
+ const taskId = "taskId" in directEvent ? directEvent.taskId
894
+ : "attemptId" in directEvent ? snapshot.events.find((event) => event.kind === "attempt_started" && event.attemptId === directEvent.attemptId)?.taskId
895
+ : undefined;
896
+ const expectedTask = tasks.find((task) => task.id === taskId);
897
+ const traversal = operationEvents.find((event) => event.kind === "failure_edge_traversed");
898
+ const expectedTraversal = traversal
899
+ ? { sourceTaskId: traversal.sourceTaskId, targetTaskId: traversal.targetTaskId, traversal: traversal.traversal }
900
+ : undefined;
901
+ if (!expectedTask || transition !== result.transition || digest(result.task) !== digest(expectedTask)
902
+ || digest(result.readyTaskIds) !== digest(readyIds(tasks))
903
+ || digest(result.failureTraversal ?? null) !== digest(expectedTraversal ?? null)) {
904
+ throw new GraphControlRefusal("invalid_graph", "Graph authority snapshot has a Task receipt that differs from committed state.");
905
+ }
906
+ }
907
+ }
908
+ function readyIds(tasks) {
909
+ return tasks.filter(task => task.state.kind === "ready").map(task => task.id).sort();
910
+ }
911
+ function normalizeAndValidate(input) {
912
+ if (!input.length)
913
+ throw new GraphControlRefusal("invalid_graph", "A graph requires at least one Task.");
914
+ const normalized = input.map(task => ({
915
+ key: task.key,
916
+ title: task.title,
917
+ goal: task.goal,
918
+ assignee: task.assignee,
919
+ needs: [...(task.needs ?? [])].sort(),
920
+ ...(task.onGoalFailed ? { onGoalFailed: { ...task.onGoalFailed } } : {}),
921
+ }));
922
+ const byId = new Map();
923
+ for (const task of normalized) {
924
+ if (!task.key.trim() || !task.title.trim() || !task.goal.trim() || !task.assignee.trim()) {
925
+ throw new GraphControlRefusal("invalid_graph", "Every Task requires nonempty key, title, goal, and assignee values.");
926
+ }
927
+ if (byId.has(task.key))
928
+ throw new GraphControlRefusal("invalid_graph", `Task key ${task.key} occurs more than once.`);
929
+ if (new Set(task.needs).size !== task.needs.length) {
930
+ throw new GraphControlRefusal("invalid_graph", `Task ${task.key} repeats a prerequisite.`);
931
+ }
932
+ byId.set(task.key, task);
933
+ }
934
+ for (const task of normalized) {
935
+ for (const prerequisite of task.needs) {
936
+ if (!byId.has(prerequisite))
937
+ throw new GraphControlRefusal("invalid_graph", `Task ${task.key} needs missing Task ${prerequisite}.`);
938
+ if (prerequisite === task.key)
939
+ throw new GraphControlRefusal("invalid_graph", `Task ${task.key} cannot need itself.`);
940
+ }
941
+ if (task.onGoalFailed) {
942
+ if (!Number.isInteger(task.onGoalFailed.maxTraversals)
943
+ || task.onGoalFailed.maxTraversals < 1
944
+ || task.onGoalFailed.maxTraversals > GRAPH_CONTROL_MAX_FAILURE_TRAVERSALS) {
945
+ throw new GraphControlRefusal("invalid_graph", `Task ${task.key} failure maxTraversals must be from 1 through ${GRAPH_CONTROL_MAX_FAILURE_TRAVERSALS}.`);
946
+ }
947
+ if (!byId.has(task.onGoalFailed.target)) {
948
+ throw new GraphControlRefusal("invalid_graph", `Task ${task.key} has missing failure target ${task.onGoalFailed.target}.`);
949
+ }
950
+ }
951
+ }
952
+ topologicalOrder(normalized);
953
+ for (const task of normalized) {
954
+ const target = task.onGoalFailed?.target;
955
+ if (target && target !== task.key && !ancestors(task.key, byId).has(target)) {
956
+ throw new GraphControlRefusal("invalid_graph", `Task ${task.key} failure target ${target} must be itself or a transitive success prerequisite.`);
957
+ }
958
+ }
959
+ return normalized;
960
+ }
961
+ function topologicalOrder(tasks) {
962
+ const byId = new Map(tasks.map(task => [task.key, task]));
963
+ const visiting = new Set();
964
+ const visited = new Set();
965
+ const order = [];
966
+ const visit = (taskId, path) => {
967
+ if (visiting.has(taskId)) {
968
+ throw new GraphControlRefusal("invalid_graph", `Success dependency cycle: ${[...path, taskId].join(" -> ")}.`);
969
+ }
970
+ if (visited.has(taskId))
971
+ return;
972
+ visiting.add(taskId);
973
+ for (const prerequisite of byId.get(taskId)?.needs ?? [])
974
+ visit(prerequisite, [...path, taskId]);
975
+ visiting.delete(taskId);
976
+ visited.add(taskId);
977
+ order.push(taskId);
978
+ };
979
+ for (const taskId of [...byId.keys()].sort())
980
+ visit(taskId, []);
981
+ return order;
982
+ }
983
+ function ancestors(taskId, tasks) {
984
+ const result = new Set();
985
+ const collect = (id) => {
986
+ for (const prerequisite of tasks.get(id)?.needs ?? []) {
987
+ if (result.has(prerequisite))
988
+ continue;
989
+ result.add(prerequisite);
990
+ collect(prerequisite);
991
+ }
992
+ };
993
+ collect(taskId);
994
+ return result;
995
+ }
996
+ function reverseClosure(seed, tasks) {
997
+ const result = new Set(seed);
998
+ let changed = true;
999
+ while (changed) {
1000
+ changed = false;
1001
+ for (const task of tasks) {
1002
+ if (result.has(task.key) || !task.needs.some(prerequisite => result.has(prerequisite)))
1003
+ continue;
1004
+ result.add(task.key);
1005
+ changed = true;
1006
+ }
1007
+ }
1008
+ return result;
1009
+ }