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