@hypercarrier/pi-team-durable 0.1.0-rc.1 → 0.1.0-rc.3

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 +130 -19
  2. package/dist/authority/contracts.d.ts +7 -0
  3. package/dist/authority/contracts.js +20 -4
  4. package/dist/authority/delivery.d.ts +25 -20
  5. package/dist/authority/delivery.js +97 -49
  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 +7 -6
  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 +61 -3
  23. package/dist/authority/runtime.d.ts +42 -2
  24. package/dist/authority/runtime.js +13 -14
  25. package/dist/authority/state.d.ts +21 -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 +48 -4
  32. package/dist/authority/tools.js +79 -91
  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
  }
@@ -1,10 +1,12 @@
1
+ import type { TSchema } from 'typebox';
1
2
  import type { Context } from '@earendil-works/chord/context';
2
3
  import { type ToolExecutionApi, type Tx } from '@earendil-works/pi-durable';
3
4
  import { type Role, type ToolName } from './contracts.ts';
4
5
  import { type GraphTaskTransition, type GraphVersionRef } from './graph-control.ts';
5
6
  import type { TaskVersionRef } from './task-version-ref.ts';
6
7
  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';
8
+ import { type Binding, type ModelRole, type ModelRoleCatalog, type Result, type StopIntent } from './state.ts';
9
+ import { type Authority as State } from './store.ts';
8
10
  export type CoreApi = Pick<ToolExecutionApi, 'taskId' | 'conversationId' | 'commit' | 'now'> & Partial<Pick<ToolExecutionApi, 'callId'>>;
9
11
  export interface CoreOptions {
10
12
  modelRoles: ModelRole[];
@@ -22,7 +24,7 @@ export interface CoreOptions {
22
24
  stops: string[];
23
25
  epoch: string;
24
26
  }, api: CoreApi) => Promise<void>;
25
- sync?: (tx: Tx, s: State, view: Args['view'], api: CoreApi, snapshot: Result) => Promise<Result>;
27
+ sync?: (tx: Tx, s: State, view: Args['view'], api: CoreApi, snapshot: () => Promise<Result>) => Promise<Result>;
26
28
  }
27
29
  interface Args {
28
30
  name?: string;
@@ -56,12 +58,54 @@ interface Args {
56
58
  task_version?: string;
57
59
  interrupt?: boolean;
58
60
  }
61
+ interface ParameterSchema extends TSchema {
62
+ properties?: Record<string, ParameterSchema>;
63
+ items?: ParameterSchema;
64
+ }
65
+ /** The input a role tool acts on. Pi's role adapters forward declared fields, including nested Task definitions. */
66
+ export declare function declaredParameters(schema: ParameterSchema, value: unknown): unknown;
59
67
  /** The catalog resolver that options select: the host's per-lead port, else the static list. */
60
68
  export declare function catalogOf(options: Pick<CoreOptions, 'modelRoles' | 'defaultModelRole' | 'catalog'>): (tx: Tx, lead: ConversationId) => Promise<ModelRoleCatalog>;
61
69
  /** Authority commands commit intent and replay receipts; injected ports own effects. */
62
70
  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>[];
71
+ lead: {
72
+ label: string;
73
+ name: string;
74
+ description: string;
75
+ parameters: TSchema;
76
+ constrainedSampling?: false | import("@earendil-works/pi-ai").ConstrainedSamplingConfig;
77
+ replay?: "safe" | "unsafe";
78
+ executionMode?: import("@earendil-works/pi-durable").ToolExecutionMode;
79
+ prepareArguments?(args: unknown): unknown;
80
+ structuredOutputSchema?: TSchema;
81
+ callers?: readonly ("model" | "tools")[];
82
+ deferred?: true;
83
+ outputLimits?: {
84
+ readonly maxBytes?: number;
85
+ readonly maxLines?: number;
86
+ readonly retain?: "head" | "tail";
87
+ };
88
+ execute(args: unknown, api: ToolExecutionApi<import("@earendil-works/chord").JsonValue>, context: Context): Promise<import("@earendil-works/pi-durable").ToolExecutionResult<import("@earendil-works/chord").JsonValue>>;
89
+ }[];
90
+ worker: {
91
+ label: string;
92
+ name: string;
93
+ description: string;
94
+ parameters: TSchema;
95
+ constrainedSampling?: false | import("@earendil-works/pi-ai").ConstrainedSamplingConfig;
96
+ replay?: "safe" | "unsafe";
97
+ executionMode?: import("@earendil-works/pi-durable").ToolExecutionMode;
98
+ prepareArguments?(args: unknown): unknown;
99
+ structuredOutputSchema?: TSchema;
100
+ callers?: readonly ("model" | "tools")[];
101
+ deferred?: true;
102
+ outputLimits?: {
103
+ readonly maxBytes?: number;
104
+ readonly maxLines?: number;
105
+ readonly retain?: "head" | "tail";
106
+ };
107
+ execute(args: unknown, api: ToolExecutionApi<import("@earendil-works/chord").JsonValue>, context: Context): Promise<import("@earendil-works/pi-durable").ToolExecutionResult<import("@earendil-works/chord").JsonValue>>;
108
+ }[];
65
109
  execute: (name: ToolName, role: Role, input: unknown, api: CoreApi, context: Context) => Promise<Result>;
66
110
  contract: import("./contracts.ts").Contract;
67
111
  };
