@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.
- package/README.md +130 -19
- package/dist/authority/contracts.d.ts +7 -0
- package/dist/authority/contracts.js +20 -4
- package/dist/authority/delivery.d.ts +25 -20
- package/dist/authority/delivery.js +97 -49
- 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 +7 -6
- 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 +61 -3
- package/dist/authority/runtime.d.ts +42 -2
- package/dist/authority/runtime.js +13 -14
- package/dist/authority/state.d.ts +21 -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 +48 -4
- package/dist/authority/tools.js +79 -91
- 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
|
}
|
|
@@ -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
|
|
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:
|
|
64
|
-
|
|
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
|
};
|
package/dist/authority/tools.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import { defineTool
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
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';
|
|
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
|
|
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,
|
|
36
|
-
if (!s.graph
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
192
|
+
for (const obligation of await allObligations(s))
|
|
219
193
|
if (obligation.epoch === team.epoch && workers.some(w => w.name === obligation.recipient))
|
|
220
|
-
obligation
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
225
|
+
await saveCall(s, api.taskId, { fingerprint, result: clean });
|
|
246
226
|
return clean;
|
|
247
227
|
}
|
|
248
|
-
s.
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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 =>
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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';
|
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.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": {
|