@hypercarrier/pi-team-durable 0.0.0-stage → 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/dist/authority/anchor.d.ts +10 -0
- package/dist/authority/anchor.js +19 -0
- package/dist/authority/card-types.d.ts +152 -0
- package/dist/authority/card-types.js +123 -0
- package/dist/authority/contracts.d.ts +12 -0
- package/dist/authority/contracts.js +29 -0
- package/dist/authority/delivery.d.ts +59 -0
- package/dist/authority/delivery.js +152 -0
- package/dist/authority/graph-card.d.ts +4 -0
- package/dist/authority/graph-card.js +39 -0
- package/dist/authority/graph-control.d.ts +299 -0
- package/dist/authority/graph-control.js +1009 -0
- package/dist/authority/index.d.ts +11 -0
- package/dist/authority/index.js +11 -0
- package/dist/authority/interrupt.d.ts +11 -0
- package/dist/authority/interrupt.js +26 -0
- package/dist/authority/limits.d.ts +7 -0
- package/dist/authority/limits.js +7 -0
- package/dist/authority/notifier.d.ts +32 -0
- package/dist/authority/notifier.js +83 -0
- package/dist/authority/observation.d.ts +55 -0
- package/dist/authority/observation.js +125 -0
- package/dist/authority/projection.d.ts +4 -0
- package/dist/authority/projection.js +15 -0
- package/dist/authority/rc7-contract.json +9334 -0
- package/dist/authority/runtime.d.ts +70 -0
- package/dist/authority/runtime.js +116 -0
- package/dist/authority/state.d.ts +150 -0
- package/dist/authority/state.js +26 -0
- package/dist/authority/sync.d.ts +12 -0
- package/dist/authority/sync.js +77 -0
- package/dist/authority/task-version-ref.d.ts +3 -0
- package/dist/authority/task-version-ref.js +5 -0
- package/dist/authority/tools.d.ts +68 -0
- package/dist/authority/tools.js +326 -0
- package/package.json +57 -4
|
@@ -0,0 +1,326 @@
|
|
|
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";
|
|
5
|
+
import { projectGraphTaskCards } from "./graph-card.js";
|
|
6
|
+
import { snapshotResult } from "./projection.js";
|
|
7
|
+
import { open, plain, event } from "./state.js";
|
|
8
|
+
const terminal = (kind) => ['goal_achieved', 'goal_failed', 'cancelled'].includes(kind);
|
|
9
|
+
const LEAD = 'team-lead';
|
|
10
|
+
class Declined extends Error {
|
|
11
|
+
result;
|
|
12
|
+
constructor(result) { super(String(result.message ?? result.reason)); this.result = result; }
|
|
13
|
+
}
|
|
14
|
+
const decline = (result) => { throw new Declined(result); };
|
|
15
|
+
function unavailable(name, args, reason, message) {
|
|
16
|
+
return decline({ kind: 'unavailable', reason, message,
|
|
17
|
+
...(['task_graph_apply', 'task_update'].includes(name) ? { operation_id: args.operation_id } : {}),
|
|
18
|
+
...(name === 'task_update' ? { task_id: args.task_id } : {}),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function failureReason(name) {
|
|
22
|
+
return ['task_read', 'task_update', 'task_graph_apply'].includes(name) ? 'task_authority_unavailable' : name === 'team_sync' ? 'team_state_unavailable' : 'team_authority_unavailable';
|
|
23
|
+
}
|
|
24
|
+
function caller(s, api, role, name, args) {
|
|
25
|
+
const team = s.team.current;
|
|
26
|
+
if (!team || (team.lifecycle !== 'active' && !(name === 'team_shutdown' && role === 'lead' && team.lead === api.conversationId)))
|
|
27
|
+
return unavailable(name, args, 'no_active_team', 'No active Team.');
|
|
28
|
+
if (role === 'lead' && team.lead === api.conversationId)
|
|
29
|
+
return LEAD;
|
|
30
|
+
const worker = Object.values(team.workers).find(w => w.state === 'active' && w.conversationId === api.conversationId);
|
|
31
|
+
if (role === 'worker' && worker)
|
|
32
|
+
return worker.name;
|
|
33
|
+
return unavailable(name, args, failureReason(name), 'Caller is not the exact current Team binding.');
|
|
34
|
+
}
|
|
35
|
+
function controller(s, api, name, args) {
|
|
36
|
+
if (!s.graph.snapshot || s.graph.epoch !== s.team.current?.epoch)
|
|
37
|
+
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 {
|
|
42
|
+
return unavailable(name, args, failureReason(name), 'Established graph authority is corrupt.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function modelTask(t) {
|
|
46
|
+
const [card] = projectGraphTaskCards({ readTasks: () => [t], readAttempts: () => [], selectReadyFrontier: () => [] });
|
|
47
|
+
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
|
+
...('attemptId' in t.state ? { current_attempt_id: t.state.attemptId } : {}), ...(t.acceptedAttemptId ? { accepted_attempt_id: t.acceptedAttemptId } : {}) };
|
|
49
|
+
}
|
|
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
|
+
/** The catalog resolver that options select: the host's per-lead port, else the static list. */
|
|
78
|
+
export function catalogOf(options) {
|
|
79
|
+
return async (tx, lead) => options.catalog ? options.catalog(tx, lead) : { modelRoles: options.modelRoles, ...(options.defaultModelRole ? { defaultModelRole: options.defaultModelRole } : {}) };
|
|
80
|
+
}
|
|
81
|
+
/** Authority commands commit intent and replay receipts; injected ports own effects. */
|
|
82
|
+
export function createAuthorityTools(options) {
|
|
83
|
+
const contract = contracts(options.goalMaxLength);
|
|
84
|
+
const rolesOf = catalogOf(options);
|
|
85
|
+
const catalog = (c) => ({ model_roles: c.modelRoles.map(r => ({ name: r.name, use: r.use })), ...(c.defaultModelRole ? { default_model_role: c.defaultModelRole } : {}) });
|
|
86
|
+
async function command(name, role, a, api, tx, s) {
|
|
87
|
+
if (name === 'team_create') {
|
|
88
|
+
if (role !== 'lead')
|
|
89
|
+
return unavailable(name, a, 'team_authority_unavailable', 'Worker cannot create a Team.');
|
|
90
|
+
if (s.team.current?.lifecycle === 'active')
|
|
91
|
+
return decline({ kind: 'refused', reason: 'active_team_exists', message: 'An active Team already owns this store.' });
|
|
92
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(a.name))
|
|
93
|
+
return decline({ kind: 'refused', reason: 'name_unavailable', message: 'Invalid Team name.' });
|
|
94
|
+
if (s.team.current) {
|
|
95
|
+
if (s.team.current.lead !== api.conversationId)
|
|
96
|
+
return unavailable(name, a, 'team_authority_unavailable', 'Only the established lead can create the next Team.');
|
|
97
|
+
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
|
+
}
|
|
101
|
+
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();
|
|
104
|
+
delete s.journal.reservation;
|
|
105
|
+
s.journal.acknowledged = 0;
|
|
106
|
+
await event(tx, s, LEAD, 'team_created', { at: new Date(api.now()).toISOString() });
|
|
107
|
+
return { kind: 'team_created', team: { name: a.name, lifecycle: 'active' }, ...catalog(await rolesOf(tx, api.conversationId)) };
|
|
108
|
+
}
|
|
109
|
+
const actor = caller(s, api, role, name, a), team = s.team.current;
|
|
110
|
+
if (name === 'ensure_worker') {
|
|
111
|
+
if (team.shutdownPending)
|
|
112
|
+
return unavailable(name, a, 'carrier_unavailable', 'Team shutdown is pending.');
|
|
113
|
+
if (a.name === LEAD || !/^[A-Za-z0-9_-]{1,64}$/.test(a.name))
|
|
114
|
+
return unavailable(name, a, 'team_authority_unavailable', 'Invalid or reserved Worker name.');
|
|
115
|
+
const prior = Object.hasOwn(team.workers, a.name) ? team.workers[a.name] : undefined;
|
|
116
|
+
const existing = prior ? { name: prior.name, scope: prior.scope, carrier: prior.state === 'active' ? 'connected' : 'absent', model_role: prior.role.name } : undefined;
|
|
117
|
+
if (prior && prior.scope !== a.scope)
|
|
118
|
+
return decline({ kind: 'refused', reason: 'name_scope_conflict', existing_worker: existing });
|
|
119
|
+
if (prior && a.model_role && prior.role.name !== a.model_role)
|
|
120
|
+
return decline({ kind: 'refused', reason: 'model_conflict', existing_worker: existing });
|
|
121
|
+
if (prior?.state === 'stopping')
|
|
122
|
+
return unavailable(name, a, 'carrier_unavailable', 'The exact Worker stop is pending.');
|
|
123
|
+
if (prior?.state === 'active')
|
|
124
|
+
return { kind: 'worker_ensured', effect: 'reused', worker: { name: prior.name, carrier: 'connected', model_role: prior.role.name } };
|
|
125
|
+
const roles = prior ? undefined : await rolesOf(tx, team.lead);
|
|
126
|
+
let selected = prior?.role ?? roles.modelRoles.find(r => r.name === (a.model_role ?? roles.defaultModelRole));
|
|
127
|
+
if (!selected)
|
|
128
|
+
return decline({ kind: 'refused', reason: 'invalid_model_role', valid_model_roles: catalog(roles).model_roles, message: 'Select a configured model role.' });
|
|
129
|
+
if (!options.createWorker)
|
|
130
|
+
return unavailable(name, a, 'carrier_unavailable', 'Worker anchor adapter is not installed.');
|
|
131
|
+
if (options.resolveModelRole)
|
|
132
|
+
selected = await options.resolveModelRole(tx, plain(selected));
|
|
133
|
+
const binding = await options.createWorker(tx, plain(selected));
|
|
134
|
+
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() } };
|
|
135
|
+
await event(tx, s, actor, 'worker_created', { worker: a.name, at: new Date(api.now()).toISOString() });
|
|
136
|
+
return { kind: 'worker_ensured', effect: prior ? 'reconnected' : 'created', worker: { name: a.name, carrier: 'connected', model_role: selected.name } };
|
|
137
|
+
}
|
|
138
|
+
const graph = controller(s, api, name, a);
|
|
139
|
+
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) };
|
|
141
|
+
if (name === 'task_read') {
|
|
142
|
+
const cards = new Map(projectGraphTaskCards(graph).map(t => [t.id, t]));
|
|
143
|
+
if (role === 'worker')
|
|
144
|
+
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
|
+
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
|
+
}
|
|
147
|
+
if (name === 'task_graph_apply') {
|
|
148
|
+
// 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);
|
|
150
|
+
if (!already && a.tasks.some(t => !Object.hasOwn(team.workers, t.assignee) || team.workers[t.assignee].state !== 'active'))
|
|
151
|
+
return decline({ kind: 'refused', operation_id: a.operation_id, reason: 'worker_unavailable', message: 'Every assignee must be a current Worker.' });
|
|
152
|
+
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();
|
|
156
|
+
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
|
+
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
|
+
}
|
|
161
|
+
if (name === 'task_update') {
|
|
162
|
+
if (role === 'worker' && a.transition === 'cancel')
|
|
163
|
+
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();
|
|
167
|
+
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
|
+
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
|
+
}
|
|
172
|
+
if (name === 'alert_send') {
|
|
173
|
+
const to = role === 'worker' ? LEAD : a.to;
|
|
174
|
+
if (!a.text?.trim() || a.task_version && !a.task_id || (to === '*') !== (a.kind === 'announcement'))
|
|
175
|
+
return decline({ kind: 'refused', reason: 'invalid_fanout', message: 'Invalid Alert text, Task reference or announcement target.' });
|
|
176
|
+
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
|
+
if (!recipients.length)
|
|
178
|
+
return decline({ kind: 'refused', reason: to === '*' ? 'no_eligible_recipients' : 'recipient_not_current', message: 'No current recipient.' });
|
|
179
|
+
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 });
|
|
181
|
+
for (const recipient of recipients) {
|
|
182
|
+
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 } : {}) } : {}) };
|
|
185
|
+
}
|
|
186
|
+
await event(tx, s, actor, 'alert_accepted', { at: new Date(api.now()).toISOString() });
|
|
187
|
+
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 } : {}) };
|
|
188
|
+
}
|
|
189
|
+
if (name === 'team_sync') {
|
|
190
|
+
// Never consume a page on a tool call alone.
|
|
191
|
+
if (!options.sync && s.journal.reservation)
|
|
192
|
+
return { kind: 'refused', reason: 'observation_in_progress', message: 'An observation awaits presentation acknowledgement.', state_changed: false, observation_advanced: false };
|
|
193
|
+
if (!options.sync)
|
|
194
|
+
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;
|
|
197
|
+
}
|
|
198
|
+
// Lifecycle effects happen after the committed stop intent, outside this transaction.
|
|
199
|
+
let workers;
|
|
200
|
+
if (name === 'worker_stop') {
|
|
201
|
+
if (a.worker === LEAD)
|
|
202
|
+
return decline({ kind: 'refused', reason: 'leader_reserved', worker: a.worker });
|
|
203
|
+
const worker = Object.hasOwn(team.workers, a.worker) ? team.workers[a.worker] : undefined;
|
|
204
|
+
if (!worker)
|
|
205
|
+
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);
|
|
207
|
+
if (held.length)
|
|
208
|
+
return decline({ kind: 'refused', reason: 'nonterminal_tasks_assigned', worker: worker.name, guarding_task_ids: held });
|
|
209
|
+
if (worker.state === 'stopped')
|
|
210
|
+
return { kind: 'worker_stopped', worker: worker.name };
|
|
211
|
+
workers = [worker];
|
|
212
|
+
}
|
|
213
|
+
else
|
|
214
|
+
workers = Object.values(team.workers).filter(w => w.state !== 'stopped');
|
|
215
|
+
if (workers.length && !options.stopWorker && !options.settleStops)
|
|
216
|
+
return unavailable(name, a, name === 'worker_stop' ? 'carrier_unavailable' : 'team_authority_unavailable', 'Anchor stop adapter is not installed.');
|
|
217
|
+
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))
|
|
219
|
+
if (obligation.epoch === team.epoch && workers.some(w => w.name === obligation.recipient))
|
|
220
|
+
obligation.state = 'retired';
|
|
221
|
+
if (name === 'team_shutdown')
|
|
222
|
+
team.shutdownPending = true;
|
|
223
|
+
return { stops, epoch: team.epoch };
|
|
224
|
+
}
|
|
225
|
+
async function execute(name, role, input, api, context) {
|
|
226
|
+
const schema = contract[role][name];
|
|
227
|
+
if (!schema || !Check(schema, input))
|
|
228
|
+
throw new Error(`Invalid ${role} ${name} parameters`);
|
|
229
|
+
const a = input;
|
|
230
|
+
const key = String(api.taskId), fingerprint = JSON.stringify({ name, role, input, conversation: api.conversationId });
|
|
231
|
+
try {
|
|
232
|
+
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;
|
|
234
|
+
if (saved) {
|
|
235
|
+
if (saved.fingerprint !== fingerprint)
|
|
236
|
+
throw new Error('Durable tool identity reused with changed input');
|
|
237
|
+
return plain(saved.result ?? { stops: saved.stops, epoch: saved.epoch });
|
|
238
|
+
}
|
|
239
|
+
const r = await command(name, role, a, api, tx, s);
|
|
240
|
+
if (name !== 'task_read')
|
|
241
|
+
await options.afterCommand?.(tx, s, name, r, api);
|
|
242
|
+
if ('kind' in r) {
|
|
243
|
+
const clean = plain(checkResult(contract, name, r));
|
|
244
|
+
if (name !== 'task_read')
|
|
245
|
+
s.receipts.calls[key] = { fingerprint, result: clean };
|
|
246
|
+
return clean;
|
|
247
|
+
}
|
|
248
|
+
s.receipts.calls[key] = { fingerprint, stops: r.stops, epoch: r.epoch };
|
|
249
|
+
return plain(r);
|
|
250
|
+
}, context);
|
|
251
|
+
if ('kind' in result)
|
|
252
|
+
return result;
|
|
253
|
+
const pending = (await api.commit(async (tx) => { const s = await open(tx); return result.stops.map(key => ({ key, intent: plain(s.team.stops[key]) })); }, context)).filter(p => !p.intent.confirmed);
|
|
254
|
+
const settled = options.settleStops ? await options.settleStops(pending, context, api) : await Promise.all(pending.map(p => p.intent.binding ? options.stopWorker(p.intent.binding, context) : false));
|
|
255
|
+
for (const [i, { key: stop, intent }] of pending.entries()) {
|
|
256
|
+
if (settled[i])
|
|
257
|
+
await api.commit(async (tx) => {
|
|
258
|
+
const s = await open(tx), w = intent.worker ? s.team.current?.workers[intent.worker] : undefined;
|
|
259
|
+
if (s.team.current?.epoch !== intent.epoch || (intent.binding && (!w || w.generation !== intent.generation || w.anchorId !== intent.binding.anchorId)))
|
|
260
|
+
throw new Error('Stop binding changed');
|
|
261
|
+
if (!s.team.stops[stop].confirmed) {
|
|
262
|
+
s.team.stops[stop].confirmed = true;
|
|
263
|
+
if (w) {
|
|
264
|
+
w.state = 'stopped';
|
|
265
|
+
await event(tx, s, LEAD, 'worker_stopped', { worker: w.name, at: new Date(api.now()).toISOString() });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}, context);
|
|
269
|
+
}
|
|
270
|
+
return await api.commit(async (tx) => {
|
|
271
|
+
const s = await open(tx), team = s.team.current;
|
|
272
|
+
if (team.epoch !== result.epoch)
|
|
273
|
+
throw new Error('Team epoch changed during stop');
|
|
274
|
+
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);
|
|
275
|
+
let r;
|
|
276
|
+
if (name === 'worker_stop')
|
|
277
|
+
r = failed.length ? { kind: 'refused', reason: 'stop_not_confirmed', worker: a.worker } : { kind: 'worker_stopped', worker: a.worker };
|
|
278
|
+
else {
|
|
279
|
+
const unfinished = controller(s, api, name, a).readTasks().filter(t => !terminal(t.state.kind)).map(t => t.id);
|
|
280
|
+
if (!failed.length && team.lifecycle !== 'stopped') {
|
|
281
|
+
team.lifecycle = 'stopped';
|
|
282
|
+
await event(tx, s, LEAD, 'team_stopped', { at: new Date(api.now()).toISOString() });
|
|
283
|
+
}
|
|
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 };
|
|
285
|
+
}
|
|
286
|
+
const clean = plain(checkResult(contract, name, r));
|
|
287
|
+
s.receipts.calls[key] = { fingerprint, result: clean };
|
|
288
|
+
return clean;
|
|
289
|
+
}, context);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
let refusal;
|
|
293
|
+
if (error instanceof Declined)
|
|
294
|
+
refusal = plain(error.result);
|
|
295
|
+
else if (error instanceof GraphControlRefusal) {
|
|
296
|
+
const reason = error.code === 'task_version_conflict' ? 'version_conflict' : error.code;
|
|
297
|
+
refusal = { kind: 'refused', operation_id: a.operation_id, ...(name === 'task_update' ? { task_id: a.task_id } : {}), reason, message: error.message };
|
|
298
|
+
}
|
|
299
|
+
else
|
|
300
|
+
throw error;
|
|
301
|
+
const clean = checkResult(contract, name, refusal);
|
|
302
|
+
// The failed transaction rolls back all mutations. Persist its model result separately.
|
|
303
|
+
if (name !== 'task_read')
|
|
304
|
+
await api.commit(async (tx) => {
|
|
305
|
+
const s = await open(tx);
|
|
306
|
+
s.receipts.calls[key] = { fingerprint, result: clean };
|
|
307
|
+
}, context);
|
|
308
|
+
return clean;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
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
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
return { lead: tools('lead'), worker: tools('worker'), execute, contract };
|
|
326
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypercarrier/pi-team-durable",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "Pi Team Bright semantics on pi-durable: one Task authority per Team, Workers as durable subthreads, accepted-then-delivered notices.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/deephbz/pi-team-durable.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/deephbz/pi-team-durable#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/deephbz/pi-team-durable/issues"
|
|
12
|
+
},
|
|
13
|
+
"author": "deephbz",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.19"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
"./authority": {
|
|
21
|
+
"types": "./dist/authority/index.d.ts",
|
|
22
|
+
"import": "./dist/authority/index.js",
|
|
23
|
+
"default": "./dist/authority/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/authority/rc7-contract.json dist/authority/",
|
|
36
|
+
"prepare": "npm run build",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"verify:package": "node scripts/verify-package.mjs"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"typebox": "1.3.27"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@earendil-works/chord": "1.4.0",
|
|
46
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
47
|
+
"@earendil-works/pi-durable": "1.4.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@earendil-works/chord": "1.4.0",
|
|
51
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
52
|
+
"@earendil-works/pi-durable": "1.4.0",
|
|
53
|
+
"@hypercarrier/pi-team-bright": "0.25.1",
|
|
54
|
+
"@types/node": "^22.19.0",
|
|
55
|
+
"jiti": "2.7.0",
|
|
56
|
+
"typescript": "6.0.3",
|
|
57
|
+
"vitest": "4.1.10"
|
|
58
|
+
}
|
|
59
|
+
}
|