@parcha/agentrun-pi 0.1.0-beta.12 → 0.1.0-beta.13
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 +20 -8
- package/dist/cli.js +10 -1
- package/dist/demo-cli.d.ts +2 -0
- package/dist/demo-cli.js +25 -0
- package/dist/durable/decision-recovery.d.ts +23 -0
- package/dist/durable/decision-recovery.js +57 -0
- package/dist/durable/decisions.d.ts +113 -0
- package/dist/durable/decisions.js +71 -0
- package/dist/durable/index.d.ts +19 -0
- package/dist/durable/index.js +13 -0
- package/dist/durable/node-runner.d.ts +100 -0
- package/dist/durable/node-runner.js +243 -0
- package/dist/durable/node.d.ts +88 -0
- package/dist/durable/node.js +81 -0
- package/dist/durable/pi-versions.d.ts +34 -0
- package/dist/durable/pi-versions.js +66 -0
- package/dist/durable/readers.d.ts +25 -0
- package/dist/durable/readers.js +64 -0
- package/dist/durable/receipts.d.ts +52 -0
- package/dist/durable/receipts.js +91 -0
- package/dist/durable/record-file.d.ts +10 -0
- package/dist/durable/record-file.js +56 -0
- package/dist/durable/record-tool.d.ts +76 -0
- package/dist/durable/record-tool.js +123 -0
- package/dist/durable/record.d.ts +157 -0
- package/dist/durable/record.js +402 -0
- package/dist/durable/recording.d.ts +90 -0
- package/dist/durable/recording.js +292 -0
- package/dist/durable/store.d.ts +23 -0
- package/dist/durable/store.js +192 -0
- package/dist/durable/task.d.ts +129 -0
- package/dist/durable/task.js +395 -0
- package/dist/extension-service.js +14 -1
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +98 -13
- package/dist/host-session.js +3 -1
- package/dist/run-error.d.ts +6 -0
- package/dist/run-error.js +13 -0
- package/dist/runner.d.ts +1 -6
- package/dist/runner.js +2 -12
- package/dist/sop.d.ts +12 -0
- package/dist/sop.js +29 -0
- package/dist/tool-context.d.ts +21 -0
- package/dist/tool-context.js +13 -0
- package/dist/workflow-session.d.ts +4 -0
- package/dist/workflow-session.js +3 -2
- package/package.json +43 -8
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// A run's store as durable recording lines (agentrun.recording/1, section 3.1). Three readers share one mapper, so a
|
|
2
|
+
// commit gives the same lines whichever reads it:
|
|
3
|
+
// - recordCommits, inside the run's process, maps each commit pi publishes as it is adopted (the full history,
|
|
4
|
+
// tasks at their real commit);
|
|
5
|
+
// - snapshotLines writes every document at one commit, for a process to record after it opens the run;
|
|
6
|
+
// - recordingLines reads a store file no process holds: the history pi kept (it deletes a current-only document's
|
|
7
|
+
// older revisions when it writes a new base, and the driver document checkpoints every 32 deltas).
|
|
8
|
+
import { apply } from "@earendil-works/chord/delta";
|
|
9
|
+
import { frozenEffectPath } from "@parcha/agentrun-dsl/recovery";
|
|
10
|
+
import { withDurableFile } from "./readers.js";
|
|
11
|
+
const PREFIXES = [["agentrun.", "workflow"], ["browser.", "browser"], ["vision.", "vision"], ["transport.", "transport"], ["pi.", "pi"]];
|
|
12
|
+
const sourceOf = (kind) => PREFIXES.find(([prefix]) => kind.startsWith(prefix))?.[1] ?? "host";
|
|
13
|
+
/** The path a node attempt's session key names: `<driver>:<label>:<frame>:<path>:a<n>` (`frozenStepSessionId`). A label
|
|
14
|
+
* or a path may hold the frame marker too, and then the key names no single path: only a key with one marker has one. */
|
|
15
|
+
function nodePath(key) {
|
|
16
|
+
const frames = [...key.matchAll(/:(?:step|adapt):\//g)];
|
|
17
|
+
const attempt = /:a\d+$/.exec(key);
|
|
18
|
+
if (frames.length !== 1 || !attempt)
|
|
19
|
+
return undefined;
|
|
20
|
+
return key.slice(frames[0].index + frames[0][0].length - 1, attempt.index);
|
|
21
|
+
}
|
|
22
|
+
/** The execution path a document names: an effect's id is `<frame>:<path>#call:<n>` after the journal key and NUL
|
|
23
|
+
* (`frozenEffectId`, read by the DSL's own `frozenEffectPath`), a node attempt's key is `nodePath`'s, and any
|
|
24
|
+
* other value may carry `executionPath` or `execution_path`. */
|
|
25
|
+
function pathOf(kind, key, value) {
|
|
26
|
+
if (kind === "agentrun.effects" && key !== undefined)
|
|
27
|
+
return frozenEffectPath(key.slice(key.indexOf("\u0000") + 1))?.path;
|
|
28
|
+
if (kind === "agentrun.nodes" && key !== undefined)
|
|
29
|
+
return nodePath(key);
|
|
30
|
+
const named = value?.executionPath ?? value?.execution_path;
|
|
31
|
+
return typeof named === "string" ? named : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** A node attempt's conversation and path, from an `agentrun.nodes` document: its value names the conversation the
|
|
34
|
+
* attempt runs in, its key the step's path. */
|
|
35
|
+
function nodeConversation(key, value) {
|
|
36
|
+
const path = key === undefined ? undefined : nodePath(key);
|
|
37
|
+
return typeof value?.conversation === "number" && path !== undefined ? [value.conversation, path] : undefined;
|
|
38
|
+
}
|
|
39
|
+
/** A line in a node's conversation (an entry, a conversation's document, a task) takes the node's path when it names
|
|
40
|
+
* none of its own, so a branch's view holds its agent's turns. */
|
|
41
|
+
function stampPaths(placed, conversations) {
|
|
42
|
+
for (const at of placed) {
|
|
43
|
+
const path = at.line.conv === undefined || at.line.path !== undefined ? undefined : conversations.get(at.line.conv);
|
|
44
|
+
if (path !== undefined)
|
|
45
|
+
at.line = { ...at.line, path };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** A document revision: `key` is the family key (absent for a singleton), `conv` the conversation the document
|
|
49
|
+
* belongs to (its scope's, or its task's). */
|
|
50
|
+
function documentLine(id, kind, key, value, version, conv, commit) {
|
|
51
|
+
const path = pathOf(kind, key, value);
|
|
52
|
+
return { commit, rank: 0, id, line: {
|
|
53
|
+
channel: "durable", source: sourceOf(kind), kind, commit, ...(path !== undefined ? { path } : {}), ...(conv !== undefined ? { conv } : {}),
|
|
54
|
+
data: { doc: kind, ...(key !== undefined ? { key } : {}), value, version },
|
|
55
|
+
} };
|
|
56
|
+
}
|
|
57
|
+
const entryLine = (entry, commit) => ({ commit, rank: 1, id: Number(entry.id), line: {
|
|
58
|
+
channel: "durable", source: "pi", kind: String(entry?.kind ?? "pi.entry"), commit, conv: Number(entry.conversationId), data: entry,
|
|
59
|
+
} });
|
|
60
|
+
const taskLine = (task, commit) => {
|
|
61
|
+
const kind = String(task.kind);
|
|
62
|
+
return { commit, rank: 2, id: Number(task.id), line: { channel: "durable", source: sourceOf(kind), kind, commit, conv: Number(task.conversationId), data: task } };
|
|
63
|
+
};
|
|
64
|
+
/** Lines in `(commit, order)` order, each with the generation `genAt` gives its commit. */
|
|
65
|
+
function numbered(placed, genAt, extra = {}) {
|
|
66
|
+
placed.sort((a, b) => a.commit - b.commit || a.rank - b.rank || a.id - b.id);
|
|
67
|
+
const lines = [];
|
|
68
|
+
for (let i = 0; i < placed.length; i += 1) {
|
|
69
|
+
const { commit, line } = placed[i];
|
|
70
|
+
const order = i > 0 && placed[i - 1].commit === commit ? lines[i - 1].order + 1 : 0;
|
|
71
|
+
const gen = genAt(commit);
|
|
72
|
+
const { data, ...rest } = line;
|
|
73
|
+
lines.push({ ...rest, ...(gen !== undefined ? { gen } : {}), order, ...extra, data });
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
/** Each node attempt's conversation and path, from every `agentrun.nodes` document's latest value. */
|
|
78
|
+
function nodeConversations(db) {
|
|
79
|
+
const out = new Map();
|
|
80
|
+
for (const doc of db.prepare("SELECT id, key_value FROM documents WHERE kind = ? AND family = 1").all(JSON.stringify("agentrun.nodes"))) {
|
|
81
|
+
let value;
|
|
82
|
+
for (const rev of db.prepare("SELECT kind, content FROM document_revisions WHERE document_id = ? ORDER BY seq").all(doc.id)) {
|
|
83
|
+
value = rev.kind === "base" ? JSON.parse(rev.content) : apply(structuredClone(value), JSON.parse(rev.content));
|
|
84
|
+
}
|
|
85
|
+
const found = nodeConversation(JSON.parse(doc.key_value), value);
|
|
86
|
+
if (found)
|
|
87
|
+
out.set(found[0], found[1]);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
/** Every surviving `agentrun.driver` revision as `[commit, generation]`, in commit order. */
|
|
92
|
+
function driverGenerations(db) {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const { id } of db.prepare("SELECT id FROM documents WHERE kind = ?").all(JSON.stringify("agentrun.driver"))) {
|
|
95
|
+
let value;
|
|
96
|
+
for (const rev of db.prepare("SELECT seq, kind, content FROM document_revisions WHERE document_id = ? ORDER BY seq").all(id)) {
|
|
97
|
+
value = rev.kind === "base" ? JSON.parse(rev.content) : apply(structuredClone(value), JSON.parse(rev.content));
|
|
98
|
+
if (typeof value?.generation === "number")
|
|
99
|
+
out.push([rev.seq, value.generation]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Two journals' revisions in one commit: the higher generation.
|
|
103
|
+
return out.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Every change the store `file` committed after `after` (default: all), as durable recording lines in `(commit, order)`
|
|
107
|
+
* order, and `last`, the highest commit returned (`after` when nothing is). For a store no process holds (a disk run's
|
|
108
|
+
* store admits one connection): read-only, in one read transaction.
|
|
109
|
+
*
|
|
110
|
+
* - A document revision is a line of the document's kind, with the whole value after it (each delta applied over its
|
|
111
|
+
* base); `key` is the family key as stored, absent for a singleton.
|
|
112
|
+
* - A conversation entry is a line of the entry's own kind, source `pi`, `conv` its conversation, the entry as stored
|
|
113
|
+
* (image blocks included: the recorder moves them to blobs).
|
|
114
|
+
* - Every task's current record is a line of the task's kind, at the store's last commit, after that commit's other
|
|
115
|
+
* lines, by task id: the state at export, since the store keeps no commit for a task's change. Its memos ride in the
|
|
116
|
+
* record; there are no memo lines.
|
|
117
|
+
* - `gen` is the generation of the latest surviving `agentrun.driver` revision at or before the line's commit (it
|
|
118
|
+
* counts journal opens, not processes), and absent before the earliest surviving one. No line has `t`: the store
|
|
119
|
+
* keeps no commit time.
|
|
120
|
+
*
|
|
121
|
+
* This is the store's surviving history: pi deletes a current-only document's older revisions when it writes a new
|
|
122
|
+
* base. The full history is `recordCommits`', written inside the run's process as each commit lands.
|
|
123
|
+
*/
|
|
124
|
+
export function recordingLines(file, options = {}) {
|
|
125
|
+
const after = options.after ?? 0;
|
|
126
|
+
return withDurableFile(file, (db) => {
|
|
127
|
+
const placed = [];
|
|
128
|
+
const taskConversation = new Map(db.prepare("SELECT id, conversation_id FROM tasks").all().map((task) => [task.id, task.conversation_id]));
|
|
129
|
+
const docs = db.prepare("SELECT DISTINCT d.id, d.kind, d.family, d.key_value, d.scope_kind, d.owner_id FROM documents d JOIN document_revisions r ON r.document_id = d.id WHERE r.seq > ?")
|
|
130
|
+
.all(after);
|
|
131
|
+
for (const doc of docs) {
|
|
132
|
+
const kind = JSON.parse(doc.kind);
|
|
133
|
+
const key = doc.family === 1 ? JSON.parse(doc.key_value) : undefined;
|
|
134
|
+
const conv = doc.scope_kind === "conversation" ? doc.owner_id : doc.scope_kind === "task" ? taskConversation.get(doc.owner_id) : undefined;
|
|
135
|
+
let value;
|
|
136
|
+
for (const rev of db.prepare("SELECT seq, kind, version, content FROM document_revisions WHERE document_id = ? ORDER BY seq").all(doc.id)) {
|
|
137
|
+
value = rev.kind === "base" ? JSON.parse(rev.content) : apply(structuredClone(value), JSON.parse(rev.content));
|
|
138
|
+
if (rev.seq > after)
|
|
139
|
+
placed.push(documentLine(doc.id, kind, key, structuredClone(value), rev.version, conv, rev.seq));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const entry of db.prepare("SELECT commit_seq, record FROM entries WHERE commit_seq > ?").all(after)) {
|
|
143
|
+
placed.push(entryLine(JSON.parse(entry.record), entry.commit_seq));
|
|
144
|
+
}
|
|
145
|
+
const end = db.prepare("SELECT next_seq FROM durable_metadata WHERE singleton = 1").get().next_seq - 1;
|
|
146
|
+
if (end > after)
|
|
147
|
+
for (const task of db.prepare("SELECT record FROM tasks").all())
|
|
148
|
+
placed.push(taskLine(JSON.parse(task.record), end));
|
|
149
|
+
stampPaths(placed, nodeConversations(db));
|
|
150
|
+
const generations = driverGenerations(db);
|
|
151
|
+
let at = 0;
|
|
152
|
+
const lines = numbered(placed, (commit) => {
|
|
153
|
+
while (at < generations.length && generations[at][0] <= commit)
|
|
154
|
+
at += 1;
|
|
155
|
+
return at > 0 ? generations[at - 1][1] : undefined;
|
|
156
|
+
});
|
|
157
|
+
return { lines, last: lines.length ? lines[lines.length - 1].commit : after };
|
|
158
|
+
}) ?? { lines: [], last: after };
|
|
159
|
+
}
|
|
160
|
+
/** The lines of one commit pi published, as `recordingLines` would give them. A commit that writes driver revisions
|
|
161
|
+
* sets the generation to the highest among them, as the file read does; any other commit keeps it. */
|
|
162
|
+
function publicationLines(publication, state) {
|
|
163
|
+
const commit = Number(publication.seq);
|
|
164
|
+
const placed = [];
|
|
165
|
+
let written;
|
|
166
|
+
for (const change of publication.changes) {
|
|
167
|
+
if (change.type === "document") {
|
|
168
|
+
// A retirement adopts no value and the store keeps no revision for it.
|
|
169
|
+
if (change.value === null || change.version === undefined)
|
|
170
|
+
continue;
|
|
171
|
+
const { record } = change;
|
|
172
|
+
placed.push(documentLine(Number(record.id), record.kind, record.key, structuredClone(change.value), change.version, change.conversationId === undefined ? undefined : Number(change.conversationId), commit));
|
|
173
|
+
const generation = change.value.generation;
|
|
174
|
+
if (record.kind === "agentrun.driver" && typeof generation === "number")
|
|
175
|
+
written = Math.max(written ?? generation, generation);
|
|
176
|
+
const node = record.kind === "agentrun.nodes" ? nodeConversation(record.key, change.value) : undefined;
|
|
177
|
+
if (node)
|
|
178
|
+
state.conversations.set(node[0], node[1]);
|
|
179
|
+
}
|
|
180
|
+
else if (change.type === "entry")
|
|
181
|
+
placed.push(entryLine(structuredClone(change.value), commit));
|
|
182
|
+
else if (change.type === "task")
|
|
183
|
+
placed.push(taskLine(structuredClone(change.value), commit));
|
|
184
|
+
// A conversation's or a submission's record, and a document copy (it carries no value), make no line.
|
|
185
|
+
}
|
|
186
|
+
if (written !== undefined)
|
|
187
|
+
state.gen = written;
|
|
188
|
+
stampPaths(placed, state.conversations);
|
|
189
|
+
return numbered(placed, () => state.gen);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Records the run's commits as durable lines inside its process, on pi's commit publications: each commit adopted
|
|
193
|
+
* after this call becomes the lines `recordingLines` would give it, with tasks at their real commit, `t` the adoption
|
|
194
|
+
* time (`clock()`, default `Date.now()`), and `gen` the generation of the latest `agentrun.driver` revision at or
|
|
195
|
+
* before the commit. `from` is the snapshot this recording continues (`snapshotLines`, written before the run
|
|
196
|
+
* resumed): its driver generation and its nodes' conversations carry on until this process writes its own. `gen` is
|
|
197
|
+
* only for a run with no driver revision yet; it never overrides a driver generation.
|
|
198
|
+
*
|
|
199
|
+
* The listener only maps and queues: it never throws, blocks or calls the Session. `write` receives one commit's lines
|
|
200
|
+
* at a time, in commit order, after the listener has returned. A `write` that completes synchronously (an append to a
|
|
201
|
+
* file) runs before pi adopts the next commit, so a process that dies loses at most the commit in flight; one that
|
|
202
|
+
* completes later leaves the commits after it queued in memory, and a death loses those too. The next process's
|
|
203
|
+
* `snapshotLines` restores the documents' state; a lost commit's entries stay missing, a gap the viewer shows. The
|
|
204
|
+
* returned stop ends the subscription, waits for every queued write, and then throws the first write that failed.
|
|
205
|
+
*/
|
|
206
|
+
export function recordCommits(session, options) {
|
|
207
|
+
const clock = options.clock ?? Date.now;
|
|
208
|
+
const fromDriver = (options.from ?? []).reduce((max, line) => {
|
|
209
|
+
const generation = line.kind === "agentrun.driver" ? line.data?.value?.generation : undefined;
|
|
210
|
+
return typeof generation === "number" ? Math.max(max ?? generation, generation) : max;
|
|
211
|
+
}, undefined);
|
|
212
|
+
const state = { gen: fromDriver ?? options.from?.at(-1)?.gen ?? options.gen, conversations: new Map() };
|
|
213
|
+
for (const line of options.from ?? []) {
|
|
214
|
+
const node = line.kind === "agentrun.nodes" ? nodeConversation(line.data?.key, line.data?.value) : undefined;
|
|
215
|
+
if (node)
|
|
216
|
+
state.conversations.set(node[0], node[1]);
|
|
217
|
+
}
|
|
218
|
+
let chain = Promise.resolve();
|
|
219
|
+
let failure;
|
|
220
|
+
const unsubscribe = session.subscribeCommits((publication) => {
|
|
221
|
+
try {
|
|
222
|
+
const t = clock();
|
|
223
|
+
const mapped = publicationLines(publication, state);
|
|
224
|
+
if (mapped.length === 0)
|
|
225
|
+
return;
|
|
226
|
+
const lines = mapped.map((line) => ({ ...line, t }));
|
|
227
|
+
chain = chain.then(() => options.write(lines)).catch((error) => { failure ??= { error }; });
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
failure ??= { error };
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return async () => {
|
|
234
|
+
unsubscribe();
|
|
235
|
+
await chain;
|
|
236
|
+
if (failure)
|
|
237
|
+
throw failure.error;
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** Every page of a storage scan. */
|
|
241
|
+
async function scanAll(scan) {
|
|
242
|
+
const out = [];
|
|
243
|
+
let cursor;
|
|
244
|
+
do {
|
|
245
|
+
const page = await scan(cursor);
|
|
246
|
+
out.push(...page.items);
|
|
247
|
+
cursor = page.next;
|
|
248
|
+
} while (cursor !== undefined);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Every document of the run as it stands, as whole values marked `snapshot: true` at commit `at`, in document id order:
|
|
253
|
+
* the state a process records after it opens the run and before it resumes, so the recording is exact after any kill.
|
|
254
|
+
* `storage` is the store the run's Harness was opened over; `at` is the store's latest commit (pi's `Storage` has no
|
|
255
|
+
* head query: a disk run reads it with pi-durable-disk's `storeHead`). The reads are of the current state (pi keeps no
|
|
256
|
+
* past revision of a session or task document), so the snapshot is exact only while nothing commits: take it before
|
|
257
|
+
* the run resumes. Every line's `gen` is the highest `agentrun.driver` generation in it; `gen` is only for a run with no
|
|
258
|
+
* driver revision yet, and never overrides one.
|
|
259
|
+
*/
|
|
260
|
+
export async function snapshotLines(storage, options) {
|
|
261
|
+
const { at, context } = options;
|
|
262
|
+
const page = 256;
|
|
263
|
+
const conversations = await scanAll((cursor) => storage.scanConversations({}, page, cursor, context));
|
|
264
|
+
const tasks = await scanAll((cursor) => storage.scanTasks({}, page, cursor, context));
|
|
265
|
+
const scopes = [
|
|
266
|
+
{ scope: { kind: "session" }, conv: undefined },
|
|
267
|
+
...conversations.map((conversation) => ({ scope: { kind: "conversation", conversationId: conversation.id }, conv: Number(conversation.id) })),
|
|
268
|
+
...tasks.map((task) => ({ scope: { kind: "task", taskId: task.id }, conv: Number(task.conversationId) })),
|
|
269
|
+
];
|
|
270
|
+
const placed = [];
|
|
271
|
+
let driverGen;
|
|
272
|
+
for (const { scope, conv } of scopes) {
|
|
273
|
+
for (const record of await scanAll((cursor) => storage.scanDocuments({ scope, at: "current" }, page, cursor, context))) {
|
|
274
|
+
const stored = await storage.document(record.id, "current", context);
|
|
275
|
+
if (!stored)
|
|
276
|
+
continue;
|
|
277
|
+
placed.push(documentLine(Number(record.id), record.kind, record.key, structuredClone(stored.value), stored.version, conv, at));
|
|
278
|
+
const generation = stored.value.generation;
|
|
279
|
+
if (record.kind === "agentrun.driver" && typeof generation === "number")
|
|
280
|
+
driverGen = Math.max(driverGen ?? generation, generation);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const nodes = new Map();
|
|
284
|
+
for (const { line } of placed) {
|
|
285
|
+
const node = line.kind === "agentrun.nodes" ? nodeConversation(line.data.key, line.data.value) : undefined;
|
|
286
|
+
if (node)
|
|
287
|
+
nodes.set(node[0], node[1]);
|
|
288
|
+
}
|
|
289
|
+
stampPaths(placed, nodes);
|
|
290
|
+
const gen = driverGen ?? options.gen;
|
|
291
|
+
return numbered(placed, () => gen, { snapshot: true });
|
|
292
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Harness } from "@earendil-works/pi-durable";
|
|
2
|
+
import type { Context } from "@earendil-works/chord";
|
|
3
|
+
import { type RecoveryStore } from "@parcha/agentrun-dsl/recovery";
|
|
4
|
+
/** An effect's key in `agentrun.effects`: its journal key, then its id. The two are joined by NUL, so neither may hold
|
|
5
|
+
* one: two pairs could otherwise name the same document. */
|
|
6
|
+
export declare const effectKey: (key: string, id: string) => string;
|
|
7
|
+
/** What a store writes through: a Harness, or the `commit` a running task is handed. */
|
|
8
|
+
export type DocumentStoreHost = Pick<Harness, "commit">;
|
|
9
|
+
export type DocumentStoreOptions = {
|
|
10
|
+
/** The one owner of the journal across the invocations of a task (its id). An open by this owner takes over a journal
|
|
11
|
+
* an earlier invocation left open, whose later writes then fail; an open by another owner is refused while the
|
|
12
|
+
* journal is open. The guard is per process, by journal key: an owner is unique to its run, and two Harness files in
|
|
13
|
+
* one process that hold one run key under one owner count as one. Without an owner the guard is per host object,
|
|
14
|
+
* and a second open of a key is refused until the first is closed. */
|
|
15
|
+
owner?: string;
|
|
16
|
+
/** The context every commit runs under; default `BACKGROUND_CONTEXT`. A task passes its invocation's, so the
|
|
17
|
+
* invocation's cancellation ends its writes. */
|
|
18
|
+
context?: Context;
|
|
19
|
+
};
|
|
20
|
+
/** The recovery store for the journal `key` that `host` writes. Opening it takes ownership: a journal already open is
|
|
21
|
+
* refused (unless the same `owner` opens it again), a binding that differs from the one the journal was first opened
|
|
22
|
+
* under is refused naming the inputs that moved, and the generation advances. */
|
|
23
|
+
export declare function documentStore(host: DocumentStoreHost, key: string, options?: DocumentStoreOptions): RecoveryStore;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { defineDocFamily } from "@earendil-works/pi-durable";
|
|
2
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
3
|
+
import { RecoveryError, asRecoveryError, bindingMismatch } from "@parcha/agentrun-dsl/recovery";
|
|
4
|
+
import { storageError } from "./pi-versions.js";
|
|
5
|
+
/** A failed commit as the driver reads it: its own message and code, kept as a RecoveryError. A Session that failed at a
|
|
6
|
+
* storage error answers with `SessionFailed` (pi-durable 1.2); the error is then the storage's, as 1.1 gave it, and the
|
|
7
|
+
* `SessionFailed` is its cause. */
|
|
8
|
+
function storeFailure(error) {
|
|
9
|
+
const own = storageError(error);
|
|
10
|
+
if (own === error || own instanceof RecoveryError)
|
|
11
|
+
return asRecoveryError(own);
|
|
12
|
+
const code = own?.code;
|
|
13
|
+
return new RecoveryError(own instanceof Error ? own.message : String(own), typeof code === "string" ? code : undefined, { cause: error });
|
|
14
|
+
}
|
|
15
|
+
/** An effect's key in `agentrun.effects`: its journal key, then its id. The two are joined by NUL, so neither may hold
|
|
16
|
+
* one: two pairs could otherwise name the same document. */
|
|
17
|
+
export const effectKey = (key, id) => {
|
|
18
|
+
if (key.includes("\u0000") || id.includes("\u0000"))
|
|
19
|
+
throw new RecoveryError("A journal key and an effect id hold no NUL character");
|
|
20
|
+
return `${key}\u0000${id}`;
|
|
21
|
+
};
|
|
22
|
+
/** A base every 32 deltas keeps a read of the journal short, however long the run. */
|
|
23
|
+
const checkpointWhen = (_value, _ops, info) => info.deltasSinceBase >= 32;
|
|
24
|
+
const DriverDoc = defineDocFamily({ kind: "agentrun.driver", version: 1, scope: "session", family: true, checkpointWhen,
|
|
25
|
+
initial: () => ({ binding: null, inputs: null, generation: 0, revision: 0, state: null, notes: [], effects: [] }) });
|
|
26
|
+
const EffectDoc = defineDocFamily({ kind: "agentrun.effects", version: 1, scope: "session", family: true,
|
|
27
|
+
initial: () => ({ driver: "", name: "", argsHash: "", status: "unknown", session: null, result: null }) });
|
|
28
|
+
/** A value as the journal stores it: plain JSON, every number finite. */
|
|
29
|
+
function json(value) {
|
|
30
|
+
return value === undefined ? null : JSON.parse(JSON.stringify(value, (_key, item) => {
|
|
31
|
+
if (typeof item === "bigint" || typeof item === "function" || typeof item === "symbol" || (typeof item === "number" && !Number.isFinite(item))) {
|
|
32
|
+
throw new RecoveryError("A journal holds finite JSON values only");
|
|
33
|
+
}
|
|
34
|
+
return item;
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
/** The journals open in this process. The run's lock keeps every other process off the run, and the journal's generation
|
|
38
|
+
* fences a stale writer; this keeps a second open of one key off it here. Without an owner a host's own keys are
|
|
39
|
+
* tracked; with one, the keys by owner. */
|
|
40
|
+
const hostKeys = new WeakMap();
|
|
41
|
+
const ownedKeys = new Map();
|
|
42
|
+
/** Take the in-process hold on `key`: what gives it back, and what undoes the take when the open fails. */
|
|
43
|
+
function hold(host, key, owner) {
|
|
44
|
+
const refused = () => new RecoveryError("Run already has a live owner");
|
|
45
|
+
if (owner === undefined) {
|
|
46
|
+
const keys = hostKeys.get(host) ?? hostKeys.set(host, new Set()).get(host);
|
|
47
|
+
if (keys.has(key))
|
|
48
|
+
throw refused();
|
|
49
|
+
keys.add(key);
|
|
50
|
+
const release = () => { keys.delete(key); };
|
|
51
|
+
return { release, undo: release };
|
|
52
|
+
}
|
|
53
|
+
const previous = ownedKeys.get(key);
|
|
54
|
+
if (previous && previous.owner !== owner)
|
|
55
|
+
throw refused();
|
|
56
|
+
const token = Symbol(key);
|
|
57
|
+
ownedKeys.set(key, { token, owner });
|
|
58
|
+
return {
|
|
59
|
+
release: () => { if (ownedKeys.get(key)?.token === token)
|
|
60
|
+
ownedKeys.delete(key); },
|
|
61
|
+
undo: () => { if (ownedKeys.get(key)?.token !== token)
|
|
62
|
+
return; if (previous)
|
|
63
|
+
ownedKeys.set(key, previous);
|
|
64
|
+
else
|
|
65
|
+
ownedKeys.delete(key); },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** The recovery store for the journal `key` that `host` writes. Opening it takes ownership: a journal already open is
|
|
69
|
+
* refused (unless the same `owner` opens it again), a binding that differs from the one the journal was first opened
|
|
70
|
+
* under is refused naming the inputs that moved, and the generation advances. */
|
|
71
|
+
export function documentStore(host, key, options = {}) {
|
|
72
|
+
return { open: (bound) => openDocumentJournal(host, key, bound, options) };
|
|
73
|
+
}
|
|
74
|
+
async function openDocumentJournal(host, key, bound, options) {
|
|
75
|
+
effectKey(key, "");
|
|
76
|
+
const context = options.context ?? BACKGROUND_CONTEXT;
|
|
77
|
+
const held = hold(host, key, options.owner);
|
|
78
|
+
const opened = await host.commit(async (tx) => {
|
|
79
|
+
const doc = await tx.doc(DriverDoc, key, null);
|
|
80
|
+
const existing = doc.binding !== null;
|
|
81
|
+
if (existing && doc.binding !== bound.binding)
|
|
82
|
+
throw bindingMismatch(doc.inputs ? JSON.stringify(doc.inputs) : null, bound.inputs);
|
|
83
|
+
if (!existing) {
|
|
84
|
+
doc.binding = bound.binding;
|
|
85
|
+
doc.inputs = bound.inputs ? { ...bound.inputs } : null;
|
|
86
|
+
}
|
|
87
|
+
doc.generation += 1;
|
|
88
|
+
const effects = [];
|
|
89
|
+
for (const id of doc.effects) {
|
|
90
|
+
const e = await tx.doc(EffectDoc, effectKey(key, id), null);
|
|
91
|
+
effects.push({ id, name: e.name, argsHash: e.argsHash, status: e.status, session: e.session, result: json(e.result), ...(e.intent ? { intent: json(e.intent) } : {}), ...(e.args !== undefined ? { args: json(e.args) } : {}), ...(Array.isArray(e.calls) ? { calls: json(e.calls) } : {}) });
|
|
92
|
+
}
|
|
93
|
+
return { existing, generation: doc.generation, revision: doc.revision, state: json(doc.state), notes: json(doc.notes), effects };
|
|
94
|
+
}, context).catch((error) => { held.undo(); throw storeFailure(error); });
|
|
95
|
+
const effects = new Map(opened.effects.map((effect) => [effect.id, effect]));
|
|
96
|
+
const notes = [...opened.notes];
|
|
97
|
+
let revision = opened.revision;
|
|
98
|
+
let closed = false;
|
|
99
|
+
/** Writes run one at a time, in the order they were asked for, each with its checks. A failed write does not stop
|
|
100
|
+
* the next, and one asked for before `close` still lands. */
|
|
101
|
+
let queue = Promise.resolve();
|
|
102
|
+
const inOrder = (write) => {
|
|
103
|
+
if (closed)
|
|
104
|
+
return Promise.reject(new RecoveryError("The journal is closed"));
|
|
105
|
+
const done = queue.then(write).catch((error) => { throw storeFailure(error); });
|
|
106
|
+
queue = done.catch(() => undefined);
|
|
107
|
+
return done;
|
|
108
|
+
};
|
|
109
|
+
/** One Session commit on the journal's record: it checks this open's generation is the record's, advances the
|
|
110
|
+
* revision, and lets `write` change the record (its state computed with that revision) and the effect documents. */
|
|
111
|
+
const commit = async (write) => host.commit(async (tx) => {
|
|
112
|
+
const doc = await tx.doc(DriverDoc, key, null);
|
|
113
|
+
if (doc.generation !== opened.generation)
|
|
114
|
+
throw new RecoveryError("Run owner generation is not acquired");
|
|
115
|
+
const next = doc.revision + 1;
|
|
116
|
+
const value = await write(doc, next, tx);
|
|
117
|
+
doc.revision = next;
|
|
118
|
+
return { revision: next, value };
|
|
119
|
+
}, context).then((done) => { revision = done.revision; return done; });
|
|
120
|
+
return {
|
|
121
|
+
existing: opened.existing, generation: opened.generation, state: opened.state,
|
|
122
|
+
get revision() { return revision; },
|
|
123
|
+
effects: () => [...effects.values()],
|
|
124
|
+
effect: (id) => effects.get(id),
|
|
125
|
+
notes: () => [...notes],
|
|
126
|
+
save: (state, note) => inOrder(async () => {
|
|
127
|
+
let entry;
|
|
128
|
+
const done = await commit((doc, next) => {
|
|
129
|
+
doc.state = json(typeof state === "function" ? state(next) : state);
|
|
130
|
+
if (note) {
|
|
131
|
+
entry = { revision: next, kind: note.kind, detail: json(note.detail), at: new Date().toISOString() };
|
|
132
|
+
doc.notes.push(entry);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
if (entry)
|
|
136
|
+
notes.push(entry);
|
|
137
|
+
return done.revision;
|
|
138
|
+
}),
|
|
139
|
+
note: (kind, detail) => inOrder(async () => {
|
|
140
|
+
const entry = { kind, detail: json(detail), at: new Date().toISOString() };
|
|
141
|
+
const done = await commit((doc, next) => { doc.notes.push({ revision: next, ...entry }); });
|
|
142
|
+
notes.push({ revision: done.revision, ...entry });
|
|
143
|
+
return done.revision;
|
|
144
|
+
}),
|
|
145
|
+
admit: (id, name, argsHash, state, session = null, intent, args) => inOrder(async () => {
|
|
146
|
+
effectKey(key, id);
|
|
147
|
+
const known = effects.get(id);
|
|
148
|
+
if (known)
|
|
149
|
+
return known;
|
|
150
|
+
const stored = json(state);
|
|
151
|
+
const kept = intent === undefined ? undefined : json(intent);
|
|
152
|
+
const keptArgs = args === undefined ? undefined : json(args);
|
|
153
|
+
await commit(async (doc, _next, tx) => {
|
|
154
|
+
Object.assign(await tx.doc(EffectDoc, effectKey(key, id), null), { driver: key, name, argsHash, status: "unknown", session, result: null, ...(kept ? { intent: kept } : {}), ...(keptArgs !== undefined ? { args: keptArgs } : {}) });
|
|
155
|
+
doc.effects.push(id);
|
|
156
|
+
doc.state = stored;
|
|
157
|
+
});
|
|
158
|
+
effects.set(id, { id, name, argsHash, status: "unknown", session, result: null, ...(kept ? { intent: kept } : {}), ...(keptArgs !== undefined ? { args: keptArgs } : {}) });
|
|
159
|
+
return "new";
|
|
160
|
+
}),
|
|
161
|
+
complete: (id, result, state = opened.state) => inOrder(async () => {
|
|
162
|
+
const known = effects.get(id);
|
|
163
|
+
if (!known)
|
|
164
|
+
throw new RecoveryError(`Effect ${id} completes without an admission`);
|
|
165
|
+
if (known.status === "completed")
|
|
166
|
+
throw new RecoveryError(`Effect ${id} completes once`);
|
|
167
|
+
const value = json(result);
|
|
168
|
+
const next = json(state);
|
|
169
|
+
await commit(async (doc, _next, tx) => {
|
|
170
|
+
const effect = await tx.doc(EffectDoc, effectKey(key, id), null);
|
|
171
|
+
effect.status = "completed";
|
|
172
|
+
effect.result = value;
|
|
173
|
+
doc.state = next;
|
|
174
|
+
});
|
|
175
|
+
effects.set(id, { ...known, status: "completed", result: value });
|
|
176
|
+
}),
|
|
177
|
+
called: (id, calls) => inOrder(async () => {
|
|
178
|
+
if (!effects.has(id))
|
|
179
|
+
throw new RecoveryError(`Effect ${id} records calls without an admission`);
|
|
180
|
+
const value = json(calls);
|
|
181
|
+
await commit(async (_doc, _next, tx) => { (await tx.doc(EffectDoc, effectKey(key, id), null)).calls = value; });
|
|
182
|
+
effects.set(id, { ...effects.get(id), ...(Array.isArray(value) ? { calls: value } : {}) });
|
|
183
|
+
}),
|
|
184
|
+
async close() {
|
|
185
|
+
if (closed)
|
|
186
|
+
return;
|
|
187
|
+
closed = true;
|
|
188
|
+
await queue;
|
|
189
|
+
held.release();
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Extension, Harness, TaskId, TaskRuntime } from "@earendil-works/pi-durable";
|
|
2
|
+
import { runWorkflow, type Workflow, type WorkflowDeps } from "@parcha/agentrun-dsl";
|
|
3
|
+
import { type EscalationRow, type RecoveryAdapters, type RecoveryDriver, type RecoveryOptions } from "@parcha/agentrun-dsl/recovery";
|
|
4
|
+
type Context = Parameters<Harness["commit"]>[1];
|
|
5
|
+
/** What one run is: its key (the same key reaches the same task and journal), the workflow as authored, its input, and
|
|
6
|
+
* what else must not change between two opens of it. */
|
|
7
|
+
export type WorkflowRequest = {
|
|
8
|
+
key: string;
|
|
9
|
+
workflow: Workflow;
|
|
10
|
+
input: Record<string, unknown>;
|
|
11
|
+
bind?: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
/** How a run ended, as the host reads it. A pause is not an outcome: a paused run's task stays pending. */
|
|
14
|
+
export type WorkflowOutcome = {
|
|
15
|
+
status: "complete";
|
|
16
|
+
output: unknown;
|
|
17
|
+
state: unknown;
|
|
18
|
+
} | {
|
|
19
|
+
status: "escalated";
|
|
20
|
+
escalation: unknown;
|
|
21
|
+
state: unknown;
|
|
22
|
+
continuation?: unknown;
|
|
23
|
+
} | {
|
|
24
|
+
status: "refused";
|
|
25
|
+
code: string;
|
|
26
|
+
message: string;
|
|
27
|
+
detail?: unknown;
|
|
28
|
+
} | {
|
|
29
|
+
status: "stopped";
|
|
30
|
+
stop: "cancelled";
|
|
31
|
+
source?: string;
|
|
32
|
+
};
|
|
33
|
+
/** One invocation of a run's phase, as the host's adapters see it: a node runner that opens conversations owns them by
|
|
34
|
+
* `runtime.taskId`, so a cancel aborts them before the task. */
|
|
35
|
+
export type WorkflowInvocation = {
|
|
36
|
+
key: string;
|
|
37
|
+
taskId: TaskId;
|
|
38
|
+
runtime: TaskRuntime<WorkflowRequest, TaskCheckpoint, WorkflowResult, object>;
|
|
39
|
+
context: Context;
|
|
40
|
+
};
|
|
41
|
+
/** The host's part, asked once per invocation. */
|
|
42
|
+
export type WorkflowTaskHost = {
|
|
43
|
+
/** The driver's host options, beside the run's key and binding: asked before the driver opens, by the run's phase and
|
|
44
|
+
* by its abort handler. A host settles an effect a killed process left unknown with `reconcile`: the driver calls it
|
|
45
|
+
* once, through its own open of the run, with the unknown effects and a `complete` that records one's result. An
|
|
46
|
+
* effect it completes is answered from that result and not dispatched again; one it leaves is refused at resume. Do not
|
|
47
|
+
* open the run's journal here: that open is a generation of its own. The task's abort handler opens the driver too, to
|
|
48
|
+
* record a cancel, so `reconcile` also runs then; `phase` is `"abort"` there and `"run"` for the run's own open, and a
|
|
49
|
+
* host that wants no reconcile during a cancel returns none for `"abort"`. */
|
|
50
|
+
options?(invocation: WorkflowInvocation & {
|
|
51
|
+
phase: "run" | "abort";
|
|
52
|
+
}): Omit<RecoveryOptions, "key" | "bind"> | Promise<Omit<RecoveryOptions, "key" | "bind">>;
|
|
53
|
+
/** What the interpreter runs with, asked once the driver is open, so a host that composes its own couplings over the
|
|
54
|
+
* driver (its spend, its sessions, its escalation rows) builds them here. */
|
|
55
|
+
run(invocation: WorkflowInvocation & {
|
|
56
|
+
driver: RecoveryDriver;
|
|
57
|
+
}): WorkflowHostParts | Promise<WorkflowHostParts>;
|
|
58
|
+
/** The host's ladder: a run that did not complete and was not refused continues here, inside the task's invocation,
|
|
59
|
+
* so what the host opens (its continuation) is owned by the task, and a cancel aborts it before the task. The host
|
|
60
|
+
* commits the escalation row through `invocation.driver.escalate` before anything acts on it; a run reopened after
|
|
61
|
+
* that commit comes here again with the row, and the interpreter does not run. `invocation.signal` ends with the
|
|
62
|
+
* run's stops: a pause leaves the task pending, and the next open comes here again with the row. What it returns
|
|
63
|
+
* rides on the escalated outcome as `continuation`; a throw is the run's failure. Absent, an escalated run ends with
|
|
64
|
+
* the interpreter's escalation and any failure is the run's. */
|
|
65
|
+
escalated?(ending: WorkflowEnding, invocation: WorkflowInvocation & {
|
|
66
|
+
driver: RecoveryDriver;
|
|
67
|
+
signal: AbortSignal;
|
|
68
|
+
}): Promise<unknown>;
|
|
69
|
+
};
|
|
70
|
+
/** Why a run continues on the host's ladder: a gate that fired (`escalation`), a failure that is not a refusal
|
|
71
|
+
* (`error`), a refusal found before any step (`refusal`), or an escalation an earlier invocation committed (`row`).
|
|
72
|
+
* `state` is the run's state at that point. */
|
|
73
|
+
export type WorkflowEnding = {
|
|
74
|
+
escalation?: unknown;
|
|
75
|
+
error?: unknown;
|
|
76
|
+
refusal?: NonNullable<RecoveryDriver["preStepRefusal"]>;
|
|
77
|
+
row?: EscalationRow;
|
|
78
|
+
state: Record<string, unknown>;
|
|
79
|
+
};
|
|
80
|
+
/** The host's adapters, which the task wraps with `withRecovery` (`durableNodes`: the node runner answers a delivered step
|
|
81
|
+
* again from its own record), or the dependencies the host built over `invocation.driver` itself, which the task runs as
|
|
82
|
+
* they are, stopped by the run's signal too, with the host's own `interpret` when it runs its documents under its own
|
|
83
|
+
* policy (its view of the document, its lint): it is called as `runWorkflow` would be, and answers as it does. */
|
|
84
|
+
export type WorkflowHostParts = {
|
|
85
|
+
adapters: RecoveryAdapters;
|
|
86
|
+
durableNodes?: boolean;
|
|
87
|
+
} | {
|
|
88
|
+
deps: WorkflowDeps;
|
|
89
|
+
interpret?: (workflow: Workflow, input: Record<string, unknown>, deps: WorkflowDeps) => ReturnType<typeof runWorkflow>;
|
|
90
|
+
};
|
|
91
|
+
/** A started run. `wait` resolves with the run's outcome once its task is terminal, and rejects with the storage's error
|
|
92
|
+
* when the Session failed at one. */
|
|
93
|
+
export type WorkflowRun = {
|
|
94
|
+
key: string;
|
|
95
|
+
taskId: TaskId<WorkflowResult>;
|
|
96
|
+
/** `created`: this start made the task; `attached`: it was live; `settled`: it was terminal and is returned as it is. */
|
|
97
|
+
started: "created" | "attached" | "settled";
|
|
98
|
+
wait(context: Context): Promise<WorkflowOutcome>;
|
|
99
|
+
};
|
|
100
|
+
/** What a stop reached. A pause reaches a run only where it is live, in this process: `paused` once the run's work has
|
|
101
|
+
* stopped with the pause in its journal, `ended` when the run ended otherwise, `not-live` when it is not running here. A cancel is `abortTask`'s
|
|
102
|
+
* answer: `marked` (the task ends aborted later) or `terminal` (it already was). */
|
|
103
|
+
export type WorkflowStop = "paused" | "ended" | "not-live" | "marked" | "terminal";
|
|
104
|
+
type TaskCheckpoint = {
|
|
105
|
+
phase: "run";
|
|
106
|
+
};
|
|
107
|
+
/** A completed task's result: the outcomes that end a run without a refusal. */
|
|
108
|
+
type WorkflowResult = Extract<WorkflowOutcome, {
|
|
109
|
+
status: "complete" | "escalated";
|
|
110
|
+
}>;
|
|
111
|
+
/** The task definition, the extension that carries it, and the host's three operations on a run. */
|
|
112
|
+
export declare function createWorkflowExtension(host: WorkflowTaskHost): {
|
|
113
|
+
extension: Extension;
|
|
114
|
+
start(harness: Harness, request: WorkflowRequest, context: Context, options?: {
|
|
115
|
+
conversationId?: number;
|
|
116
|
+
background?: boolean;
|
|
117
|
+
stop?: {
|
|
118
|
+
action: "pause" | "cancel";
|
|
119
|
+
source?: string;
|
|
120
|
+
};
|
|
121
|
+
}): Promise<WorkflowRun>;
|
|
122
|
+
read(harness: Harness, key: string, context: Context): Promise<{
|
|
123
|
+
taskId: TaskId<WorkflowResult>;
|
|
124
|
+
status: string;
|
|
125
|
+
outcome?: WorkflowOutcome;
|
|
126
|
+
} | undefined>;
|
|
127
|
+
stop(harness: Harness, key: string, action: "pause" | "cancel", context: Context, source?: string): Promise<WorkflowStop>;
|
|
128
|
+
};
|
|
129
|
+
export {};
|