@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,675 @@
1
+ // Current authority uses base-checkpointed records. The controller log is read
2
+ // only for migration, explicit history inspection, and generation reconciliation.
3
+ import { createHash } from 'node:crypto';
4
+ import { defineDoc, defineDocFamily } from '@earendil-works/pi-durable';
5
+ import { GraphTaskController, GraphControlRefusal } from "./graph-control.js";
6
+ import { IncrementalGraphTaskController, GraphTaskRequired, prerequisiteProjection, graphControlFingerprint } from "./incremental-graph-control.js";
7
+ import { projectGraphTaskCards } from "./graph-card.js";
8
+ import { TeamDoc, GraphDoc, JournalDoc, OutboxDoc, ReceiptDoc, plain, fenceLegacyDoc, hasLegacyFence } from "./state.js";
9
+ import { openObservations } from "./observation-store.js";
10
+ const family = (kind) => defineDocFamily({ kind, version: 1, scope: 'session', family: true, initial: () => ({}), checkpointWhen: () => true });
11
+ const dictionary = () => Object.create(null);
12
+ const emptyGraph = () => ({ sequence: 0, ready: {}, occupied: {} });
13
+ const initialHead = () => ({ initialized: false, journal: { seq: 0, events: '[]', acknowledged: 0 }, pendingBuckets: {}, unscheduledBuckets: {}, obligationSequence: 0, recentObligations: [], recentAlerts: [], leadAlerts: [], leadAlertCount: 0, taskCount: 0, attention: {}, frontier: {}, taskObligations: {}, workerBindings: {} });
14
+ // The typed envelope is replaced as one base. Opaque JSON avoids tracking every
15
+ // unchanged index leaf through Chord's draft machinery.
16
+ export const HeadDoc = defineDoc({ kind: 'ptb.head', version: 1, scope: 'session', checkpointWhen: () => true, initial: () => ({ value: JSON.stringify(initialHead()) }) });
17
+ export const TaskDoc = family('ptb.task');
18
+ export const GraphIndexDoc = family('ptb.graph-index');
19
+ export const WorkerIndexDoc = family('ptb.worker-index');
20
+ export const OrdinalDoc = family('ptb.ordinal');
21
+ export const OperationDoc = family('ptb.op');
22
+ export const ObligationDoc = family('ptb.obl');
23
+ export const EligibilityDoc = family('ptb.eligibility');
24
+ export const AlertDoc = family('ptb.alert');
25
+ export const DirectCallDoc = family('ptb.call.direct');
26
+ export const CallDoc = defineDoc({ kind: 'ptb.call', version: 1, scope: 'task', initial: () => ({}), checkpointWhen: () => true });
27
+ const address = (epoch, id) => JSON.stringify([epoch, id]);
28
+ export const taskAddress = address;
29
+ const sha = (text) => createHash('sha256').update(text).digest('hex');
30
+ const terminal = (kind) => ['goal_achieved', 'goal_failed', 'cancelled'].includes(kind);
31
+ const ring = (ids, id, limit) => { ids.push(id); if (ids.length > limit)
32
+ ids.splice(0, ids.length - limit); };
33
+ function journalView(head, flush) {
34
+ let cached;
35
+ const target = {};
36
+ for (const key of ['seq', 'acknowledged', 'reservation'])
37
+ Object.defineProperty(target, key, { enumerable: true, configurable: true, get: () => head.journal[key], set: value => { head.journal[key] = value; flush(); } });
38
+ Object.defineProperty(target, 'events', { enumerable: true, configurable: true, get: () => cached ??= JSON.parse(head.journal.events), set: value => { cached = value; head.journal.events = JSON.stringify(value.map((e) => e.entryId === undefined ? e : { seq: e.seq, entryId: e.entryId })); flush(); } });
39
+ return new Proxy(target, { deleteProperty(object, key) { if (key === 'reservation') {
40
+ delete head.journal.reservation;
41
+ flush();
42
+ }
43
+ else
44
+ Reflect.deleteProperty(object, key); return true; } });
45
+ }
46
+ export async function recentEvents(s, refs = s.journal.events) {
47
+ return Promise.all(refs.map(async (ref) => {
48
+ if ('kind' in ref)
49
+ return ref;
50
+ const entry = await s.tx.entry(ref.entryId);
51
+ if (entry?.kind !== 'ptb.event' || entry.data?.seq !== ref.seq)
52
+ throw new Error('Journal event reference is corrupt.');
53
+ return { ...entry.data, entryId: entry.id };
54
+ }));
55
+ }
56
+ const transactions = new WeakMap();
57
+ export function flushAuthority(s) { s.headDoc.value = JSON.stringify(s.head); }
58
+ export async function openAuthority(tx) {
59
+ const cached = transactions.get(tx);
60
+ if (cached)
61
+ return cached;
62
+ const team = await tx.doc(TeamDoc);
63
+ const headDoc = await tx.doc(HeadDoc);
64
+ const head = JSON.parse(headDoc.value);
65
+ for (const map of [head.pendingBuckets, head.unscheduledBuckets, head.attention, head.frontier, head.taskObligations, head.workerBindings])
66
+ Object.setPrototypeOf(map, null);
67
+ const s = { tx, team, head, headDoc, deferFlush: false, flush: () => { if (!s.deferFlush)
68
+ flushAuthority(s); }, journal: journalView(head, () => s.flush()), records: new Map(), obligations: new Map(), eligibility: new Map(), newObligations: new Set(), calls: new Map(), workerIndexes: new Map() };
69
+ transactions.set(tx, s);
70
+ // Fence observation access before migration appends controller-log entries.
71
+ if (!head.legacyFenced)
72
+ await openObservations(tx);
73
+ if (!head.initialized)
74
+ await migrate(s);
75
+ if (!head.legacyFenced) {
76
+ for (const token of [GraphDoc, JournalDoc, OutboxDoc, ReceiptDoc])
77
+ fenceLegacyDoc(await tx.doc(token));
78
+ head.legacyFenced = true;
79
+ flushAuthority(s);
80
+ }
81
+ return s;
82
+ }
83
+ export async function startEpoch(s, epoch) {
84
+ s.head.epoch = epoch;
85
+ s.head.graph = emptyGraph();
86
+ s.head.taskCount = 0;
87
+ s.head.attention = dictionary();
88
+ s.head.frontier = dictionary();
89
+ s.head.workerBindings = dictionary();
90
+ s.head.leadAlerts = [];
91
+ s.head.leadAlertCount = 0;
92
+ s.head.stopRefresh = false;
93
+ s.records.clear();
94
+ if (s.head.log === undefined)
95
+ s.head.log = (await s.tx.createConversation({ ownership: { kind: 'ownerless' } })).id;
96
+ }
97
+ const graphIndexes = new Map();
98
+ async function graphIndex(s) {
99
+ const version = s.head.graph?.revision?.version;
100
+ if (!version)
101
+ return { topology: [], ids: new Set() };
102
+ let cached = graphIndexes.get(version);
103
+ if (!cached) {
104
+ const doc = await s.tx.doc(GraphIndexDoc, address(s.head.epoch, version), null);
105
+ if (typeof doc.value !== 'string')
106
+ throw new Error('Established graph authority is corrupt.');
107
+ const topology = JSON.parse(doc.value);
108
+ cached = { topology, ids: new Set(topology.map(t => t.key)) };
109
+ if (graphIndexes.size >= 32)
110
+ graphIndexes.delete(graphIndexes.keys().next().value);
111
+ graphIndexes.set(version, cached);
112
+ }
113
+ return cached;
114
+ }
115
+ export async function taskIds(s) { return [...(await graphIndex(s)).ids]; }
116
+ async function saveGraphHead(s, head) {
117
+ const { topology, projections: _projections, ...small } = head;
118
+ s.head.graph = small;
119
+ s.head.taskCount = topology.length;
120
+ if (head.revision) {
121
+ (await s.tx.doc(GraphIndexDoc, address(s.head.epoch, head.revision.version), null)).value = JSON.stringify(topology);
122
+ if (graphIndexes.size >= 32)
123
+ graphIndexes.delete(graphIndexes.keys().next().value);
124
+ graphIndexes.set(head.revision.version, { topology, ids: new Set(topology.map(t => t.key)) });
125
+ }
126
+ }
127
+ export async function taskRecord(s, id) {
128
+ if (s.records.has(id))
129
+ return s.records.get(id);
130
+ if (!(await graphIndex(s)).ids.has(id)) {
131
+ s.records.set(id, undefined);
132
+ return;
133
+ }
134
+ const doc = await s.tx.doc(TaskDoc, address(s.head.epoch, id), null);
135
+ const value = doc.value ? JSON.parse(doc.value) : undefined;
136
+ if (!value?.materialized?.node || !value.card)
137
+ throw new Error('Established graph authority is corrupt.');
138
+ s.records.set(id, value);
139
+ return value;
140
+ }
141
+ export async function taskCards(s, ids) {
142
+ ids ??= await taskIds(s);
143
+ const cards = await Promise.all(ids.map(async (id) => (await taskRecord(s, id))?.card));
144
+ return cards.filter((card) => !!card).map(plain).sort((a, b) => a.id.localeCompare(b.id));
145
+ }
146
+ async function workerIndex(s, worker) {
147
+ const key = address(s.head.epoch, worker), cached = s.workerIndexes.get(key);
148
+ if (cached)
149
+ return cached;
150
+ const doc = await s.tx.doc(WorkerIndexDoc, key, null), ids = typeof doc.value === 'string' ? JSON.parse(doc.value) : [];
151
+ s.workerIndexes.set(key, ids);
152
+ return ids;
153
+ }
154
+ export async function unfinished(s, worker) {
155
+ const ids = worker ? await workerIndex(s, worker) : (await taskCards(s)).filter(t => !terminal(t.status)).map(t => t.id);
156
+ return [...ids].sort((a, b) => a.localeCompare(b));
157
+ }
158
+ async function changeWorkerIndex(s, worker, id, add) {
159
+ const ids = await workerIndex(s, worker), at = ids.indexOf(id);
160
+ if (add && at === -1)
161
+ ids.push(id);
162
+ else if (!add && at !== -1)
163
+ ids.splice(at, 1);
164
+ else
165
+ return;
166
+ (await s.tx.doc(WorkerIndexDoc, address(s.head.epoch, worker), null)).value = JSON.stringify(ids);
167
+ }
168
+ export async function obligation(s, key) {
169
+ if (s.obligations.has(key))
170
+ return s.obligations.get(key);
171
+ const doc = await s.tx.doc(ObligationDoc, key, null);
172
+ if (doc.value)
173
+ s.obligations.set(key, doc.value);
174
+ return doc.value;
175
+ }
176
+ const bucketOf = (key) => String(Number.parseInt(sha(key).slice(0, 8), 16) % 64);
177
+ async function eligibility(s, bucket) {
178
+ let value = s.eligibility.get(bucket);
179
+ if (value)
180
+ return value;
181
+ const doc = await s.tx.doc(EligibilityDoc, bucket, null);
182
+ value = { doc, ...(typeof doc.value === 'string' ? JSON.parse(doc.value) : { pending: {}, unscheduled: {} }) };
183
+ s.eligibility.set(bucket, value);
184
+ return value;
185
+ }
186
+ function saveEligibility(s, bucket, index) {
187
+ index.doc.value = JSON.stringify({ pending: index.pending, unscheduled: index.unscheduled });
188
+ for (const kind of ['pending', 'unscheduled']) {
189
+ const head = kind === 'pending' ? s.head.pendingBuckets : s.head.unscheduledBuckets;
190
+ if (Object.keys(index[kind]).length)
191
+ head[bucket] = true;
192
+ else
193
+ delete head[bucket];
194
+ }
195
+ }
196
+ async function indexedKeys(s, kind) {
197
+ const buckets = Object.keys(kind === 'pending' ? s.head.pendingBuckets : s.head.unscheduledBuckets);
198
+ const entries = (await Promise.all(buckets.map(async (bucket) => Object.entries((await eligibility(s, bucket))[kind])))).flat();
199
+ return entries.sort((a, b) => a[1] - b[1]).map(([key]) => key);
200
+ }
201
+ export async function markScheduled(s, key) {
202
+ const bucket = bucketOf(key), index = await eligibility(s, bucket);
203
+ delete index.unscheduled[key];
204
+ saveEligibility(s, bucket, index);
205
+ }
206
+ export async function unscheduledObligations(s) {
207
+ return (await Promise.all((await indexedKeys(s, 'unscheduled')).map(key => obligation(s, key)))).filter((o) => !!o);
208
+ }
209
+ export async function addObligation(s, value) {
210
+ const doc = await s.tx.doc(ObligationDoc, value.key, null);
211
+ if (doc.value)
212
+ return;
213
+ if (s.head.obligationTail)
214
+ doc.previous = s.head.obligationTail;
215
+ doc.value = plain(value);
216
+ doc.order = ++s.head.obligationSequence;
217
+ s.head.obligationTail = value.key;
218
+ if (value.state === 'pending') {
219
+ const bucket = bucketOf(value.key), index = await eligibility(s, bucket);
220
+ index.pending[value.key] = doc.order;
221
+ if (!value.deliveryTaskId)
222
+ index.unscheduled[value.key] = doc.order;
223
+ saveEligibility(s, bucket, index);
224
+ if (value.kind === 'task') {
225
+ s.head.taskObligations[value.taskId] ??= [];
226
+ s.head.taskObligations[value.taskId].push(value.key);
227
+ }
228
+ }
229
+ ring(s.head.recentObligations, value.key, 60);
230
+ s.obligations.set(value.key, doc.value);
231
+ s.newObligations.add(value.key);
232
+ }
233
+ export async function setObligationState(s, value, state) {
234
+ value.state = state;
235
+ const bucket = bucketOf(value.key), index = await eligibility(s, bucket);
236
+ if (state === 'pending') {
237
+ const doc = await s.tx.doc(ObligationDoc, value.key, null);
238
+ index.pending[value.key] = Number(doc.order);
239
+ }
240
+ else {
241
+ delete index.pending[value.key];
242
+ delete index.unscheduled[value.key];
243
+ if (value.kind === 'task') {
244
+ const keys = s.head.taskObligations[value.taskId];
245
+ if (keys) {
246
+ const at = keys.indexOf(value.key);
247
+ if (at !== -1)
248
+ keys.splice(at, 1);
249
+ if (!keys.length)
250
+ delete s.head.taskObligations[value.taskId];
251
+ }
252
+ }
253
+ }
254
+ saveEligibility(s, bucket, index);
255
+ }
256
+ export async function allObligations(s) {
257
+ const values = [];
258
+ let key = s.head.obligationTail;
259
+ while (key) {
260
+ const doc = await s.tx.doc(ObligationDoc, key, null);
261
+ if (doc.value) {
262
+ values.push(doc.value);
263
+ s.obligations.set(key, doc.value);
264
+ }
265
+ key = doc.previous;
266
+ }
267
+ return values.reverse();
268
+ }
269
+ export async function pendingObligations(s) {
270
+ return (await Promise.all((await indexedKeys(s, 'pending')).map(key => obligation(s, key)))).filter((o) => !!o);
271
+ }
272
+ export async function alertRecord(s, id) {
273
+ return (await s.tx.doc(AlertDoc, id, null)).value;
274
+ }
275
+ export async function addAlert(s, value) {
276
+ const doc = await s.tx.doc(AlertDoc, value.id, null);
277
+ if (doc.value)
278
+ return;
279
+ if (s.head.alertTail)
280
+ doc.previous = s.head.alertTail;
281
+ doc.value = plain(value);
282
+ s.head.alertTail = value.id;
283
+ ring(s.head.recentAlerts, value.id, 30);
284
+ if (value.recipients.some(r => r.name === 'team-lead' && r.conversationId === s.team.current?.lead) && value.id.startsWith(`${s.head.epoch}:`)) {
285
+ ring(s.head.leadAlerts, value.id, 10);
286
+ s.head.leadAlertCount++;
287
+ }
288
+ }
289
+ export async function callReceipt(s, id, native) {
290
+ const cached = s.calls.get(id);
291
+ if (cached)
292
+ return cached.doc;
293
+ native ??= (await s.tx.task(id))?.kind === 'pi.tool';
294
+ const doc = native ? await s.tx.doc(CallDoc, id) : await s.tx.doc(DirectCallDoc, String(id), null);
295
+ s.calls.set(id, { doc, native });
296
+ return doc;
297
+ }
298
+ export async function saveCall(s, id, receipt) {
299
+ const doc = await callReceipt(s, id), native = s.calls.get(id).native;
300
+ if (!doc.fingerprint && !native) {
301
+ if (s.head.callTail)
302
+ doc.previous = s.head.callTail;
303
+ s.head.callTail = String(id);
304
+ }
305
+ doc.fingerprint = receipt.fingerprint;
306
+ if (receipt.result) {
307
+ doc.result = JSON.stringify(receipt.result);
308
+ delete doc.stops;
309
+ delete doc.epoch;
310
+ }
311
+ else {
312
+ doc.stops = receipt.stops;
313
+ doc.epoch = receipt.epoch;
314
+ delete doc.result;
315
+ }
316
+ flushAuthority(s);
317
+ }
318
+ async function indexTask(s, prior, next, id) {
319
+ const before = prior && !terminal(prior.card.status) ? prior.card.assignee : undefined;
320
+ const after = next && !terminal(next.card.status) ? next.card.assignee : undefined;
321
+ if (before !== after) {
322
+ if (before !== undefined)
323
+ await changeWorkerIndex(s, before, id, false);
324
+ if (after !== undefined)
325
+ await changeWorkerIndex(s, after, id, true);
326
+ }
327
+ delete s.head.attention[id];
328
+ if (next?.card.status === 'blocked')
329
+ s.head.attention[id] = 'blocked';
330
+ else if (next?.card.status === 'goal_failed' && !String(next.operator.derivedReason ?? '').startsWith('dependency'))
331
+ s.head.attention[id] = 'failed';
332
+ }
333
+ function rendered(engine, id) {
334
+ const materialized = engine.materializedTask(id), view = engine.readTask(id), attempts = engine.readAttempts(id);
335
+ const [card] = projectGraphTaskCards({ readTasks: () => [view], readAttempts: () => attempts, selectReadyFrontier: () => [] });
336
+ const operatorAttempts = attempts.map(a => {
337
+ const fold = materialized.attempts.find(f => f.started.attemptId === a.id);
338
+ return { id: a.id, ordinal: a.ordinal, ...(fold.started.recorded_at ? { startedAt: Date.parse(fold.started.recorded_at) } : {}), ...(fold.endedAt ? { endedAt: Date.parse(fold.endedAt) } : {}), ...(a.outcome ? { outcome: a.outcome } : {}), ...(a.evidence ? { evidence: a.evidence } : {}) };
339
+ });
340
+ const evidence = view.state.kind === 'blocked' ? view.state.evidence : operatorAttempts.at(-1)?.evidence;
341
+ const operator = { key: id, title: view.title, goal: view.goal, assignee: view.assignee, needs: view.needs, version: view.version, current_context: view.currentContext, attempts: operatorAttempts, derived: view.state.kind,
342
+ status: ['ready', 'dependency_waiting'].includes(view.state.kind) ? 'pending' : view.state.kind, ...('reason' in view.state ? { derivedReason: view.state.reason } : {}), ...(evidence ? { evidence } : {}) };
343
+ return { materialized, card, operator };
344
+ }
345
+ async function writeTask(s, engine, id, present) {
346
+ const prior = await taskRecord(s, id);
347
+ const materialized = engine.materializedTask(id);
348
+ const counter = await s.tx.doc(OrdinalDoc, address(s.head.epoch, id), null);
349
+ counter.count = materialized.attemptsStarted;
350
+ let next;
351
+ if (present) {
352
+ next = rendered(engine, id);
353
+ (await s.tx.doc(TaskDoc, address(s.head.epoch, id), null)).value = JSON.stringify(next);
354
+ }
355
+ else
356
+ (await s.tx.doc(TaskDoc, address(s.head.epoch, id), null)).value = JSON.stringify({ materialized });
357
+ await indexTask(s, prior, next, id);
358
+ s.records.set(id, next);
359
+ }
360
+ async function logRevision(s, revision, operationId, prior) {
361
+ const previous = new Map(prior.map(task => [task.key, task]));
362
+ const upserts = revision.tasks.filter(task => previous.get(task.key)?.lineage !== task.lineage);
363
+ const keys = new Set(revision.tasks.map(task => task.key));
364
+ await s.tx.appendEntry(s.head.log, { kind: 'ptb.graph-revision', data: plain({ epoch: s.head.epoch, operationId, sequence: revision.sequence, version: revision.version, recorded_at: revision.recorded_at, order: revision.tasks.map(t => t.key), upserts, removed: prior.filter(t => !keys.has(t.key)).map(t => t.key) }) });
365
+ }
366
+ async function logEvent(s, event) {
367
+ await s.tx.appendEntry(s.head.log, { kind: 'ptb.graph-event', data: plain({ epoch: s.head.epoch, event }) });
368
+ }
369
+ async function saveOperation(s, receipt, legacy = false) {
370
+ const result = JSON.stringify(receipt.result);
371
+ Object.assign(await s.tx.doc(OperationDoc, address(s.head.epoch, receipt.operationId), null), { operationId: receipt.operationId, fingerprint: receipt.fingerprint, result, digest: sha(result), ...(legacy ? { legacy: true } : {}) });
372
+ }
373
+ function checkedOperation(doc, fingerprint) {
374
+ if (!doc.operationId)
375
+ return;
376
+ if (fingerprint && fingerprint !== doc.fingerprint)
377
+ throw new GraphControlRefusal('operation_conflict', `Operation ${doc.operationId} was already used with different semantics.`);
378
+ let result;
379
+ try {
380
+ result = JSON.parse(doc.result);
381
+ }
382
+ catch {
383
+ throw new GraphControlRefusal('invalid_graph', `Operation ${doc.operationId} receipt is malformed: invalid JSON`);
384
+ }
385
+ if (!result || !['graph_applied', 'task_transitioned'].includes(result.kind))
386
+ throw new GraphControlRefusal('invalid_graph', 'Graph authority snapshot has a receipt without a committed command.');
387
+ if (sha(doc.result) !== doc.digest)
388
+ throw new GraphControlRefusal('invalid_graph', result.kind === 'graph_applied' ? 'Graph authority snapshot has a graph receipt that differs from committed state.' : 'Graph authority snapshot has a Task receipt that differs from committed state.');
389
+ return { operationId: doc.operationId, fingerprint: doc.fingerprint, result };
390
+ }
391
+ export async function hasOperation(s, operationId) {
392
+ return !!(await s.tx.doc(OperationDoc, address(s.head.epoch, operationId), null)).operationId;
393
+ }
394
+ export async function graphCommand(s, command, now) {
395
+ const receiptDoc = await s.tx.doc(OperationDoc, address(s.head.epoch, command.input.operationId), null);
396
+ const receipt = checkedOperation(receiptDoc, graphControlFingerprint({ command: command.kind === 'apply' ? 'apply_graph' : 'transition', ...command.input }));
397
+ if (receipt) {
398
+ if (receiptDoc.legacy) {
399
+ // Validate an rc.7 receipt against its original committed projection once.
400
+ const oracle = GraphTaskController.recover(await readGraphHistory(s));
401
+ const result = command.kind === 'apply' ? oracle.applyGraph(command.input) : oracle.transition(command.input);
402
+ delete receiptDoc.legacy;
403
+ return result;
404
+ }
405
+ return { ...receipt.result, replayed: true };
406
+ }
407
+ const head = { ...s.head.graph, topology: (await graphIndex(s)).topology, projections: dictionary() };
408
+ const generationHistory = s.head.stopRefresh ? await readGraphHistory(s) : undefined;
409
+ const records = new Map();
410
+ const load = async (ids) => {
411
+ for (const id of ids) {
412
+ if (records.has(id))
413
+ continue;
414
+ const record = await taskRecord(s, id);
415
+ if (record) {
416
+ records.set(id, record.materialized);
417
+ head.projections[id] = prerequisiteProjection(record.materialized.node.derived);
418
+ }
419
+ else {
420
+ const counter = await s.tx.doc(OrdinalDoc, address(s.head.epoch, id), null);
421
+ const historical = await s.tx.doc(TaskDoc, address(s.head.epoch, id), null);
422
+ records.set(id, { id, attemptsStarted: Number(counter.count ?? 0), attempts: historical.value ? JSON.parse(historical.value).materialized?.attempts ?? [] : [] });
423
+ }
424
+ }
425
+ };
426
+ if (command.kind === 'apply')
427
+ await load([...new Set([...head.topology.map(t => t.key), ...command.input.tasks.map(t => t.key)])]);
428
+ else
429
+ await load([command.input.taskId]);
430
+ for (;;) {
431
+ const engine = IncrementalGraphTaskController.restore(head, records, () => new Date(now()));
432
+ try {
433
+ const result = command.kind === 'apply' ? engine.applyGraph(command.input) : engine.transition(command.input);
434
+ if (result.replayed)
435
+ return result;
436
+ const changes = engine.changes(), next = command.kind === 'apply' ? engine.materializedHead() : undefined;
437
+ const present = next ? new Set(next.topology.map(t => t.key)) : undefined;
438
+ for (const id of changes.taskIds)
439
+ await writeTask(s, engine, id, present?.has(id) ?? true);
440
+ const prior = changes.revisions.length ? head.topology.map(t => records.get(t.key).node.definition) : [];
441
+ for (const revision of changes.revisions)
442
+ await logRevision(s, revision, command.input.operationId, prior);
443
+ for (const event of changes.events)
444
+ await logEvent(s, event);
445
+ for (const op of changes.receipts)
446
+ await saveOperation(s, op);
447
+ if (next)
448
+ await saveGraphHead(s, next);
449
+ else {
450
+ const update = engine.headUpdate();
451
+ s.head.graph.sequence = update.sequence;
452
+ s.head.graph.ready = update.ready;
453
+ s.head.graph.occupied = update.occupied;
454
+ }
455
+ if (generationHistory) {
456
+ generationHistory.graphRevisions.push(...changes.revisions);
457
+ generationHistory.events.push(...changes.events);
458
+ generationHistory.receipts.push(...changes.receipts);
459
+ }
460
+ await reconcile(s, engine, changes.taskIds, changes.events, now(), generationHistory);
461
+ return result;
462
+ }
463
+ catch (error) {
464
+ if (!(error instanceof GraphTaskRequired))
465
+ throw error;
466
+ const missing = error.ids.filter(id => !records.has(id));
467
+ if (!missing.length)
468
+ throw new Error('Established graph authority is corrupt.');
469
+ await load(missing);
470
+ }
471
+ }
472
+ }
473
+ async function reconcile(s, engine, changed, events, now, generationHistory) {
474
+ const team = s.team.current;
475
+ // Pending Task obligations are bounded by the ready frontier. Alert delivery
476
+ // records are not read during graph reconciliation.
477
+ for (const id of changed) {
478
+ const oldKeys = [...(s.head.taskObligations[id] ?? [])];
479
+ for (const key of oldKeys) {
480
+ const o = await obligation(s, key);
481
+ if (!o)
482
+ continue;
483
+ const record = await taskRecord(s, id), t = record?.card;
484
+ if (!t || t.version !== o.version || t.assignee !== o.recipient || t.status !== 'ready')
485
+ await setObligationState(s, o, 'retired');
486
+ }
487
+ }
488
+ const frontier = dictionary();
489
+ const ready = Object.entries(s.head.graph.ready).filter(([assignee, ids]) => !s.head.graph.occupied[assignee]?.length && ids.length)
490
+ .map(([assignee, ids]) => ({ assignee, id: [...ids].sort((a, b) => a.localeCompare(b))[0] }))
491
+ .sort((a, b) => a.id.localeCompare(b.id));
492
+ for (const { assignee, id } of ready) {
493
+ const w = team.workers[assignee];
494
+ if (!w || w.state !== 'active')
495
+ continue;
496
+ const previous = s.head.frontier[assignee];
497
+ if (previous?.id === id && previous.generation === w.generation && !changed.includes(id)) {
498
+ frontier[assignee] = plain(previous);
499
+ continue;
500
+ }
501
+ const t = (await taskRecord(s, id)).card;
502
+ frontier[assignee] = { id, version: t.version, generation: w.generation };
503
+ const key = `${team.epoch}:task:${id}:${t.version}:g${w.generation}`;
504
+ await addObligation(s, { key, epoch: team.epoch, kind: 'task', recipient: w.name, conversationId: w.conversationId, generation: w.generation, taskId: id, version: t.version, state: 'pending', acceptedAt: now });
505
+ }
506
+ s.head.frontier = frontier;
507
+ let attempts = [...new Set(events.flatMap(e => 'attemptId' in e && e.attemptId ? [e.attemptId] : []))]
508
+ .flatMap(id => { const taskId = events.find(e => 'attemptId' in e && e.attemptId === id); return taskId ? changed.flatMap(key => engine.readAttempts(key).filter(a => a.id === id)) : []; });
509
+ // The reference emits ready Tasks by Task ID, then stop notices by Attempt
510
+ // start sequence. Revision event order does not preserve Attempt chronology.
511
+ if (generationHistory) {
512
+ attempts = GraphTaskController.recover(generationHistory).readAttempts();
513
+ s.head.stopRefresh = false;
514
+ }
515
+ else
516
+ attempts.sort((a, b) => engine.attemptStartedSequence(a.id) - engine.attemptStartedSequence(b.id));
517
+ for (const attempt of attempts) {
518
+ if (attempt.state !== 'superseded' && attempt.state !== 'cancelled')
519
+ continue;
520
+ const w = team.workers[attempt.assignee];
521
+ if (!w || w.state !== 'active')
522
+ continue;
523
+ const key = `${team.epoch}:stop:${attempt.id}:g${w.generation}`;
524
+ await addObligation(s, { key, epoch: team.epoch, kind: 'stop', recipient: w.name, conversationId: w.conversationId, generation: w.generation, taskId: attempt.taskId, state: 'pending', acceptedAt: now });
525
+ }
526
+ }
527
+ /** History materialization for diagnostics, migration checks, and generation reconciliation. */
528
+ export async function readGraphHistory(s, epoch = s.head.epoch) {
529
+ if (s.migratedHistory?.has(epoch))
530
+ return plain(s.migratedHistory.get(epoch));
531
+ const snapshot = { schema: 'pi-team-bright-graph-control/1', graphRevisions: [], events: [], receipts: [] };
532
+ if (s.head.log === undefined)
533
+ return snapshot;
534
+ let cursor;
535
+ const definitions = new Map(), operations = new Set();
536
+ do {
537
+ const page = await s.tx.scanEntries({ conversationId: s.head.log, order: 'ascending' }, 500, cursor);
538
+ for (const entry of page.items) {
539
+ const data = entry.data;
540
+ if (data?.epoch !== epoch)
541
+ continue;
542
+ if (entry.kind === 'ptb.graph-revision') {
543
+ for (const id of data.removed)
544
+ definitions.delete(id);
545
+ for (const task of data.upserts)
546
+ definitions.set(task.key, task);
547
+ snapshot.graphRevisions.push({ sequence: data.sequence, version: data.version, tasks: data.order.map((id) => definitions.get(id)), ...(data.recorded_at ? { recorded_at: data.recorded_at } : {}) });
548
+ operations.add(data.operationId);
549
+ }
550
+ else if (entry.kind === 'ptb.graph-event') {
551
+ snapshot.events.push(data.event);
552
+ operations.add(data.event.operationId);
553
+ }
554
+ }
555
+ cursor = page.next;
556
+ } while (cursor);
557
+ for (const id of operations) {
558
+ const receipt = checkedOperation(await s.tx.doc(OperationDoc, address(epoch, id), null));
559
+ if (receipt)
560
+ snapshot.receipts.push(receipt);
561
+ }
562
+ return snapshot;
563
+ }
564
+ async function migrate(s) {
565
+ const oldGraph = await s.tx.doc(GraphDoc);
566
+ const oldJournal = await s.tx.doc(JournalDoc);
567
+ const oldOutbox = await s.tx.doc(OutboxDoc);
568
+ const oldReceipts = await s.tx.doc(ReceiptDoc);
569
+ if ([oldGraph, oldJournal, oldOutbox, oldReceipts].some(hasLegacyFence))
570
+ throw new Error('Authority head is missing after authority cutover.');
571
+ const callsToMigrate = [];
572
+ for (const [id, receipt] of Object.entries(oldReceipts.calls)) {
573
+ const task = await s.tx.task(Number(id));
574
+ if (task?.kind === 'pi.tool' && task.state.status === 'terminal')
575
+ continue;
576
+ await callReceipt(s, Number(id), task?.kind === 'pi.tool');
577
+ callsToMigrate.push([id, plain(receipt)]);
578
+ }
579
+ s.head.journal = { ...plain(oldJournal), events: JSON.stringify(plain(oldJournal.events)) };
580
+ if (s.team.current || oldGraph.snapshot || Object.keys(oldGraph.history ?? {}).length) {
581
+ s.head.log = (await s.tx.createConversation({ ownership: { kind: 'ownerless' } })).id;
582
+ const snapshots = { ...plain(oldGraph.history ?? {}), ...(oldGraph.snapshot && oldGraph.epoch ? { [oldGraph.epoch]: plain(oldGraph.snapshot) } : {}) };
583
+ s.migratedHistory = new Map(Object.entries(snapshots));
584
+ for (const [epoch, snapshot] of Object.entries(snapshots)) {
585
+ const oracle = GraphTaskController.recover(snapshot), engine = IncrementalGraphTaskController.fromSnapshot(snapshot);
586
+ if (JSON.stringify(engine.readTasks()) !== JSON.stringify(oracle.readTasks()) || JSON.stringify(engine.readAttempts()) !== JSON.stringify(oracle.readAttempts()))
587
+ throw new Error('Graph migration differs from the reference controller.');
588
+ s.head.epoch = epoch;
589
+ s.head.graph = emptyGraph();
590
+ s.head.taskCount = 0;
591
+ s.head.attention = dictionary();
592
+ s.records.clear();
593
+ const ids = new Set([...snapshot.graphRevisions.flatMap(r => r.tasks.map(t => t.key)), ...snapshot.events.flatMap(e => e.kind === 'attempt_started' ? [e.taskId] : [])]);
594
+ const current = new Set(engine.materializedHead().topology.map(t => t.key));
595
+ for (const id of ids)
596
+ await writeTask(s, engine, id, current.has(id));
597
+ let prior = [];
598
+ for (const entry of [...snapshot.graphRevisions, ...snapshot.events].sort((a, b) => a.sequence - b.sequence)) {
599
+ if ('tasks' in entry) {
600
+ const op = snapshot.receipts.find(r => r.result.kind === 'graph_applied' && r.result.graphVersion === entry.version)?.operationId;
601
+ if (!op)
602
+ throw new Error('Graph migration revision has no receipt.');
603
+ await logRevision(s, entry, op, prior);
604
+ prior = entry.tasks;
605
+ }
606
+ else
607
+ await logEvent(s, entry);
608
+ }
609
+ for (const receipt of snapshot.receipts)
610
+ await saveOperation(s, receipt, true);
611
+ await saveGraphHead(s, engine.materializedHead());
612
+ }
613
+ s.head.epoch = oldGraph.epoch;
614
+ if (!oldGraph.snapshot)
615
+ delete s.head.graph;
616
+ }
617
+ for (const value of Object.values(oldOutbox.alerts))
618
+ await addAlert(s, plain(value));
619
+ for (const value of Object.values(oldOutbox.obligations))
620
+ await addObligation(s, plain(value));
621
+ for (const [id, receipt] of callsToMigrate)
622
+ await saveCall(s, Number(id), receipt);
623
+ s.head.workerBindings = Object.fromEntries(Object.values(s.team.current?.workers ?? {}).map(w => [String(w.conversationId), w.name]));
624
+ s.head.stopRefresh = Object.values(s.team.current?.workers ?? {}).some(w => w.generation > 1);
625
+ s.head.initialized = true;
626
+ }
627
+ /** Legacy-shaped, full inspection for diagnostics and existing recovery tests. */
628
+ export async function inspectAuthority(tx) {
629
+ const s = await openAuthority(tx), graph = { ...(s.head.epoch ? { epoch: s.head.epoch } : {}) };
630
+ if (s.head.graph)
631
+ graph.snapshot = await readGraphHistory(s);
632
+ const history = {};
633
+ for (const team of s.team.history)
634
+ history[team.epoch] = await readGraphHistory(s, team.epoch);
635
+ if (Object.keys(history).length)
636
+ graph.history = history;
637
+ const outbox = { alerts: {}, obligations: {} };
638
+ const alerts = [];
639
+ let alert = s.head.alertTail;
640
+ while (alert) {
641
+ const doc = await tx.doc(AlertDoc, alert, null);
642
+ if (doc.value)
643
+ alerts.push(plain(doc.value));
644
+ alert = doc.previous;
645
+ }
646
+ for (const value of alerts.reverse())
647
+ outbox.alerts[value.id] = value;
648
+ const obligations = [];
649
+ let key = s.head.obligationTail;
650
+ while (key) {
651
+ const doc = await tx.doc(ObligationDoc, key, null);
652
+ if (doc.value)
653
+ obligations.push(plain(doc.value));
654
+ key = doc.previous;
655
+ }
656
+ for (const value of obligations.reverse())
657
+ outbox.obligations[value.key] = value;
658
+ const receipts = { calls: {} };
659
+ let call = s.head.callTail;
660
+ while (call) {
661
+ const doc = await tx.doc(DirectCallDoc, call, null);
662
+ if (doc.fingerprint)
663
+ receipts.calls[call] = { fingerprint: doc.fingerprint, ...(doc.result ? { result: JSON.parse(doc.result) } : { stops: doc.stops, epoch: doc.epoch }) };
664
+ call = doc.previous;
665
+ }
666
+ let events = await recentEvents(s);
667
+ const journal = new Proxy(s.journal, { get(target, key, receiver) { return key === 'events' ? events : Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { if (key === 'events')
668
+ events = value; return Reflect.set(target, key, value, receiver); } });
669
+ const result = { team: s.team, graph: new Proxy(graph, { deleteProperty(target, key) { if (key === 'snapshot') {
670
+ delete s.head.graph;
671
+ flushAuthority(s);
672
+ } return Reflect.deleteProperty(target, key); } }), journal, outbox, receipts };
673
+ Object.defineProperty(result, 'flush', { value: () => flushAuthority(s) });
674
+ return result;
675
+ }