@@ -1,12 +1,20 @@
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";
1
+ import { defineTool } from '@earendil-works/pi-durable';
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';
9
+ /** The input a role tool acts on. Pi's role adapters forward declared fields, including nested Task definitions. */
10
+ export function declaredParameters(schema, value) {
11
+ const { items, properties } = schema;
12
+ if (Array.isArray(value) && items)
13
+ return value.map(item => declaredParameters(items, item));
14
+ if (value !== null && typeof value === 'object' && !Array.isArray(value) && properties)
15
+ return Object.fromEntries(Object.entries(value).filter(([key]) => Object.hasOwn(properties, key)).map(([key, item]) => [key, declaredParameters(properties[key], item)]));
16
+ return value;
17
+ }
10
18
  class Declined extends Error {
11
19
  result;
12
20
  constructor(result) { super(String(result.message ?? result.reason)); this.result = result; }
@@ -27,53 +35,24 @@ function caller(s, api, role, name, args) {
27
35
  return unavailable(name, args, 'no_active_team', 'No active Team.');
28
36
  if (role === 'lead' && team.lead === api.conversationId)
29
37
  return LEAD;
30
- const worker = Object.values(team.workers).find(w => w.state === 'active' && w.conversationId === api.conversationId);
38
+ const workerName = s.head.workerBindings[String(api.conversationId)];
39
+ const candidate = workerName ? team.workers[workerName] : undefined;
40
+ const worker = candidate?.state === 'active' && candidate.conversationId === api.conversationId ? candidate : undefined;
31
41
  if (role === 'worker' && worker)
32
42
  return worker.name;
33
43
  return unavailable(name, args, failureReason(name), 'Caller is not the exact current Team binding.');
34
44
  }
35
- function controller(s, api, name, args) {
36
- if (!s.graph.snapshot || s.graph.epoch !== s.team.current?.epoch)
45
+ function controller(s, _api, name, args) {
46
+ if (!s.head.graph || s.head.epoch !== s.team.current?.epoch)
37
47
  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 {
48
+ if (!Number.isSafeInteger(s.head.graph.sequence) || !s.head.graph.ready || !s.head.graph.occupied)
42
49
  return unavailable(name, args, failureReason(name), 'Established graph authority is corrupt.');
43
- }
44
50
  }
45
51
  function modelTask(t) {
46
52
  const [card] = projectGraphTaskCards({ readTasks: () => [t], readAttempts: () => [], selectReadyFrontier: () => [] });
47
53
  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
54
  ...('attemptId' in t.state ? { current_attempt_id: t.state.attemptId } : {}), ...(t.acceptedAttemptId ? { accepted_attempt_id: t.acceptedAttemptId } : {}) };
49
55
  }
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
56
  /** The catalog resolver that options select: the host's per-lead port, else the static list. */
78
57
  export function catalogOf(options) {
79
58
  return async (tx, lead) => options.catalog ? options.catalog(tx, lead) : { modelRoles: options.modelRoles, ...(options.defaultModelRole ? { defaultModelRole: options.defaultModelRole } : {}) };
@@ -95,12 +74,9 @@ export function createAuthorityTools(options) {
95
74
  if (s.team.current.lead !== api.conversationId)
96
75
  return unavailable(name, a, 'team_authority_unavailable', 'Only the established lead can create the next Team.');
97
76
  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
77
  }
101
78
  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();
79
+ await startEpoch(s, s.team.current.epoch);
104
80
  delete s.journal.reservation;
105
81
  s.journal.acknowledged = 0;
106
82
  await event(tx, s, LEAD, 'team_created', { at: new Date(api.now()).toISOString() });
@@ -132,56 +108,54 @@ export function createAuthorityTools(options) {
132
108
  selected = await options.resolveModelRole(tx, plain(selected));
133
109
  const binding = await options.createWorker(tx, plain(selected));
134
110
  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() } };
111
+ if (prior) {
112
+ delete s.head.workerBindings[String(prior.conversationId)];
113
+ s.head.stopRefresh = true;
114
+ }
115
+ s.head.workerBindings[String(binding.conversationId)] = a.name;
135
116
  await event(tx, s, actor, 'worker_created', { worker: a.name, at: new Date(api.now()).toISOString() });
136
117
  return { kind: 'worker_ensured', effect: prior ? 'reconnected' : 'created', worker: { name: a.name, carrier: 'connected', model_role: selected.name } };
137
118
  }
