@hypercarrier/pi-team-durable 0.1.0-rc.1 → 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 (35) hide show
  1. package/README.md +100 -9
  2. package/dist/authority/contracts.d.ts +7 -0
  3. package/dist/authority/contracts.js +20 -4
  4. package/dist/authority/delivery.d.ts +4 -3
  5. package/dist/authority/delivery.js +35 -29
  6. package/dist/authority/graph-control.d.ts +1 -1
  7. package/dist/authority/incremental-graph-control.d.ts +156 -0
  8. package/dist/authority/incremental-graph-control.js +1051 -0
  9. package/dist/authority/index.d.ts +5 -1
  10. package/dist/authority/index.js +5 -1
  11. package/dist/authority/interrupt.js +3 -3
  12. package/dist/authority/notifier.d.ts +1 -1
  13. package/dist/authority/notifier.js +26 -20
  14. package/dist/authority/observation-store.d.ts +104 -0
  15. package/dist/authority/observation-store.js +226 -0
  16. package/dist/authority/observation.d.ts +3 -45
  17. package/dist/authority/observation.js +86 -68
  18. package/dist/authority/panel.d.ts +121 -0
  19. package/dist/authority/panel.js +44 -0
  20. package/dist/authority/projection.d.ts +5 -4
  21. package/dist/authority/projection.js +11 -11
  22. package/dist/authority/rc7-contract.json +57 -1
  23. package/dist/authority/runtime.d.ts +38 -2
  24. package/dist/authority/runtime.js +13 -14
  25. package/dist/authority/state.d.ts +19 -4
  26. package/dist/authority/state.js +14 -12
  27. package/dist/authority/store.d.ts +144 -0
  28. package/dist/authority/store.js +675 -0
  29. package/dist/authority/sync.d.ts +4 -3
  30. package/dist/authority/sync.js +20 -19
  31. package/dist/authority/tools.d.ts +41 -4
  32. package/dist/authority/tools.js +63 -87
  33. package/dist/testing.d.ts +11 -0
  34. package/dist/testing.js +10 -0
  35. package/package.json +7 -1
@@ -1,12 +1,13 @@
1
1
  import { type Tx, type EntryId } from '@earendil-works/pi-durable';
2
2
  import type { CoreApi } from './tools.ts';
3
- import { type State, type Event, type Result } from './state.ts';
3
+ import { type Event, type Result } from './state.ts';
4
+ import { type Authority as State } from './store.ts';
4
5
  export declare const UPDATE_PAGE_EVENTS = 50;
5
6
  export declare const UPDATE_SCAN_ENTRIES = 100;
6
- export declare function updates(s: State, events: Event[]): Result;
7
+ export declare function updates(s: State, events: Event[]): Promise<Result>;
7
8
  /** Page native history without making the journal document an unbounded cache. */
8
9
  export declare function pageEvents(tx: Tx, s: State, from: number, after?: EntryId): Promise<{
9
10
  events: Event[];
10
11
  scanThrough?: EntryId;
11
12
  }>;
12
- export declare function sync(tx: Tx, s: State, view: 'snapshot' | 'updates' | undefined, api: CoreApi, snapshot: Result): Promise<Result>;
13
+ export declare function sync(tx: Tx, s: State, view: 'snapshot' | 'updates' | undefined, api: CoreApi, snapshot: () => Promise<Result>): Promise<Result>;
@@ -1,13 +1,12 @@
1
1
  import { LiveDoc } from '@earendil-works/pi-durable';
2
- import { GraphTaskController } from "./graph-control.js";
3
- import { projectGraphTaskCards } from "./graph-card.js";
4
- import { observations, reservationHead, reserve } from "./observation.js";
5
- import { plain } from "./state.js";
2
+ import { openObservations, reservationHead, reserve, pages, savePage } from "./observation-store.js";
3
+ import { taskCards, recentEvents } from "./store.js";
6
4
  export const UPDATE_PAGE_EVENTS = 50;
7
5
  export const UPDATE_SCAN_ENTRIES = 100;
