@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,70 @@
|
|
|
1
|
+
import type { Context } from '@earendil-works/chord/context';
|
|
2
|
+
import { type Harness, type Extension } from '@earendil-works/pi-durable';
|
|
3
|
+
import { type CoreApi, type CoreOptions } from './tools.ts';
|
|
4
|
+
import type { AbortRun } from './interrupt.ts';
|
|
5
|
+
import { type ObservationPortResolver } from './observation.ts';
|
|
6
|
+
import { type StopIntent } from './state.ts';
|
|
7
|
+
export interface StopPlan {
|
|
8
|
+
id: string;
|
|
9
|
+
conversations?: string[];
|
|
10
|
+
tasks?: string[];
|
|
11
|
+
}
|
|
12
|
+
export type StopOutcome = {
|
|
13
|
+
status: 'settled';
|
|
14
|
+
tasks: Record<string, string>;
|
|
15
|
+
} | {
|
|
16
|
+
status: 'failed';
|
|
17
|
+
tasks: Record<string, string>;
|
|
18
|
+
error: string;
|
|
19
|
+
};
|
|
20
|
+
export declare const stopPlan: (id: string, intent: StopIntent) => StopPlan;
|
|
21
|
+
export declare function pendingStops(harness: Harness, context: Context): Promise<StopPlan[]>;
|
|
22
|
+
export declare function confirmStop(harness: Harness, id: string, outcome: StopOutcome, context: Context, now?: () => number): Promise<void>;
|
|
23
|
+
/** Called by NodePlugin.closing after its own lead Close intent is stored. */
|
|
24
|
+
export declare function closing(harness: Harness, conversationId: string, context: Context): Promise<StopPlan[]>;
|
|
25
|
+
export interface RuntimeOptions extends Pick<CoreOptions, 'modelRoles' | 'defaultModelRole' | 'catalog' | 'goalMaxLength' | 'resolveModelRole'> {
|
|
26
|
+
observationPort: ObservationPortResolver;
|
|
27
|
+
abortRun: AbortRun;
|
|
28
|
+
stop: (api: CoreApi, plans: StopPlan[], context: Context) => Promise<StopOutcome[]>;
|
|
29
|
+
}
|
|
30
|
+
export declare function createTeamRuntime(options: RuntimeOptions): {
|
|
31
|
+
core: {
|
|
32
|
+
lead: import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>[];
|
|
33
|
+
worker: import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>[];
|
|
34
|
+
execute: (name: import("./contracts.ts").ToolName, role: import("./contracts.ts").Role, input: unknown, api: CoreApi, context: Context) => Promise<import("./state.ts").Result>;
|
|
35
|
+
contract: import("./contracts.ts").Contract;
|
|
36
|
+
};
|
|
37
|
+
lead: Extension<import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>>;
|
|
38
|
+
worker: Extension<import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>>;
|
|
39
|
+
mechanics: Extension<import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>>;
|
|
40
|
+
observation: Extension<import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>>;
|
|
41
|
+
extensions: Extension<import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>>[];
|
|
42
|
+
tasks: {
|
|
43
|
+
WorkerAnchor: import("@earendil-works/pi-durable").Task<null, {
|
|
44
|
+
phase: "hold";
|
|
45
|
+
}, null, object>;
|
|
46
|
+
Deliver: import("@earendil-works/pi-durable").Task<import("./delivery.ts").DeliveryInput, {
|
|
47
|
+
phase: "interrupt";
|
|
48
|
+
} | {
|
|
49
|
+
phase: "submit";
|
|
50
|
+
} | {
|
|
51
|
+
phase: "settle";
|
|
52
|
+
submissionId: import("@earendil-works/pi-durable").SubmissionId;
|
|
53
|
+
}, null, object>;
|
|
54
|
+
Notify: import("@earendil-works/pi-durable").Task<{
|
|
55
|
+
epoch: string;
|
|
56
|
+
}, {
|
|
57
|
+
phase: "tick";
|
|
58
|
+
ordinal: number;
|
|
59
|
+
due?: number;
|
|
60
|
+
} | {
|
|
61
|
+
phase: "submit";
|
|
62
|
+
pageId: string;
|
|
63
|
+
ordinal: number;
|
|
64
|
+
} | {
|
|
65
|
+
phase: "settle";
|
|
66
|
+
pageId: string;
|
|
67
|
+
ordinal: number;
|
|
68
|
+
}, null, object>;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { configure } from '@earendil-works/pi-durable';
|
|
2
|
+
import { catalogOf, createAuthorityTools } from "./tools.js";
|
|
3
|
+
import { createAnchoredConversation, WorkerAnchor } from "./anchor.js";
|
|
4
|
+
import { createDeliver, scheduleDeliveries } from "./delivery.js";
|
|
5
|
+
import { createNotifier } from "./notifier.js";
|
|
6
|
+
import { observations, observationExtension } from "./observation.js";
|
|
7
|
+
import { sync } from "./sync.js";
|
|
8
|
+
import { open, event } from "./state.js";
|
|
9
|
+
export const stopPlan = (id, intent) => ({ id, conversations: intent.conversations.map(String), tasks: intent.tasks.map(String) });
|
|
10
|
+
export async function pendingStops(harness, context) {
|
|
11
|
+
return harness.commit(async (tx) => Object.entries((await open(tx)).team.stops).filter(([, i]) => !i.confirmed).map(([id, i]) => stopPlan(id, i)), context);
|
|
12
|
+
}
|
|
13
|
+
export async function confirmStop(harness, id, outcome, context, now = Date.now) {
|
|
14
|
+
await harness.commit(async (tx) => {
|
|
15
|
+
const s = await open(tx), intent = s.team.stops[id];
|
|
16
|
+
if (!intent || intent.confirmed)
|
|
17
|
+
return;
|
|
18
|
+
if (outcome.status !== 'settled') {
|
|
19
|
+
intent.error = outcome.error;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// Confirm the exact persisted targets, including tasks already terminal before mark.
|
|
23
|
+
for (const target of intent.tasks) {
|
|
24
|
+
const task = await tx.task(target);
|
|
25
|
+
if (!task || task.state.status !== 'terminal')
|
|
26
|
+
throw new Error('PTB stop target is not terminal');
|
|
27
|
+
}
|
|
28
|
+
if (s.team.current?.epoch !== intent.epoch)
|
|
29
|
+
throw new Error('PTB stop epoch changed');
|
|
30
|
+
const worker = intent.worker ? s.team.current.workers[intent.worker] : undefined;
|
|
31
|
+
if (intent.binding && (!worker || worker.generation !== intent.generation || worker.anchorId !== intent.binding.anchorId || worker.conversationId !== intent.binding.conversationId))
|
|
32
|
+
throw new Error('PTB stop binding changed');
|
|
33
|
+
intent.confirmed = true;
|
|
34
|
+
delete intent.error;
|
|
35
|
+
if (worker) {
|
|
36
|
+
worker.state = 'stopped';
|
|
37
|
+
await event(tx, s, 'team-lead', 'worker_stopped', { worker: worker.name, at: new Date(now()).toISOString() });
|
|
38
|
+
}
|
|
39
|
+
if (s.team.current.shutdownPending && Object.values(s.team.stops).filter(i => i.epoch === intent.epoch).every(i => i.confirmed)) {
|
|
40
|
+
s.team.current.lifecycle = 'stopped';
|
|
41
|
+
await event(tx, s, 'team-lead', 'team_stopped', { at: new Date(now()).toISOString() });
|
|
42
|
+
}
|
|
43
|
+
}, context);
|
|
44
|
+
}
|
|
45
|
+
function addMechanicStops(s, shutdown) {
|
|
46
|
+
const team = s.team.current;
|
|
47
|
+
for (const intent of Object.values(s.team.stops)) {
|
|
48
|
+
if (intent.epoch !== team.epoch || intent.confirmed)
|
|
49
|
+
continue;
|
|
50
|
+
for (const o of Object.values(s.outbox.obligations))
|
|
51
|
+
if (o.epoch === team.epoch && o.recipient === intent.worker && o.deliveryTaskId && !intent.tasks.includes(o.deliveryTaskId))
|
|
52
|
+
intent.tasks.push(o.deliveryTaskId);
|
|
53
|
+
}
|
|
54
|
+
if (!shutdown)
|
|
55
|
+
return;
|
|
56
|
+
const key = `${team.epoch}:shutdown-mechanics`;
|
|
57
|
+
const tasks = Object.values(s.outbox.obligations).filter(o => o.epoch === team.epoch && o.deliveryTaskId).map(o => o.deliveryTaskId);
|
|
58
|
+
if (team.notifierId)
|
|
59
|
+
tasks.push(team.notifierId);
|
|
60
|
+
s.team.stops[key] ??= { epoch: team.epoch, tasks, conversations: [], confirmed: false };
|
|
61
|
+
return key;
|
|
62
|
+
}
|
|
63
|
+
/** Called by NodePlugin.closing after its own lead Close intent is stored. */
|
|
64
|
+
export async function closing(harness, conversationId, context) {
|
|
65
|
+
return harness.commit(async (tx) => {
|
|
66
|
+
const s = await open(tx), team = s.team.current;
|
|
67
|
+
if (!team || String(team.lead) !== conversationId || team.lifecycle !== 'active')
|
|
68
|
+
return [];
|
|
69
|
+
team.shutdownPending = true;
|
|
70
|
+
for (const w of Object.values(team.workers))
|
|
71
|
+
if (w.state !== 'stopped') {
|
|
72
|
+
const key = `${team.epoch}:stop:${w.name}:g${w.generation}`;
|
|
73
|
+
w.state = 'stopping';
|
|
74
|
+
s.team.stops[key] ??= { epoch: team.epoch, worker: w.name, generation: w.generation, binding: { conversationId: w.conversationId, anchorId: w.anchorId }, tasks: [w.anchorId], conversations: [w.conversationId], confirmed: false };
|
|
75
|
+
}
|
|
76
|
+
for (const o of Object.values(s.outbox.obligations))
|
|
77
|
+
if (o.epoch === team.epoch)
|
|
78
|
+
o.state = 'retired';
|
|
79
|
+
addMechanicStops(s, true);
|
|
80
|
+
return Object.entries(s.team.stops).filter(([, i]) => i.epoch === team.epoch && !i.confirmed).map(([id, i]) => stopPlan(id, i));
|
|
81
|
+
}, context);
|
|
82
|
+
}
|
|
83
|
+
export function createTeamRuntime(options) {
|
|
84
|
+
const Notify = createNotifier(catalogOf(options));
|
|
85
|
+
const Deliver = createDeliver(options.abortRun);
|
|
86
|
+
let lead, worker;
|
|
87
|
+
const core = createAuthorityTools({ ...options, sync,
|
|
88
|
+
createWorker: async (tx, role) => {
|
|
89
|
+
const s = await open(tx), binding = await createAnchoredConversation(tx, s.team.current.lead);
|
|
90
|
+
await configure(tx, binding.conversationId, { model: role.model, ...(role.thinking ? { thinkingLevel: role.thinking } : {}), extensions: { remove: [lead], add: [worker] } });
|
|
91
|
+
return binding;
|
|
92
|
+
},
|
|
93
|
+
settleStops: async (stops, context, api) => stops.length ? (await options.stop(api, stops.map(s => stopPlan(s.key, s.intent)), context)).map(o => o.status === 'settled') : [],
|
|
94
|
+
afterCommand: async (tx, s, name, result) => {
|
|
95
|
+
const team = s.team.current;
|
|
96
|
+
if (!team || team.lifecycle !== 'active')
|
|
97
|
+
return;
|
|
98
|
+
if (name === 'team_create') {
|
|
99
|
+
const o = await observations(tx);
|
|
100
|
+
o.epoch = team.epoch;
|
|
101
|
+
o.acknowledged = 0;
|
|
102
|
+
o.paused = false;
|
|
103
|
+
}
|
|
104
|
+
if (!team.notifierId && !team.shutdownPending)
|
|
105
|
+
team.notifierId = await tx.createTask(Notify, { epoch: team.epoch }, { conversationId: team.lead, ownership: { kind: 'conversation' }, background: true, abandonOnRestart: false });
|
|
106
|
+
await scheduleDeliveries(tx, s, Deliver);
|
|
107
|
+
const extra = addMechanicStops(s, name === 'team_shutdown');
|
|
108
|
+
if (extra && Array.isArray(result.stops) && !result.stops.includes(extra))
|
|
109
|
+
result.stops.push(extra);
|
|
110
|
+
}, });
|
|
111
|
+
lead = { name: 'ptb.lead', tools: core.lead };
|
|
112
|
+
worker = { name: 'ptb.worker', tools: core.worker };
|
|
113
|
+
const mechanics = { name: 'ptb.mechanics', tasks: [WorkerAnchor, Deliver, Notify] };
|
|
114
|
+
const observation = observationExtension(options.observationPort);
|
|
115
|
+
return { core, lead, worker, mechanics, observation, extensions: [mechanics, lead, worker, observation], tasks: { WorkerAnchor, Deliver, Notify } };
|
|
116
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { type ConversationId, type TaskId, type SubmissionId, type EntryId, type JsonObject, type Tx } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { GraphControlSnapshot } from './graph-control.ts';
|
|
3
|
+
export interface Binding {
|
|
4
|
+
conversationId: ConversationId;
|
|
5
|
+
anchorId: TaskId;
|
|
6
|
+
}
|
|
7
|
+
export interface ModelRole {
|
|
8
|
+
name: string;
|
|
9
|
+
use: string;
|
|
10
|
+
model: {
|
|
11
|
+
provider: string;
|
|
12
|
+
modelId: string;
|
|
13
|
+
};
|
|
14
|
+
thinking?: string;
|
|
15
|
+
}
|
|
16
|
+
/** The roles a lead can select, with the role ensure_worker uses when the call names none. */
|
|
17
|
+
export interface ModelRoleCatalog {
|
|
18
|
+
modelRoles: ModelRole[];
|
|
19
|
+
defaultModelRole?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface Worker extends Binding {
|
|
22
|
+
createdAt?: number;
|
|
23
|
+
name: string;
|
|
24
|
+
scope: string;
|
|
25
|
+
generation: number;
|
|
26
|
+
state: 'active' | 'stopping' | 'stopped';
|
|
27
|
+
role: ModelRole;
|
|
28
|
+
}
|
|
29
|
+
export interface Team {
|
|
30
|
+
createdAt?: number;
|
|
31
|
+
epoch: string;
|
|
32
|
+
name: string;
|
|
33
|
+
purpose: string;
|
|
34
|
+
lead: ConversationId;
|
|
35
|
+
lifecycle: 'active' | 'stopped';
|
|
36
|
+
workers: Record<string, Worker>;
|
|
37
|
+
shutdownPending?: boolean;
|
|
38
|
+
notifierId?: TaskId;
|
|
39
|
+
}
|
|
40
|
+
export interface StopIntent {
|
|
41
|
+
epoch: string;
|
|
42
|
+
worker?: string;
|
|
43
|
+
binding?: Binding;
|
|
44
|
+
generation?: number;
|
|
45
|
+
tasks: TaskId[];
|
|
46
|
+
conversations: ConversationId[];
|
|
47
|
+
confirmed: boolean;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface TeamState {
|
|
51
|
+
current?: Team;
|
|
52
|
+
history: Team[];
|
|
53
|
+
stops: Record<string, StopIntent>;
|
|
54
|
+
}
|
|
55
|
+
export interface GraphState {
|
|
56
|
+
epoch?: string;
|
|
57
|
+
snapshot?: GraphControlSnapshot;
|
|
58
|
+
history?: Record<string, GraphControlSnapshot>;
|
|
59
|
+
}
|
|
60
|
+
export interface Alert {
|
|
61
|
+
interrupt?: true;
|
|
62
|
+
id: string;
|
|
63
|
+
from: string;
|
|
64
|
+
kind: string;
|
|
65
|
+
text: string;
|
|
66
|
+
task_id?: string;
|
|
67
|
+
task_version?: string;
|
|
68
|
+
receivedAt: string;
|
|
69
|
+
recipients: {
|
|
70
|
+
name: string;
|
|
71
|
+
conversationId: ConversationId;
|
|
72
|
+
generation: number;
|
|
73
|
+
}[];
|
|
74
|
+
}
|
|
75
|
+
export interface Obligation {
|
|
76
|
+
interrupt?: true;
|
|
77
|
+
interruptRun?: TaskId;
|
|
78
|
+
interruption?: 'interrupted' | 'ended' | 'failed';
|
|
79
|
+
acceptedAt?: number;
|
|
80
|
+
submittedAt?: number;
|
|
81
|
+
settledAt?: number;
|
|
82
|
+
key: string;
|
|
83
|
+
epoch: string;
|
|
84
|
+
kind: 'task' | 'stop' | 'alert';
|
|
85
|
+
recipient: string;
|
|
86
|
+
conversationId: ConversationId;
|
|
87
|
+
generation: number;
|
|
88
|
+
taskId?: string;
|
|
89
|
+
version?: string;
|
|
90
|
+
alertId?: string;
|
|
91
|
+
state: 'pending' | 'retired' | 'settled';
|
|
92
|
+
deliveryTaskId?: TaskId;
|
|
93
|
+
submissionId?: SubmissionId;
|
|
94
|
+
outcome?: string;
|
|
95
|
+
}
|
|
96
|
+
export interface OutboxState {
|
|
97
|
+
alerts: Record<string, Alert>;
|
|
98
|
+
obligations: Record<string, Obligation>;
|
|
99
|
+
}
|
|
100
|
+
export interface Event {
|
|
101
|
+
seq: number;
|
|
102
|
+
epoch: string;
|
|
103
|
+
actor: string;
|
|
104
|
+
kind: string;
|
|
105
|
+
taskId?: string;
|
|
106
|
+
taskIds?: string[];
|
|
107
|
+
worker?: string;
|
|
108
|
+
at?: string;
|
|
109
|
+
text?: string;
|
|
110
|
+
entryId?: EntryId;
|
|
111
|
+
}
|
|
112
|
+
export interface JournalState {
|
|
113
|
+
seq: number;
|
|
114
|
+
events: Event[];
|
|
115
|
+
reservation?: {
|
|
116
|
+
epoch: string;
|
|
117
|
+
call: string;
|
|
118
|
+
through: number;
|
|
119
|
+
};
|
|
120
|
+
acknowledged: number;
|
|
121
|
+
}
|
|
122
|
+
export type Result = Record<string, unknown> & {
|
|
123
|
+
kind: string;
|
|
124
|
+
};
|
|
125
|
+
export interface Receipt {
|
|
126
|
+
fingerprint: string;
|
|
127
|
+
result?: Result;
|
|
128
|
+
stops?: string[];
|
|
129
|
+
epoch?: string;
|
|
130
|
+
}
|
|
131
|
+
export interface ReceiptState {
|
|
132
|
+
calls: Record<string, Receipt>;
|
|
133
|
+
}
|
|
134
|
+
export declare const TeamDoc: import("@earendil-works/pi-durable").SessionDocToken<JsonObject>;
|
|
135
|
+
export declare const GraphDoc: import("@earendil-works/pi-durable").SessionDocToken<JsonObject>;
|
|
136
|
+
export declare const JournalDoc: import("@earendil-works/pi-durable").SessionDocToken<JsonObject>;
|
|
137
|
+
export declare const OutboxDoc: import("@earendil-works/pi-durable").SessionDocToken<JsonObject>;
|
|
138
|
+
export declare const ReceiptDoc: import("@earendil-works/pi-durable").SessionDocToken<JsonObject>;
|
|
139
|
+
export declare function open(tx: Tx): Promise<{
|
|
140
|
+
team: TeamState;
|
|
141
|
+
graph: GraphState;
|
|
142
|
+
journal: JournalState;
|
|
143
|
+
outbox: OutboxState;
|
|
144
|
+
receipts: ReceiptState;
|
|
145
|
+
}>;
|
|
146
|
+
export type State = Awaited<ReturnType<typeof open>>;
|
|
147
|
+
export declare const plain: <T>(value: T) => T;
|
|
148
|
+
export declare const JOURNAL_RESIDENT_LIMIT = 100;
|
|
149
|
+
/** History is append-only storage; the document contains a bounded recent projection. */
|
|
150
|
+
export declare function event(tx: Tx, s: State, actor: string, kind: string, extra?: Pick<Event, 'taskId' | 'taskIds' | 'worker' | 'at' | 'text'>): Promise<void>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineDoc } from '@earendil-works/pi-durable';
|
|
2
|
+
const doc = (kind, initial) => defineDoc({ kind, version: 1, scope: 'session', checkpointWhen: () => false, initial });
|
|
3
|
+
export const TeamDoc = doc('ptb.team', () => ({ history: [], stops: {} }));
|
|
4
|
+
export const GraphDoc = doc('ptb.graph', () => ({}));
|
|
5
|
+
export const JournalDoc = defineDoc({ kind: 'ptb.journal', version: 1, scope: 'session', initial: () => ({ seq: 0, events: [], acknowledged: 0 }), checkpointWhen: (_value, _ops, info) => info.deltasSinceBase >= 31 });
|
|
6
|
+
export const OutboxDoc = doc('ptb.outbox', () => ({ alerts: {}, obligations: {} }));
|
|
7
|
+
export const ReceiptDoc = doc('ptb.receipts', () => ({ calls: {} }));
|
|
8
|
+
export async function open(tx) {
|
|
9
|
+
return {
|
|
10
|
+
team: await tx.doc(TeamDoc),
|
|
11
|
+
graph: await tx.doc(GraphDoc),
|
|
12
|
+
journal: await tx.doc(JournalDoc),
|
|
13
|
+
outbox: await tx.doc(OutboxDoc),
|
|
14
|
+
receipts: await tx.doc(ReceiptDoc),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export const plain = (value) => JSON.parse(JSON.stringify(value));
|
|
18
|
+
export const JOURNAL_RESIDENT_LIMIT = 100;
|
|
19
|
+
/** History is append-only storage; the document contains a bounded recent projection. */
|
|
20
|
+
export async function event(tx, s, actor, kind, extra = {}) {
|
|
21
|
+
const value = { seq: ++s.journal.seq, epoch: s.team.current.epoch, actor, kind, ...extra };
|
|
22
|
+
const entry = await tx.appendEntry(s.team.current.lead, { kind: 'ptb.event', data: value });
|
|
23
|
+
s.journal.events.push({ ...value, entryId: entry.id });
|
|
24
|
+
if (s.journal.events.length > JOURNAL_RESIDENT_LIMIT)
|
|
25
|
+
s.journal.events.splice(0, s.journal.events.length - JOURNAL_RESIDENT_LIMIT);
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Tx, type EntryId } from '@earendil-works/pi-durable';
|
|
2
|
+
import type { CoreApi } from './tools.ts';
|
|
3
|
+
import { type State, type Event, type Result } from './state.ts';
|
|
4
|
+
export declare const UPDATE_PAGE_EVENTS = 50;
|
|
5
|
+
export declare const UPDATE_SCAN_ENTRIES = 100;
|
|
6
|
+
export declare function updates(s: State, events: Event[]): Result;
|
|
7
|
+
/** Page native history without making the journal document an unbounded cache. */
|
|
8
|
+
export declare function pageEvents(tx: Tx, s: State, from: number, after?: EntryId): Promise<{
|
|
9
|
+
events: Event[];
|
|
10
|
+
scanThrough?: EntryId;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function sync(tx: Tx, s: State, view: 'snapshot' | 'updates' | undefined, api: CoreApi, snapshot: Result): Promise<Result>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { LiveDoc } from '@earendil-works/pi-durable';
|
|
2
|
+
import { GraphTaskController } from "./graph-control.js";
|
|
3
|
+
import { projectGraphTaskCards } from "./graph-card.js";
|
|
4
|
+
import { observations, reservationHead, reserve } from "./observation.js";
|
|
5
|
+
import { plain } from "./state.js";
|
|
6
|
+
export const UPDATE_PAGE_EVENTS = 50;
|
|
7
|
+
export const UPDATE_SCAN_ENTRIES = 100;
|
|
8
|
+
export function updates(s, events) {
|
|
9
|
+
const team = s.team.current, cards = projectGraphTaskCards(GraphTaskController.recover(plain(s.graph.snapshot)));
|
|
10
|
+
const ids = [...new Set(events.flatMap(e => e.taskIds ?? (e.taskId ? [e.taskId] : [])))];
|
|
11
|
+
return { kind: 'updates',
|
|
12
|
+
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
|
+
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.' })),
|
|
14
|
+
task_changes: ids.flatMap(id => { const current = cards.find(t => t.id === id); return current ? [{ task_id: id, change_kinds: ['status'], journal_entries: events.filter(e => e.taskId === id && e.at && e.text).map(e => ({ id: `${e.epoch}:${e.seq}`, at: e.at, actor: e.actor, kind: 'note', text: e.text })), current }] : []; }),
|
|
15
|
+
alerts: [], };
|
|
16
|
+
}
|
|
17
|
+
/** Page native history without making the journal document an unbounded cache. */
|
|
18
|
+
export async function pageEvents(tx, s, from, after) {
|
|
19
|
+
const recent = s.journal.events.filter(e => e.seq > from);
|
|
20
|
+
if (recent.length && s.journal.events[0].seq <= from + 1) {
|
|
21
|
+
const events = recent.slice(0, UPDATE_PAGE_EVENTS);
|
|
22
|
+
return { events, scanThrough: events.at(-1)?.entryId };
|
|
23
|
+
}
|
|
24
|
+
const native = await tx.scanEntries({ conversationId: s.team.current.lead, order: 'ascending', ...(after ? { minEntryId: after } : {}) }, UPDATE_SCAN_ENTRIES);
|
|
25
|
+
const events = [];
|
|
26
|
+
let scanThrough = after;
|
|
27
|
+
for (const entry of native.items) {
|
|
28
|
+
if (after !== undefined && entry.id <= after)
|
|
29
|
+
continue;
|
|
30
|
+
scanThrough = entry.id;
|
|
31
|
+
if (entry.kind === 'ptb.event') {
|
|
32
|
+
const event = entry.data;
|
|
33
|
+
if (event.epoch === s.team.current.epoch && event.seq > from)
|
|
34
|
+
events.push({ ...event, entryId: entry.id });
|
|
35
|
+
}
|
|
36
|
+
if (events.length >= UPDATE_PAGE_EVENTS)
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
return { events, scanThrough };
|
|
40
|
+
}
|
|
41
|
+
export async function sync(tx, s, view, api, snapshot) {
|
|
42
|
+
const team = s.team.current, live = await tx.doc(LiveDoc, api.conversationId), run = live.run?.inputs[0];
|
|
43
|
+
if (run === undefined)
|
|
44
|
+
return { kind: 'unavailable', reason: 'team_state_unavailable', message: 'Observation requires a persisted lead run.' };
|
|
45
|
+
const o = await observations(tx);
|
|
46
|
+
o.paused = false;
|
|
47
|
+
if (Object.values(o.pages).some(p => p.epoch === team.epoch && p.run !== run && ['reserved', 'presented'].includes(p.state)))
|
|
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);
|
|
50
|
+
const carrier = { kind: 'tool', callId: api.callId ?? String(api.taskId) };
|
|
51
|
+
// Failed-run pages reappear once in order; the old replay receipt remains immutable.
|
|
52
|
+
const released = Object.values(o.pages).find(p => p.epoch === team.epoch && p.state === 'released');
|
|
53
|
+
if (released) {
|
|
54
|
+
const result = JSON.parse(released.body);
|
|
55
|
+
released.state = 'replaced';
|
|
56
|
+
reserve(o, { id, epoch: team.epoch, lead: team.lead, run, from: released.from, through: released.through, carrier, ...(released.scanThrough ? { scanThrough: released.scanThrough } : {}) }, result);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
const outstanding = Object.values(o.pages).filter(p => p.epoch === team.epoch && p.run === run && ['reserved', 'presented'].includes(p.state));
|
|
60
|
+
if ((view !== 'snapshot' || outstanding.length > 0) && from >= s.journal.seq)
|
|
61
|
+
return { kind: 'caught_up', head: s.journal.seq, epoch_id: team.epoch };
|
|
62
|
+
let result, through, scanThrough;
|
|
63
|
+
if (!outstanding.length && (view === 'snapshot' || from === 0)) {
|
|
64
|
+
result = snapshot;
|
|
65
|
+
through = s.journal.seq;
|
|
66
|
+
scanThrough = s.journal.events.at(-1)?.entryId;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const previous = Object.values(o.pages).filter(p => p.epoch === team.epoch && p.scanThrough !== undefined && (p.state === 'acknowledged' || p.run === run && ['reserved', 'presented'].includes(p.state))).sort((a, b) => Number(b.scanThrough) - Number(a.scanThrough))[0];
|
|
70
|
+
const page = await pageEvents(tx, s, from, previous?.scanThrough);
|
|
71
|
+
result = updates(s, page.events);
|
|
72
|
+
through = page.events.at(-1)?.seq ?? from;
|
|
73
|
+
scanThrough = page.scanThrough;
|
|
74
|
+
}
|
|
75
|
+
reserve(o, { id, epoch: team.epoch, lead: team.lead, run, from, through, carrier, ...(scanThrough ? { scanThrough } : {}) }, result);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Context } from '@earendil-works/chord/context';
|
|
2
|
+
import { type ToolExecutionApi, type Tx } from '@earendil-works/pi-durable';
|
|
3
|
+
import { type Role, type ToolName } from './contracts.ts';
|
|
4
|
+
import { type GraphTaskTransition, type GraphVersionRef } from './graph-control.ts';
|
|
5
|
+
import type { TaskVersionRef } from './task-version-ref.ts';
|
|
6
|
+
import type { ConversationId } from '@earendil-works/pi-durable';
|
|
7
|
+
import { type Binding, type ModelRole, type ModelRoleCatalog, type Result, type State, type StopIntent } from './state.ts';
|
|
8
|
+
export type CoreApi = Pick<ToolExecutionApi, 'taskId' | 'conversationId' | 'commit' | 'now'> & Partial<Pick<ToolExecutionApi, 'callId'>>;
|
|
9
|
+
export interface CoreOptions {
|
|
10
|
+
modelRoles: ModelRole[];
|
|
11
|
+
defaultModelRole?: string;
|
|
12
|
+
goalMaxLength?: number;
|
|
13
|
+
createWorker?: (tx: Tx, role: ModelRole) => Promise<Binding>;
|
|
14
|
+
resolveModelRole?: (tx: Tx, role: ModelRole) => Promise<ModelRole>;
|
|
15
|
+
catalog?: (tx: Tx, lead: ConversationId) => Promise<ModelRoleCatalog>;
|
|
16
|
+
stopWorker?: (binding: Binding, context: Context) => Promise<boolean>;
|
|
17
|
+
settleStops?: (stops: {
|
|
18
|
+
key: string;
|
|
19
|
+
intent: StopIntent;
|
|
20
|
+
}[], context: Context, api: CoreApi) => Promise<boolean[]>;
|
|
21
|
+
afterCommand?: (tx: Tx, s: State, name: ToolName, result: Result | {
|
|
22
|
+
stops: string[];
|
|
23
|
+
epoch: string;
|
|
24
|
+
}, api: CoreApi) => Promise<void>;
|
|
25
|
+
sync?: (tx: Tx, s: State, view: Args['view'], api: CoreApi, snapshot: Result) => Promise<Result>;
|
|
26
|
+
}
|
|
27
|
+
interface Args {
|
|
28
|
+
name?: string;
|
|
29
|
+
purpose?: string;
|
|
30
|
+
scope?: string;
|
|
31
|
+
model_role?: string;
|
|
32
|
+
worker?: string;
|
|
33
|
+
view?: 'snapshot' | 'updates';
|
|
34
|
+
task_id?: string;
|
|
35
|
+
task_ids?: string[];
|
|
36
|
+
operation_id?: string;
|
|
37
|
+
expected_graph_version?: GraphVersionRef;
|
|
38
|
+
expected_version?: TaskVersionRef;
|
|
39
|
+
transition?: GraphTaskTransition;
|
|
40
|
+
current_context?: string;
|
|
41
|
+
evidence?: string;
|
|
42
|
+
tasks?: {
|
|
43
|
+
key: string;
|
|
44
|
+
title: string;
|
|
45
|
+
goal: string;
|
|
46
|
+
assignee: string;
|
|
47
|
+
needs?: string[];
|
|
48
|
+
on_goal_failed?: {
|
|
49
|
+
target: string;
|
|
50
|
+
max_traversals: number;
|
|
51
|
+
};
|
|
52
|
+
}[];
|
|
53
|
+
to?: string;
|
|
54
|
+
kind?: string;
|
|
55
|
+
text?: string;
|
|
56
|
+
task_version?: string;
|
|
57
|
+
interrupt?: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** The catalog resolver that options select: the host's per-lead port, else the static list. */
|
|
60
|
+
export declare function catalogOf(options: Pick<CoreOptions, 'modelRoles' | 'defaultModelRole' | 'catalog'>): (tx: Tx, lead: ConversationId) => Promise<ModelRoleCatalog>;
|
|
61
|
+
/** Authority commands commit intent and replay receipts; injected ports own effects. */
|
|
62
|
+
export declare function createAuthorityTools(options: CoreOptions): {
|
|
63
|
+
lead: import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>[];
|
|
64
|
+
worker: import("@earendil-works/pi-durable").ToolRegistration<import("typebox").TSchema, import("@earendil-works/chord").JsonValue>[];
|
|
65
|
+
execute: (name: ToolName, role: Role, input: unknown, api: CoreApi, context: Context) => Promise<Result>;
|
|
66
|
+
contract: import("./contracts.ts").Contract;
|
|
67
|
+
};
|
|
68
|
+
export {};
|