138
- const graph = controller(s, api, name, a);
119
+ controller(s, api, name, a);
139
120
  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) };
121
+ 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
122
  if (name === 'task_read') {
142
- const cards = new Map(projectGraphTaskCards(graph).map(t => [t.id, t]));
123
+ const cards = new Map((await taskCards(s, role === 'worker' ? [a.task_id] : a.task_ids)).map(t => [t.id, t]));
143
124
  if (role === 'worker')
144
125
  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
126
  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
127
  }
147
128
  if (name === 'task_graph_apply') {
148
129
  // 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);
130
+ const already = await hasOperation(s, a.operation_id);
150
131
  if (!already && a.tasks.some(t => !Object.hasOwn(team.workers, t.assignee) || team.workers[t.assignee].state !== 'active'))
151
132
  return decline({ kind: 'refused', operation_id: a.operation_id, reason: 'worker_unavailable', message: 'Every assignee must be a current Worker.' });
152
133
  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();
134
+ const r = await graphCommand(s, { kind: 'apply', input: { operationId: a.operation_id, expectedGraphVersion: a.expected_graph_version, tasks } }, api.now);
135
+ if (!r.replayed)
156
136
  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
137
  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
138
  }
161
139
  if (name === 'task_update') {
162
140
  if (role === 'worker' && a.transition === 'cancel')
163
141
  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();
142
+ 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);
143
+ if (!r.replayed)
167
144
  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
145
  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
146
  }
172
147
  if (name === 'alert_send') {
173
- const to = role === 'worker' ? LEAD : a.to;
148
+ const to = role === 'worker' ? LEAD : a.to, interrupt = role === 'lead' && a.interrupt === true;
174
149
  if (!a.text?.trim() || a.task_version && !a.task_id || (to === '*') !== (a.kind === 'announcement'))
175
150
  return decline({ kind: 'refused', reason: 'invalid_fanout', message: 'Invalid Alert text, Task reference or announcement target.' });
176
151
  const recipients = to === LEAD ? [{ name: LEAD, conversationId: team.lead, generation: 0 }] : Object.values(team.workers).filter(w => w.state === 'active' && (to === '*' || w.name === to)).map(w => ({ name: w.name, conversationId: w.conversationId, generation: w.generation }));
177
152
  if (!recipients.length)
178
153
  return decline({ kind: 'refused', reason: to === '*' ? 'no_eligible_recipients' : 'recipient_not_current', message: 'No current recipient.' });
179
154
  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 });