8
- export function updates(s, events) {
9
- const team = s.team.current, cards = projectGraphTaskCards(GraphTaskController.recover(plain(s.graph.snapshot)));
6
+ export async function updates(s, events) {
7
+ const team = s.team.current;
10
8
  const ids = [...new Set(events.flatMap(e => e.taskIds ?? (e.taskId ? [e.taskId] : [])))];
9
+ const cards = await taskCards(s, ids);
11
10
  return { kind: 'updates',
12
11
  team_changes: events.filter(e => e.kind === 'team_created' || e.kind === 'team_stopped').map(e => ({ kind: e.kind === 'team_created' ? 'created' : 'lifecycle', text: e.kind === 'team_created' ? team.purpose : 'Team stopped.' })),
13
12
  worker_changes: events.filter(e => e.worker && ['worker_created', 'worker_stopped'].includes(e.kind)).map(e => ({ worker: e.worker, scope: team.workers[e.worker]?.scope ?? 'Historical Worker', kind: e.kind === 'worker_created' ? 'created' : 'stopped', text: e.kind === 'worker_created' ? 'Worker created.' : 'Worker stopped.' })),
@@ -18,7 +17,7 @@ export function updates(s, events) {
18
17
  export async function pageEvents(tx, s, from, after) {
19
18
  const recent = s.journal.events.filter(e => e.seq > from);
20
19
  if (recent.length && s.journal.events[0].seq <= from + 1) {
21
- const events = recent.slice(0, UPDATE_PAGE_EVENTS);
20
+ const events = await recentEvents(s, recent.slice(0, UPDATE_PAGE_EVENTS));
22
21
  return { events, scanThrough: events.at(-1)?.entryId };
23
22
  }
24
23
  const native = await tx.scanEntries({ conversationId: s.team.current.lead, order: 'ascending', ...(after ? { minEntryId: after } : {}) }, UPDATE_SCAN_ENTRIES);
@@ -42,36 +41,38 @@ export async function sync(tx, s, view, api, snapshot) {
42
41
  const team = s.team.current, live = await tx.doc(LiveDoc, api.conversationId), run = live.run?.inputs[0];
43
42
  if (run === undefined)
44
43
  return { kind: 'unavailable', reason: 'team_state_unavailable', message: 'Observation requires a persisted lead run.' };
45
- const o = await observations(tx);
46
- o.paused = false;
47
- if (Object.values(o.pages).some(p => p.epoch === team.epoch && p.run !== run && ['reserved', 'presented'].includes(p.state)))
44
+ const o = await openObservations(tx);
45
+ o.head.paused = false;
46
+ const active = await pages(o, 'openPages');
47
+ if (active.some(p => p.epoch === team.epoch && p.run !== run))
48
48
  return { kind: 'refused', reason: 'observation_in_progress', message: 'Another run or automatic delivery owns an unacknowledged page.', state_changed: false, observation_advanced: false };
49
- const id = `${team.epoch}:sync:${api.taskId}`, from = reservationHead(o, team.epoch, run);
49
+ const id = `${team.epoch}:sync:${api.taskId}`, from = await reservationHead(o, team.epoch, run);
50
50
  const carrier = { kind: 'tool', callId: api.callId ?? String(api.taskId) };
51
51
  // Failed-run pages reappear once in order; the old replay receipt remains immutable.
52
- const released = Object.values(o.pages).find(p => p.epoch === team.epoch && p.state === 'released');
52
+ const released = (await pages(o, 'releasedPages', true)).find(p => p.epoch === team.epoch);
53
53
  if (released) {
54
54
  const result = JSON.parse(released.body);
55
55
  released.state = 'replaced';
56
- reserve(o, { id, epoch: team.epoch, lead: team.lead, run, from: released.from, through: released.through, carrier, ...(released.scanThrough ? { scanThrough: released.scanThrough } : {}) }, result);
56
+ await savePage(o, released);
57
+ await reserve(o, { id, epoch: team.epoch, lead: team.lead, run, from: released.from, through: released.through, carrier, ...(released.scanThrough ? { scanThrough: released.scanThrough } : {}) }, result);
57
58
  return result;
58
59
  }
59
- const outstanding = Object.values(o.pages).filter(p => p.epoch === team.epoch && p.run === run && ['reserved', 'presented'].includes(p.state));
60
+ const outstanding = active.filter(p => p.epoch === team.epoch && p.run === run);
60
61
  if ((view !== 'snapshot' || outstanding.length > 0) && from >= s.journal.seq)
61
62
  return { kind: 'caught_up', head: s.journal.seq, epoch_id: team.epoch };
62
63
  let result, through, scanThrough;
63
64
  if (!outstanding.length && (view === 'snapshot' || from === 0)) {
64
- result = snapshot;
65
+ result = await snapshot();
65
66
  through = s.journal.seq;
66
67
  scanThrough = s.journal.events.at(-1)?.entryId;
67
68
  }
68
69
  else {
69
- const previous = Object.values(o.pages).filter(p => p.epoch === team.epoch && p.scanThrough !== undefined && (p.state === 'acknowledged' || p.run === run && ['reserved', 'presented'].includes(p.state))).sort((a, b) => Number(b.scanThrough) - Number(a.scanThrough))[0];
70
- const page = await pageEvents(tx, s, from, previous?.scanThrough);
71
- result = updates(s, page.events);
70
+ const previous = Math.max(o.head.scanByEpoch[team.epoch] ?? 0, ...outstanding.map(p => p.scanThrough ?? 0));
71
+ const page = await pageEvents(tx, s, from, previous || undefined);
72
+ result = await updates(s, page.events);
72
73
  through = page.events.at(-1)?.seq ?? from;
73
74
  scanThrough = page.scanThrough;
74
75
  }
75
- reserve(o, { id, epoch: team.epoch, lead: team.lead, run, from, through, carrier, ...(scanThrough ? { scanThrough } : {}) }, result);
76
+ await reserve(o, { id, epoch: team.epoch, lead: team.lead, run, from, through, carrier, ...(scanThrough ? { scanThrough } : {}) }, result);
76
77
  return result;
77
78
  }
@@ -4,7 +4,8 @@ import { type Role, type ToolName } from './contracts.ts';
4
4
  import { type GraphTaskTransition, type GraphVersionRef } from './graph-control.ts';
5
5
  import type { TaskVersionRef } from './task-version-ref.ts';
6
6
  import type { ConversationId } from '@earendil-works/pi-durable';
7
- import { type Binding, type ModelRole, type ModelRoleCatalog, type Result, type State, type StopIntent } from './state.ts';
7
+ import { type Binding, type ModelRole, type ModelRoleCatalog, type Result, type StopIntent } from './state.ts';
8
+ import { type Authority as State } from './store.ts';
8
9
  export type CoreApi = Pick<ToolExecutionApi, 'taskId' | 'conversationId' | 'commit' | 'now'> & Partial<Pick<ToolExecutionApi, 'callId'>>;
9
10
  export interface CoreOptions {
10
11
  modelRoles: ModelRole[];
@@ -22,7 +23,7 @@ export interface CoreOptions {
22
23
  stops: string[];
23
24
  epoch: string;
24
25
  }, api: CoreApi) => Promise<void>;
25
- sync?: (tx: Tx, s: State, view: Args['view'], api: CoreApi, snapshot: Result) => Promise<Result>;
26
+ sync?: (tx: Tx, s: State, view: Args['view'], api: CoreApi, snapshot: () => Promise<Result>) => Promise<Result>;
26
27
  }
27
28
  interface Args {
28
29
  name?: string;
@@ -60,8 +61,44 @@ interface Args {
60
61
  export declare function catalogOf(options: Pick<CoreOptions, 'modelRoles' | 'defaultModelRole' | 'catalog'>): (tx: Tx, lead: ConversationId) => Promise<ModelRoleCatalog>;
61
62
  /** Authority commands commit intent and replay receipts; injected ports own effects. */
62
63
  export declare function createAuthorityTools(options: CoreOptions): {
63
- lead: import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>[];
64
- worker: import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>[];
64
+ lead: {
65
+ label: string;
66
+ name: string;
67
+ description: string;
68
+ parameters: import("typebox").TSchema;
69
+ constrainedSampling?: false | import("@earendil-works/pi-ai").ConstrainedSamplingConfig;
70
+ replay?: "safe" | "unsafe";
71
+ executionMode?: import("@earendil-works/pi-durable").ToolExecutionMode;
72
+ prepareArguments?(args: unknown): unknown;
73
+ structuredOutputSchema?: import("typebox").TSchema;
74
+ callers?: readonly ("model" | "tools")[];
75
+ deferred?: true;
76
+ outputLimits?: {
77
+ readonly maxBytes?: number;
78
+ readonly maxLines?: number;
79
+ readonly retain?: "head" | "tail";
80
+ };
81
+ execute(args: unknown, api: ToolExecutionApi<import("@earendil-works/chord").JsonValue>, context: Context): Promise<import("@earendil-works/pi-durable").ToolExecutionResult<import("@earendil-works/chord").JsonValue>>;
82
+ }[];
83
+ worker: {
84
+ label: string;
85
+ name: string;
86
+ description: string;
87
+ parameters: import("typebox").TSchema;
88
+ constrainedSampling?: false | import("@earendil-works/pi-ai").ConstrainedSamplingConfig;
89
+ replay?: "safe" | "unsafe";
90
+ executionMode?: import("@earendil-works/pi-durable").ToolExecutionMode;
91
+ prepareArguments?(args: unknown): unknown;
92
+ structuredOutputSchema?: import("typebox").TSchema;
93
+ callers?: readonly ("model" | "tools")[];
94
+ deferred?: true;
95
+ outputLimits?: {
96
+ readonly maxBytes?: number;
97
+ readonly maxLines?: number;
98
+ readonly retain?: "head" | "tail";
99
+ };
100
+ execute(args: unknown, api: ToolExecutionApi<import("@earendil-works/chord").JsonValue>, context: Context): Promise<import("@earendil-works/pi-durable").ToolExecutionResult<import("@earendil-works/chord").JsonValue>>;
101
+ }[];
65
102
  execute: (name: ToolName, role: Role, input: unknown, api: CoreApi, context: Context) => Promise<Result>;
66
103
  contract: import("./contracts.ts").Contract;
67
104
  };
@@ -1,11 +1,10 @@
1
1
  import { defineTool, LiveDoc } from '@earendil-works/pi-durable';
2
- import { Check } from 'typebox/value';
3
- import { contracts, checkResult, TOOL_NAMES } from "./contracts.js";
4
- import { GraphTaskController, GraphControlRefusal } from "./graph-control.js";
2
+ import { contracts, checkSchema, checkResult, TOOL_NAMES } from "./contracts.js";
3
+ import { GraphControlRefusal } from "./graph-control.js";
5
4
  import { projectGraphTaskCards } from "./graph-card.js";
6
5
  import { snapshotResult } from "./projection.js";
7
- import { open, plain, event } from "./state.js";
8
- const terminal = (kind) => ['goal_achieved', 'goal_failed', 'cancelled'].includes(kind);
6
+ import { plain, event } from "./state.js";
7
+ import { openAuthority as open, startEpoch, taskCards, unfinished, graphCommand, hasOperation, addAlert, addObligation, callReceipt, saveCall, allObligations, setObligationState } from "./store.js";
9
8
  const LEAD = 'team-lead';
10
9
  class Declined extends Error {
11
10
  result;
@@ -27,53 +26,24 @@ function caller(s, api, role, name, args) {
27
26
  return unavailable(name, args, 'no_active_team', 'No active Team.');
28
27
  if (role === 'lead' && team.lead === api.conversationId)
29
28
  return LEAD;
30
- const worker = Object.values(team.workers).find(w => w.state === 'active' && w.conversationId === api.conversationId);
29
+ const workerName = s.head.workerBindings[String(api.conversationId)];
30
+ const candidate = workerName ? team.workers[workerName] : undefined;
31
+ const worker = candidate?.state === 'active' && candidate.conversationId === api.conversationId ? candidate : undefined;
31
32
  if (role === 'worker' && worker)
32
33
  return worker.name;
33
34
  return unavailable(name, args, failureReason(name), 'Caller is not the exact current Team binding.');
34
35
  }
35
- function controller(s, api, name, args) {
36
- if (!s.graph.snapshot || s.graph.epoch !== s.team.current?.epoch)
36
+ function controller(s, _api, name, args) {
37
+ if (!s.head.graph || s.head.epoch !== s.team.current?.epoch)
37
38
  return unavailable(name, args, failureReason(name), 'Established graph authority is missing.');
38
- try {
39
- return GraphTaskController.recover(plain(s.graph.snapshot), () => new Date(api.now()));
40
- }
41
- catch {
39
+ if (!Number.isSafeInteger(s.head.graph.sequence) || !s.head.graph.ready || !s.head.graph.occupied)
42
40
  return unavailable(name, args, failureReason(name), 'Established graph authority is corrupt.');
43
- }
44
41
  }
45
42
  function modelTask(t) {
46
43
  const [card] = projectGraphTaskCards({ readTasks: () => [t], readAttempts: () => [], selectReadyFrontier: () => [] });
47
44
  return { id: t.id, status: t.state.kind, assignee: t.assignee, needs: t.needs, state: card.state, attempts_started: t.attemptsStarted, version: t.version,
48
45
  ...('attemptId' in t.state ? { current_attempt_id: t.state.attemptId } : {}), ...(t.acceptedAttemptId ? { accepted_attempt_id: t.acceptedAttemptId } : {}) };
49
46
  }
50
- function reconcile(s, graph, now) {
51
- const team = s.team.current;
52
- const byId = new Map(graph.readTasks().map(t => [t.id, t]));
53
- for (const obligation of Object.values(s.outbox.obligations)) {
54
- if (obligation.epoch !== team.epoch || obligation.kind !== 'task' || obligation.state !== 'pending')
55
- continue;
56
- const t = byId.get(obligation.taskId);
57
- if (!t || t.version !== obligation.version || t.assignee !== obligation.recipient || t.state.kind !== 'ready')
58
- obligation.state = 'retired';
59
- }
60
- for (const t of graph.selectReadyFrontier()) {
61
- const w = team.workers[t.assignee];
62
- if (!w || w.state !== 'active')
63
- continue;
64
- const key = `${team.epoch}:task:${t.id}:${t.version}:g${w.generation}`;
65
- if (!Object.hasOwn(s.outbox.obligations, key))
66
- s.outbox.obligations[key] = { key, epoch: team.epoch, kind: 'task', recipient: w.name, conversationId: w.conversationId, generation: w.generation, taskId: t.id, version: t.version, state: 'pending', acceptedAt: now };
67
- }
68
- for (const attempt of graph.readAttempts().filter(a => a.state === 'superseded' || a.state === 'cancelled')) {
69
- const w = team.workers[attempt.assignee];
70
- if (!w || w.state !== 'active')
71
- continue;
72
- const key = `${team.epoch}:stop:${attempt.id}:g${w.generation}`;
73
- if (!Object.hasOwn(s.outbox.obligations, key))
74
- s.outbox.obligations[key] = { key, epoch: team.epoch, kind: 'stop', recipient: w.name, conversationId: w.conversationId, generation: w.generation, taskId: attempt.taskId, state: 'pending', acceptedAt: now };
75
- }
76
- }
77
47
  /** The catalog resolver that options select: the host's per-lead port, else the static list. */
78
48
  export function catalogOf(options) {
79
49
  return async (tx, lead) => options.catalog ? options.catalog(tx, lead) : { modelRoles: options.modelRoles, ...(options.defaultModelRole ? { defaultModelRole: options.defaultModelRole } : {}) };
@@ -95,12 +65,9 @@ export function createAuthorityTools(options) {
95
65
  if (s.team.current.lead !== api.conversationId)
96
66
  return unavailable(name, a, 'team_authority_unavailable', 'Only the established lead can create the next Team.');
97
67
  s.team.history.push(plain(s.team.current));
98
- if (s.graph.snapshot)
99
- s.graph.history = { ...s.graph.history, [s.team.current.epoch]: plain(s.graph.snapshot) };
100
68
  }
101
69
  s.team.current = { epoch: `team:${api.taskId}`, name: a.name, purpose: a.purpose, lead: api.conversationId, lifecycle: 'active', workers: {}, createdAt: api.now() };
102
- s.graph.epoch = s.team.current.epoch;
103
- s.graph.snapshot = new GraphTaskController(undefined, () => new Date(api.now())).snapshot();
70
+ await startEpoch(s, s.team.current.epoch);
104
71
  delete s.journal.reservation;
105
72
  s.journal.acknowledged = 0;
106
73
  await event(tx, s, LEAD, 'team_created', { at: new Date(api.now()).toISOString() });
@@ -132,41 +99,40 @@ export function createAuthorityTools(options) {
132
99
  selected = await options.resolveModelRole(tx, plain(selected));
133
100
  const binding = await options.createWorker(tx, plain(selected));
134
101
  team.workers = { ...plain(team.workers), [a.name]: { ...binding, name: a.name, scope: a.scope, generation: (prior?.generation ?? 0) + 1, state: 'active', role: plain(selected), createdAt: api.now() } };
102
+ if (prior) {
103
+ delete s.head.workerBindings[String(prior.conversationId)];
104
+ s.head.stopRefresh = true;
105
+ }
106
+ s.head.workerBindings[String(binding.conversationId)] = a.name;
135
107
  await event(tx, s, actor, 'worker_created', { worker: a.name, at: new Date(api.now()).toISOString() });
136
108
  return { kind: 'worker_ensured', effect: prior ? 'reconnected' : 'created', worker: { name: a.name, carrier: 'connected', model_role: selected.name } };
137
109
  }
138
- const graph = controller(s, api, name, a);
110
+ controller(s, api, name, a);
139
111
  if (name === 'team_shutdown' && team.lifecycle === 'stopped')
140
- return { kind: 'team_shutdown', lifecycle: 'stopped', stopped_workers: Object.values(team.workers).filter(w => w.state === 'stopped').map(w => w.name), unfinished_task_ids: graph.readTasks().filter(t => !terminal(t.state.kind)).map(t => t.id) };
112
+ return { kind: 'team_shutdown', lifecycle: 'stopped', stopped_workers: Object.values(team.workers).filter(w => w.state === 'stopped').map(w => w.name), unfinished_task_ids: await unfinished(s) };
141
113
  if (name === 'task_read') {
142
- const cards = new Map(projectGraphTaskCards(graph).map(t => [t.id, t]));
114
+ const cards = new Map((await taskCards(s, role === 'worker' ? [a.task_id] : a.task_ids)).map(t => [t.id, t]));
143
115
  if (role === 'worker')
144
116
  return cards.has(a.task_id) ? { kind: 'found', task: cards.get(a.task_id) } : { kind: 'missing', task_id: a.task_id, reason: 'task_not_found' };
145
117
  return { kind: 'task_read_batch', outcomes: a.task_ids.map((id, input_index) => cards.has(id) ? { kind: 'found', input_index, task_id: id, task: cards.get(id) } : { kind: 'missing', input_index, task_id: id, reason: 'task_not_found' }) };
146
118
  }
147
119
  if (name === 'task_graph_apply') {
148
120
  // The controller owns replay identity. Check current assignees only for a new operation.
149
- const already = graph.snapshot().receipts.some(r => r.operationId === a.operation_id);
121
+ const already = await hasOperation(s, a.operation_id);
150
122
  if (!already && a.tasks.some(t => !Object.hasOwn(team.workers, t.assignee) || team.workers[t.assignee].state !== 'active'))
151
123
  return decline({ kind: 'refused', operation_id: a.operation_id, reason: 'worker_unavailable', message: 'Every assignee must be a current Worker.' });
152
124
  const tasks = a.tasks.map(t => ({ key: t.key, title: t.title, goal: t.goal, assignee: t.assignee, needs: t.needs, ...(t.on_goal_failed ? { onGoalFailed: { target: t.on_goal_failed.target, maxTraversals: t.on_goal_failed.max_traversals } } : {}) }));
153
- const r = graph.applyGraph({ operationId: a.operation_id, expectedGraphVersion: a.expected_graph_version, tasks });
154
- if (!r.replayed) {
155
- s.graph.snapshot = graph.snapshot();
125
+ const r = await graphCommand(s, { kind: 'apply', input: { operationId: a.operation_id, expectedGraphVersion: a.expected_graph_version, tasks } }, api.now);
126
+ if (!r.replayed)
156
127
  await event(tx, s, actor, 'graph_applied', { taskIds: r.tasks.map(t => t.id), at: new Date(api.now()).toISOString() });
157
- reconcile(s, graph, api.now());
158
- }
159
128
  return { kind: 'task_graph_applied', operation_id: r.operationId, graph_version: r.graphVersion, replayed: r.replayed, tasks_by_key: Object.fromEntries(r.tasks.map(t => [t.id, modelTask(t)])), ready_task_ids: r.readyTaskIds };
160
129
  }
161
130
  if (name === 'task_update') {
162
131
  if (role === 'worker' && a.transition === 'cancel')
163
132
  return decline({ kind: 'refused', task_id: a.task_id, operation_id: a.operation_id, reason: 'worker_mismatch', message: 'Only the lead cancels Tasks.' });
164
- const r = graph.transition({ taskId: a.task_id, operationId: a.operation_id, expectedVersion: a.expected_version, transition: a.transition, currentContext: a.current_context, evidence: a.evidence, worker: role === 'worker' ? actor : a.transition && a.transition !== 'cancel' ? LEAD : undefined });
165
- if (!r.replayed) {
166
- s.graph.snapshot = graph.snapshot();
133
+ const r = await graphCommand(s, { kind: 'transition', input: { taskId: a.task_id, operationId: a.operation_id, expectedVersion: a.expected_version, transition: a.transition, currentContext: a.current_context, evidence: a.evidence, worker: role === 'worker' ? actor : a.transition && a.transition !== 'cancel' ? LEAD : undefined } }, api.now);
134
+ if (!r.replayed)
167
135
  await event(tx, s, actor, 'task_transitioned', { taskId: r.task.id, at: new Date(api.now()).toISOString(), text: a.evidence ?? a.current_context ?? r.transition });
168
- reconcile(s, graph, api.now());
169
- }
170
136
  return { kind: 'updated', operation_id: r.operationId, replayed: r.replayed, transition: r.transition, task: { id: r.task.id, status: r.task.state.kind, assignee: r.task.assignee, version: r.task.version }, ready_task_ids: r.readyTaskIds, ...(r.failureTraversal ? { failure_traversal: { source_task_id: r.failureTraversal.sourceTaskId, target_task_id: r.failureTraversal.targetTaskId, traversal: r.failureTraversal.traversal } } : {}) };
171
137
  }
172
138
  if (name === 'alert_send') {
@@ -177,11 +143,11 @@ export function createAuthorityTools(options) {
177
143
  if (!recipients.length)
178
144
  return decline({ kind: 'refused', reason: to === '*' ? 'no_eligible_recipients' : 'recipient_not_current', message: 'No current recipient.' });
179
145
  const id = `${team.epoch}:alert:${api.taskId}`;
180
- s.outbox.alerts[id] = plain({ id, from: actor, kind: a.kind, text: a.text.trim(), ...(a.interrupt ? { interrupt: true } : {}), task_id: a.task_id, task_version: a.task_version, receivedAt: new Date(api.now()).toISOString(), recipients });
146
+ await addAlert(s, plain({ id, from: actor, kind: a.kind, text: a.text.trim(), ...(a.interrupt ? { interrupt: true } : {}), task_id: a.task_id, task_version: a.task_version, receivedAt: new Date(api.now()).toISOString(), recipients }));
181
147
  for (const recipient of recipients) {
182
148
  const key = `${id}:${recipient.name}:g${recipient.generation}`;
183
149
  const run = a.interrupt ? (await tx.doc(LiveDoc, recipient.conversationId)).run?.taskId : undefined;
184
- s.outbox.obligations[key] = { key, epoch: team.epoch, kind: 'alert', recipient: recipient.name, conversationId: recipient.conversationId, generation: recipient.generation, alertId: id, state: 'pending', acceptedAt: api.now(), ...(a.interrupt ? { interrupt: true, ...(run !== undefined ? { interruptRun: run } : {}) } : {}) };
150
+ await addObligation(s, { key, epoch: team.epoch, kind: 'alert', recipient: recipient.name, conversationId: recipient.conversationId, generation: recipient.generation, alertId: id, state: 'pending', acceptedAt: api.now(), ...(a.interrupt ? { interrupt: true, ...(run !== undefined ? { interruptRun: run } : {}) } : {}) });
185
151
  }
186
152
  await event(tx, s, actor, 'alert_accepted', { at: new Date(api.now()).toISOString() });
187
153
  return { kind: 'alert_sent', accepted_recipients: recipients.map(r => r.name), failed_recipients: [], ...(a.task_id ? { task_id: a.task_id } : {}), ...(a.task_version ? { task_version: a.task_version } : {}) };
@@ -192,8 +158,8 @@ export function createAuthorityTools(options) {
192
158
  return { kind: 'refused', reason: 'observation_in_progress', message: 'An observation awaits presentation acknowledgement.', state_changed: false, observation_advanced: false };
193
159
  if (!options.sync)
194
160
  s.journal.reservation = { epoch: team.epoch, call: String(api.taskId), through: s.journal.seq };
195
- const roles = await rolesOf(tx, team.lead), snapshot = snapshotResult(s, roles.modelRoles, roles.defaultModelRole);
196
- return options.sync ? options.sync(tx, s, a.view, api, snapshot) : snapshot;
161
+ const roles = await rolesOf(tx, team.lead), snapshot = () => snapshotResult(s, roles.modelRoles, roles.defaultModelRole);
162
+ return options.sync ? options.sync(tx, s, a.view, api, snapshot) : snapshot();
197
163
  }
198
164
  // Lifecycle effects happen after the committed stop intent, outside this transaction.
199
165
  let workers;
@@ -203,7 +169,7 @@ export function createAuthorityTools(options) {
203
169
  const worker = Object.hasOwn(team.workers, a.worker) ? team.workers[a.worker] : undefined;
204
170
  if (!worker)
205
171
  return decline({ kind: 'refused', reason: 'worker_not_found', worker: a.worker });
206
- const held = graph.readTasks().filter(t => t.assignee === worker.name && !terminal(t.state.kind)).map(t => t.id);
172
+ const held = await unfinished(s, worker.name);
207
173
  if (held.length)
208
174
  return decline({ kind: 'refused', reason: 'nonterminal_tasks_assigned', worker: worker.name, guarding_task_ids: held });
209
175
  if (worker.state === 'stopped')
@@ -215,26 +181,28 @@ export function createAuthorityTools(options) {
215
181
  if (workers.length && !options.stopWorker && !options.settleStops)
216
182
  return unavailable(name, a, name === 'worker_stop' ? 'carrier_unavailable' : 'team_authority_unavailable', 'Anchor stop adapter is not installed.');
217
183
  const stops = workers.map(w => { const key = `${team.epoch}:stop:${w.name}:g${w.generation}`; w.state = 'stopping'; s.team.stops[key] ??= { epoch: team.epoch, worker: w.name, binding: { conversationId: w.conversationId, anchorId: w.anchorId }, generation: w.generation, tasks: [w.anchorId], conversations: [w.conversationId], confirmed: false }; return key; });
218
- for (const obligation of Object.values(s.outbox.obligations))
184
+ for (const obligation of await allObligations(s))
219
185
  if (obligation.epoch === team.epoch && workers.some(w => w.name === obligation.recipient))
220
- obligation.state = 'retired';
186
+ await setObligationState(s, obligation, 'retired');
221
187
  if (name === 'team_shutdown')
222
188
  team.shutdownPending = true;
223
189
  return { stops, epoch: team.epoch };
224
190
  }
225
191
  async function execute(name, role, input, api, context) {
226
192
  const schema = contract[role][name];
227
- if (!schema || !Check(schema, input))
193
+ if (!schema || !checkSchema(schema, input))
228
194
  throw new Error(`Invalid ${role} ${name} parameters`);
229
195
  const a = input;
230
- const key = String(api.taskId), fingerprint = JSON.stringify({ name, role, input, conversation: api.conversationId });
196
+ const fingerprint = JSON.stringify({ name, role, input, conversation: api.conversationId });
231
197
  try {
232
198
  const result = await api.commit(async (tx) => {
233
- const s = await open(tx), saved = Object.hasOwn(s.receipts.calls, key) ? s.receipts.calls[key] : undefined;
199
+ const native = (await tx.task(api.taskId))?.kind === 'pi.tool';
200
+ const s = await open(tx), receipt = await callReceipt(s, api.taskId, native), saved = receipt.fingerprint ? receipt : undefined;
201
+ s.deferFlush = true;
234
202
  if (saved) {
235
203
  if (saved.fingerprint !== fingerprint)
236
204
  throw new Error('Durable tool identity reused with changed input');
237
- return plain(saved.result ?? { stops: saved.stops, epoch: saved.epoch });
205
+ return saved.result ? JSON.parse(saved.result) : plain({ stops: saved.stops, epoch: saved.epoch });
238
206
  }
239
207
  const r = await command(name, role, a, api, tx, s);
240
208
  if (name !== 'task_read')
@@ -242,10 +210,10 @@ export function createAuthorityTools(options) {
242
210
  if ('kind' in r) {
243
211
  const clean = plain(checkResult(contract, name, r));
244
212
  if (name !== 'task_read')
245
- s.receipts.calls[key] = { fingerprint, result: clean };
213
+ await saveCall(s, api.taskId, { fingerprint, result: clean });
246
214
  return clean;
247
215
  }
248
- s.receipts.calls[key] = { fingerprint, stops: r.stops, epoch: r.epoch };
216
+ await saveCall(s, api.taskId, { fingerprint, stops: r.stops, epoch: r.epoch });
249
217
  return plain(r);
250
218
  }, context);
251
219
  if ('kind' in result)
@@ -269,6 +237,7 @@ export function createAuthorityTools(options) {
269
237
  }
270
238
  return await api.commit(async (tx) => {
271
239
  const s = await open(tx), team = s.team.current;
240
+ await callReceipt(s, api.taskId);
272
241
  if (team.epoch !== result.epoch)
273
242
  throw new Error('Team epoch changed during stop');
274
243
  const stopped = result.stops.filter(id => s.team.stops[id].confirmed && s.team.stops[id].worker).map(id => s.team.stops[id].worker), failed = result.stops.filter(id => !s.team.stops[id].confirmed).map(id => s.team.stops[id].worker ?? LEAD);
@@ -276,15 +245,16 @@ export function createAuthorityTools(options) {
276
245
  if (name === 'worker_stop')
277
246
  r = failed.length ? { kind: 'refused', reason: 'stop_not_confirmed', worker: a.worker } : { kind: 'worker_stopped', worker: a.worker };
278
247
  else {
279
- const unfinished = controller(s, api, name, a).readTasks().filter(t => !terminal(t.state.kind)).map(t => t.id);
248
+ controller(s, api, name, a);
249
+ const remaining = await unfinished(s);
280
250
  if (!failed.length && team.lifecycle !== 'stopped') {
281
251
  team.lifecycle = 'stopped';
282
252
  await event(tx, s, LEAD, 'team_stopped', { at: new Date(api.now()).toISOString() });
283
253
  }
284
- r = failed.length ? { kind: 'partial', lifecycle: 'active', stopped_workers: stopped, failed_workers: failed, unfinished_task_ids: unfinished, recovery: { action: 'retry_team_shutdown' } } : { kind: 'team_shutdown', lifecycle: 'stopped', stopped_workers: stopped, unfinished_task_ids: unfinished };
254
+ r = failed.length ? { kind: 'partial', lifecycle: 'active', stopped_workers: stopped, failed_workers: failed, unfinished_task_ids: remaining, recovery: { action: 'retry_team_shutdown' } } : { kind: 'team_shutdown', lifecycle: 'stopped', stopped_workers: stopped, unfinished_task_ids: remaining };
285
255
  }
286
256
  const clean = plain(checkResult(contract, name, r));
287
- s.receipts.calls[key] = { fingerprint, result: clean };
257
+ await saveCall(s, api.taskId, { fingerprint, result: clean });
288
258
  return clean;
289
259
  }, context);
290
260
  }
@@ -303,24 +273,30 @@ export function createAuthorityTools(options) {
303
273
  if (name !== 'task_read')
304
274
  await api.commit(async (tx) => {
305
275
  const s = await open(tx);
306
- s.receipts.calls[key] = { fingerprint, result: clean };
276
+ await saveCall(s, api.taskId, { fingerprint, result: clean });
307
277
  }, context);
308
278
  return clean;
309
279
  }
310
280
  }
311
281
  function tools(role) {
312
- return TOOL_NAMES.filter(name => contract[role][name]).map(name => defineTool({
313
- name, description: `${name}: PTB rc.7 authority operation.`, parameters: contract[role][name], replay: 'safe',
314
- execute: async (args, api, context) => {
315
- const result = await execute(name, role, args, api, context);
316
- const pending = options.settleStops && (result.kind === 'partial' || result.reason === 'stop_not_confirmed');
317
- const names = result.kind === 'partial' ? result.failed_workers.map(n => n === LEAD ? 'Team delivery and notification tasks' : n).join(', ') : String(result.worker);
318
- const output = [{ type: 'text', text: JSON.stringify(result) }];
319
- if (pending)
320
- output.push({ type: 'text', text: `Still stopping: ${names}. The stop is recorded and no new work starts. Settlement continues in the background. A later ${name} confirms completion.` });
321
- return { output };
322
- },
323
- }));
282
+ return TOOL_NAMES.filter(name => contract[role][name]).map(name => {
283
+ const presentation = contract.presentation[role][name];
284
+ if (!presentation)
285
+ throw new Error(`The rc.7 contract has no ${role} ${name} label and description`);
286
+ // pi-durable registers no label. It stays on the tool for hosts that show one, as Pi does.
287
+ return { ...defineTool({
288
+ name, description: presentation.description, parameters: contract[role][name], replay: 'safe',
289
+ execute: async (args, api, context) => {
290
+ const result = await execute(name, role, args, api, context);
291
+ const pending = options.settleStops && (result.kind === 'partial' || result.reason === 'stop_not_confirmed');
292
+ const names = result.kind === 'partial' ? result.failed_workers.map(n => n === LEAD ? 'Team delivery and notification tasks' : n).join(', ') : String(result.worker);
293
+ const output = [{ type: 'text', text: JSON.stringify(result) }];
294
+ if (pending)
295
+ output.push({ type: 'text', text: `Still stopping: ${names}. The stop is recorded and no new work starts. Settlement continues in the background. A later ${name} confirms completion.` });
296
+ return { output };
297
+ },
298
+ }), label: presentation.label };
299
+ });
324
300
  }
325
301
  return { lead: tools('lead'), worker: tools('worker'), execute, contract };
326
302
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Unstable test support for hosts that exercise authority internals.
3
+ * It names only the internals HyperCarrier's AmPi tests import. Production code must use `./authority`.
4
+ * Names and behavior can change in any release without a major version.
5
+ */
6
+ export { Deliver } from './authority/delivery.ts';
7
+ export { createAuthorityTools, type CoreApi } from './authority/tools.ts';
8
+ export { addAlert, addObligation, flushAuthority, graphCommand, obligation, setObligationState, taskRecord, type Authority } from './authority/store.ts';
9
+ export type { Alert, Obligation } from './authority/state.ts';
10
+ export { ObservationHeadDoc, PageDoc, PageBodyDoc, reserve, startObservationEpoch } from './authority/observation.ts';
11
+ export { advanceAcknowledged, page, retireBody, savePage } from './authority/observation-store.ts';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Unstable test support for hosts that exercise authority internals.
3
+ * It names only the internals HyperCarrier's AmPi tests import. Production code must use `./authority`.
4
+ * Names and behavior can change in any release without a major version.
5
+ */
6
+ export { Deliver } from "./authority/delivery.js";
7
+ export { createAuthorityTools } from "./authority/tools.js";
8
+ export { addAlert, addObligation, flushAuthority, graphCommand, obligation, setObligationState, taskRecord } from "./authority/store.js";
9
+ export { ObservationHeadDoc, PageDoc, PageBodyDoc, reserve, startObservationEpoch } from "./authority/observation.js";
10
+ export { advanceAcknowledged, page, retireBody, savePage } from "./authority/observation-store.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypercarrier/pi-team-durable",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.0-rc.2",
4
4
  "description": "Pi Team Bright semantics on pi-durable: one Task authority per Team, Workers as durable subthreads, accepted-then-delivered notices.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,6 +21,11 @@
21
21
  "types": "./dist/authority/index.d.ts",
22
22
  "import": "./dist/authority/index.js",
23
23
  "default": "./dist/authority/index.js"
24
+ },
25
+ "./testing": {
26
+ "types": "./dist/testing.d.ts",
27
+ "import": "./dist/testing.js",
28
+ "default": "./dist/testing.js"
24
29
  }
25
30
  },
26
31
  "files": [
@@ -36,6 +41,7 @@
36
41
  "prepare": "npm run build",
37
42
  "typecheck": "tsc --noEmit",
38
43
  "test": "vitest run",
44
+ "test:oracle": "node test/oracle-run.ts",
39
45
  "verify:package": "node scripts/verify-package.mjs"
40
46
  },
41
47
  "dependencies": {