@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.
- package/README.md +100 -9
- package/dist/authority/contracts.d.ts +7 -0
- package/dist/authority/contracts.js +20 -4
- package/dist/authority/delivery.d.ts +4 -3
- package/dist/authority/delivery.js +35 -29
- package/dist/authority/graph-control.d.ts +1 -1
- package/dist/authority/incremental-graph-control.d.ts +156 -0
- package/dist/authority/incremental-graph-control.js +1051 -0
- package/dist/authority/index.d.ts +5 -1
- package/dist/authority/index.js +5 -1
- package/dist/authority/interrupt.js +3 -3
- package/dist/authority/notifier.d.ts +1 -1
- package/dist/authority/notifier.js +26 -20
- package/dist/authority/observation-store.d.ts +104 -0
- package/dist/authority/observation-store.js +226 -0
- package/dist/authority/observation.d.ts +3 -45
- package/dist/authority/observation.js +86 -68
- package/dist/authority/panel.d.ts +121 -0
- package/dist/authority/panel.js +44 -0
- package/dist/authority/projection.d.ts +5 -4
- package/dist/authority/projection.js +11 -11
- package/dist/authority/rc7-contract.json +57 -1
- package/dist/authority/runtime.d.ts +38 -2
- package/dist/authority/runtime.js +13 -14
- package/dist/authority/state.d.ts +19 -4
- package/dist/authority/state.js +14 -12
- package/dist/authority/store.d.ts +144 -0
- package/dist/authority/store.js +675 -0
- package/dist/authority/sync.d.ts +4 -3
- package/dist/authority/sync.js +20 -19
- package/dist/authority/tools.d.ts +41 -4
- package/dist/authority/tools.js +63 -87
- package/dist/testing.d.ts +11 -0
- package/dist/testing.js +10 -0
- package/package.json +7 -1
package/dist/authority/sync.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/authority/sync.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { LiveDoc } from '@earendil-works/pi-durable';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
46
|
-
o.paused = false;
|
|
47
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
70
|
-
const page = await pageEvents(tx, s, from, previous
|
|
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
|
|
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:
|
|
64
|
-
|
|
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
|
};
|
package/dist/authority/tools.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { defineTool, LiveDoc } from '@earendil-works/pi-durable';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
8
|
-
|
|
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
|
|
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,
|
|
36
|
-
if (!s.graph
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
184
|
+
for (const obligation of await allObligations(s))
|
|
219
185
|
if (obligation.epoch === team.epoch && workers.some(w => w.name === obligation.recipient))
|
|
220
|
-
obligation
|
|
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 || !
|
|
193
|
+
if (!schema || !checkSchema(schema, input))
|
|
228
194
|
throw new Error(`Invalid ${role} ${name} parameters`);
|
|
229
195
|
const a = input;
|
|
230
|
-
const
|
|
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
|
|
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
|
|
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.
|
|
213
|
+
await saveCall(s, api.taskId, { fingerprint, result: clean });
|
|
246
214
|
return clean;
|
|
247
215
|
}
|
|
248
|
-
s.
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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 =>
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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';
|
package/dist/testing.js
ADDED
|
@@ -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.
|
|
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": {
|