155
+ await addAlert(s, plain({ id, from: actor, kind: a.kind, text: a.text.trim(), ...(interrupt ? { interrupt: true } : {}), task_id: a.task_id, task_version: a.task_version, receivedAt: new Date(api.now()).toISOString(), recipients }));
181
156
  for (const recipient of recipients) {
182
157
  const key = `${id}:${recipient.name}:g${recipient.generation}`;
183
- 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 } : {}) } : {}) };
158
+ 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(), ...(interrupt ? { interrupt: true, interruptAt: 'delivery' } : {}) });
185
159
  }
186
160
  await event(tx, s, actor, 'alert_accepted', { at: new Date(api.now()).toISOString() });
187
161
  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 +166,8 @@ export function createAuthorityTools(options) {
192
166
  return { kind: 'refused', reason: 'observation_in_progress', message: 'An observation awaits presentation acknowledgement.', state_changed: false, observation_advanced: false };
193
167
  if (!options.sync)
194
168
  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;
169
+ const roles = await rolesOf(tx, team.lead), snapshot = () => snapshotResult(s, roles.modelRoles, roles.defaultModelRole);
170
+ return options.sync ? options.sync(tx, s, a.view, api, snapshot) : snapshot();
197
171
  }
198
172
  // Lifecycle effects happen after the committed stop intent, outside this transaction.
199
173
  let workers;
@@ -203,7 +177,7 @@ export function createAuthorityTools(options) {
203
177
  const worker = Object.hasOwn(team.workers, a.worker) ? team.workers[a.worker] : undefined;
204
178
  if (!worker)
205
179
  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);
180
+ const held = await unfinished(s, worker.name);
207
181
  if (held.length)
208
182
  return decline({ kind: 'refused', reason: 'nonterminal_tasks_assigned', worker: worker.name, guarding_task_ids: held });
209
183
  if (worker.state === 'stopped')
@@ -215,26 +189,32 @@ export function createAuthorityTools(options) {
215
189
  if (workers.length && !options.stopWorker && !options.settleStops)
216
190
  return unavailable(name, a, name === 'worker_stop' ? 'carrier_unavailable' : 'team_authority_unavailable', 'Anchor stop adapter is not installed.');
217
191
  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))
192
+ for (const obligation of await allObligations(s))
219
193
  if (obligation.epoch === team.epoch && workers.some(w => w.name === obligation.recipient))
220
- obligation.state = 'retired';
194
+ await setObligationState(s, obligation, 'retired');
221
195
  if (name === 'team_shutdown')
222
196
  team.shutdownPending = true;
223
197
  return { stops, epoch: team.epoch };
224
198
  }
225
199
  async function execute(name, role, input, api, context) {
226
200
  const schema = contract[role][name];
227
- if (!schema || !Check(schema, input))
201
+ // Earlier receipts hashed raw arguments. Their exact input must still replay without executing again.
202
+ const rawFingerprint = JSON.stringify({ name, role, input, conversation: api.conversationId });
203
+ if (schema)
204
+ input = declaredParameters(schema, input);
205
+ if (!schema || !checkSchema(schema, input))
228
206
  throw new Error(`Invalid ${role} ${name} parameters`);
229
207
  const a = input;
230
- const key = String(api.taskId), fingerprint = JSON.stringify({ name, role, input, conversation: api.conversationId });
208
+ const fingerprint = JSON.stringify({ name, role, input, conversation: api.conversationId });
231
209
  try {
232
210
  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;
211
+ const native = (await tx.task(api.taskId))?.kind === 'pi.tool';
212
+ const s = await open(tx), receipt = await callReceipt(s, api.taskId, native), saved = receipt.fingerprint ? receipt : undefined;
213
+ s.deferFlush = true;
234
214
  if (saved) {
235
- if (saved.fingerprint !== fingerprint)
215
+ if (saved.fingerprint !== fingerprint && saved.fingerprint !== rawFingerprint)
236
216
  throw new Error('Durable tool identity reused with changed input');
237
- return plain(saved.result ?? { stops: saved.stops, epoch: saved.epoch });
217
+ return saved.result ? JSON.parse(saved.result) : plain({ stops: saved.stops, epoch: saved.epoch });
238
218
  }
239
219
  const r = await command(name, role, a, api, tx, s);
240
220
  if (name !== 'task_read')
@@ -242,10 +222,10 @@ export function createAuthorityTools(options) {
242
222
  if ('kind' in r) {
243
223
  const clean = plain(checkResult(contract, name, r));
244
224
  if (name !== 'task_read')
245
- s.receipts.calls[key] = { fingerprint, result: clean };
225
+ await saveCall(s, api.taskId, { fingerprint, result: clean });
246
226
  return clean;
247
227
  }
248
- s.receipts.calls[key] = { fingerprint, stops: r.stops, epoch: r.epoch };
228
+ await saveCall(s, api.taskId, { fingerprint, stops: r.stops, epoch: r.epoch });
249
229
  return plain(r);
250
230
  }, context);
251
231
  if ('kind' in result)
@@ -269,6 +249,7 @@ export function createAuthorityTools(options) {
269
249
  }
270
250
  return await api.commit(async (tx) => {
271
251
  const s = await open(tx), team = s.team.current;
252
+ await callReceipt(s, api.taskId);
272
253
  if (team.epoch !== result.epoch)
273
254
  throw new Error('Team epoch changed during stop');
274
255
  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 +257,16 @@ export function createAuthorityTools(options) {
276
257
  if (name === 'worker_stop')
277
258
  r = failed.length ? { kind: 'refused', reason: 'stop_not_confirmed', worker: a.worker } : { kind: 'worker_stopped', worker: a.worker };
278
259
  else {
279
- const unfinished = controller(s, api, name, a).readTasks().filter(t => !terminal(t.state.kind)).map(t => t.id);
260
+ controller(s, api, name, a);
261
+ const remaining = await unfinished(s);
280
262
  if (!failed.length && team.lifecycle !== 'stopped') {
281
263
  team.lifecycle = 'stopped';
282
264
  await event(tx, s, LEAD, 'team_stopped', { at: new Date(api.now()).toISOString() });
283
265
  }
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 };
266
+ 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
267
  }
286
268
  const clean = plain(checkResult(contract, name, r));
287
- s.receipts.calls[key] = { fingerprint, result: clean };
269
+ await saveCall(s, api.taskId, { fingerprint, result: clean });
288
270
  return clean;
289
271
  }, context);
290
272
  }
@@ -303,24 +285,30 @@ export function createAuthorityTools(options) {
303
285
  if (name !== 'task_read')
304
286
  await api.commit(async (tx) => {
305
287
  const s = await open(tx);
306
- s.receipts.calls[key] = { fingerprint, result: clean };
288
+ await saveCall(s, api.taskId, { fingerprint, result: clean });
307
289
  }, context);
308
290
  return clean;
309
291
  }
310
292
  }
311
293
  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
- }));
294
+ return TOOL_NAMES.filter(name => contract[role][name]).map(name => {
295
+ const presentation = contract.presentation[role][name];
296
+ if (!presentation)
297
+ throw new Error(`The rc.7 contract has no ${role} ${name} label and description`);
298
+ // pi-durable registers no label. It stays on the tool for hosts that show one, as Pi does.
299
+ return { ...defineTool({
300
+ name, description: presentation.description, parameters: contract[role][name], replay: 'safe',
301
+ execute: async (args, api, context) => {
302
+ const result = await execute(name, role, args, api, context);
303
+ const pending = options.settleStops && (result.kind === 'partial' || result.reason === 'stop_not_confirmed');
304
+ const names = result.kind === 'partial' ? result.failed_workers.map(n => n === LEAD ? 'Team delivery and notification tasks' : n).join(', ') : String(result.worker);
305
+ const output = [{ type: 'text', text: JSON.stringify(result) }];
306
+ if (pending)
307
+ 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.` });
308
+ return { output };
309
+ },
310
+ }), label: presentation.label };
311
+ });
324
312
  }
325
313
  return { lead: tools('lead'), worker: tools('worker'), execute, contract };
326
314
  }
@@ -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.3",
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": {