pi-durable-subagents 1.0.23 → 1.0.25

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.
@@ -0,0 +1,117 @@
1
+ // `events --all [--since <cursor>] [--limit <n>] [--json] [--wait-ms <n>]` — read the cross-workflow event log.
2
+ // Output is JSON lines (with or without --json). Without --since: `{"head","more":false}`. With --since: the events
3
+ // after the cursor (at most --limit, default and max EVENTS_PAGE_MAX), then `{"head","more"}`: more:true → head is the
4
+ // cursor of the last event printed; more:false → the log head. Exit 0; 4 cursor-expired (other epoch, seq below
5
+ // `dropped`, or beyond the head); 1 malformed cursor or options (`{"error":"invalid-arguments","message"}`) or an
6
+ // unreadable log (`{"error":"log-unreadable","message"}`: corrupt, or gone between reads twice); 75 no log yet within
7
+ // --wait-ms (an orchestrator was started to create it). Read-only: never repairs the log, never starts the orchestrator when the log exists.
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { eventsLog } from "../paths.js";
10
+ import { EPOCH, LogCorrupt, readHead, readPage } from "./log.js";
11
+ import { EVENTS_PAGE_MAX, EXIT_CURSOR_EXPIRED } from "./types.js";
12
+ export const EVENTS_EXIT = { ok: 0, invalid: 1, expired: EXIT_CURSOR_EXPIRED, pending: 75 };
13
+ export const EVENTS_USAGE = "usage: events --all [--since <epoch>:<seq>] [--limit <n>] [--json] [--wait-ms <n>]";
14
+ /** A cursor `<epoch>:<seq>`, or undefined when malformed. */
15
+ export function parseCursor(text) {
16
+ const m = /^([0-9a-f]{16}):(0|[1-9]\d{0,15})$/.exec(text);
17
+ return m && EPOCH.test(m[1]) && Number.isSafeInteger(Number(m[2])) ? { epoch: m[1], seq: Number(m[2]) } : undefined;
18
+ }
19
+ /** Exit 1 with one JSON line `{"error":"invalid-arguments","message"}` (the output stays JSON lines). */
20
+ function invalid(ctx, message) { ctx.write(JSON.stringify({ error: "invalid-arguments", message })); return EVENTS_EXIT.invalid; }
21
+ /** Exit 1 with one JSON line `{"error":"log-unreadable","message"}`. */
22
+ function unreadable(ctx, message) { ctx.write(JSON.stringify({ error: "log-unreadable", message })); return EVENTS_EXIT.invalid; }
23
+ class Vanished extends Error {
24
+ constructor() { super("the event log disappeared while it was read"); }
25
+ }
26
+ class Expired extends Error {
27
+ head;
28
+ constructor(head) { super("cursor-expired"); this.head = head; }
29
+ }
30
+ export async function eventsAll(args, ctx) {
31
+ try {
32
+ return await read(args, ctx);
33
+ }
34
+ catch (error) {
35
+ if (error instanceof Vanished)
36
+ try {
37
+ return await read(args, ctx);
38
+ }
39
+ catch (again) {
40
+ error = again;
41
+ } // retried once
42
+ if (error instanceof LogCorrupt || error instanceof Vanished)
43
+ return unreadable(ctx, error.message);
44
+ throw error;
45
+ }
46
+ }
47
+ async function read(args, ctx) {
48
+ const values = {};
49
+ for (let i = 0; i < args.length; i++) {
50
+ const arg = args[i], name = arg.startsWith("--") ? arg.slice(2) : undefined;
51
+ const kind = name === "all" || name === "json" ? "flag" : name === "since" || name === "limit" || name === "wait-ms" ? "value" : undefined;
52
+ if (!name || !kind || Object.hasOwn(values, name)) {
53
+ return invalid(ctx, `unknown, repeated or misplaced argument ${JSON.stringify(arg)}; ${EVENTS_USAGE}`);
54
+ }
55
+ if (kind === "flag") {
56
+ values[name] = true;
57
+ continue;
58
+ }
59
+ const value = args[++i];
60
+ if (value === undefined) {
61
+ return invalid(ctx, `--${name} needs a value; ${EVENTS_USAGE}`);
62
+ }
63
+ values[name] = value;
64
+ }
65
+ const number = (name, min, max) => {
66
+ const raw = values[name];
67
+ if (raw === undefined)
68
+ return undefined;
69
+ return typeof raw === "string" && /^\d+$/.test(raw) && Number(raw) >= min && Number(raw) <= max ? Number(raw) : null;
70
+ };
71
+ const limit = number("limit", 1, EVENTS_PAGE_MAX), wait = number("wait-ms", 0, 86_400_000);
72
+ if (limit === null)
73
+ return invalid(ctx, `--limit needs an integer 1-${EVENTS_PAGE_MAX}`);
74
+ if (wait === null)
75
+ return invalid(ctx, "--wait-ms needs a non-negative integer");
76
+ const since = typeof values.since === "string" ? parseCursor(values.since) : undefined;
77
+ if (values.since !== undefined && !since)
78
+ return invalid(ctx, `malformed cursor ${JSON.stringify(values.since)}; a cursor is <epoch>:<seq> as printed in "head" or "cursor"`);
79
+ const path = eventsLog(ctx.home);
80
+ // No log yet: the orchestrator creates it at start (deriving everything still on disk); start one and wait.
81
+ if (!readHead(path)) {
82
+ await ctx.starter(ctx.home, ctx.env);
83
+ const deadline = performance.now() + (wait ?? ctx.waitMs ?? 60_000);
84
+ while (!readHead(path) && performance.now() < deadline)
85
+ await delay(Math.min(100, Math.max(0, deadline - performance.now())));
86
+ if (!readHead(path)) {
87
+ ctx.write(JSON.stringify({ pending: true }));
88
+ return EVENTS_EXIT.pending;
89
+ }
90
+ }
91
+ const cursor = (h, seq) => `${h.epoch}:${seq}`;
92
+ if (!since) {
93
+ const h = readHead(path);
94
+ if (!h)
95
+ throw new Vanished();
96
+ ctx.write(JSON.stringify({ head: cursor(h, h.head), more: false }));
97
+ return EVENTS_EXIT.ok;
98
+ }
99
+ try {
100
+ const page = readPage(path, since.seq, limit ?? EVENTS_PAGE_MAX, h => {
101
+ if (h.epoch !== since.epoch || since.seq < h.dropped || since.seq > h.head)
102
+ throw new Expired(h);
103
+ });
104
+ if (!page)
105
+ throw new Vanished();
106
+ for (const e of page.events)
107
+ ctx.write(JSON.stringify(e));
108
+ ctx.write(JSON.stringify({ head: page.more ? page.events.at(-1).cursor : cursor(page, page.head), more: page.more }));
109
+ return EVENTS_EXIT.ok;
110
+ }
111
+ catch (error) {
112
+ if (!(error instanceof Expired))
113
+ throw error;
114
+ ctx.write(JSON.stringify({ error: "cursor-expired", head: cursor(error.head, error.head.head), oldest: cursor(error.head, error.head.dropped) }));
115
+ return EVENTS_EXIT.expired;
116
+ }
117
+ }
@@ -0,0 +1,128 @@
1
+ // Journal → event drafts. Pure functions of durable sources: the entry at journal index i (seq i+1) is derived from
2
+ // that journal's entries up to it and from orchestrator-ledger entries durable before it, so a re-derivation after a
3
+ // crash gives the same events with the same ids (`<wid>:<journal seq>:<type>`, `<wid>:submitted`).
4
+ import { createHash } from "node:crypto";
5
+ import { requestId } from "../requests.js";
6
+ import { JT, isEntry } from "../types.js";
7
+ import { fenceReason, interruptingFence } from "./fence.js";
8
+ import { EVENT_DATA_INLINE_MAX } from "./types.js";
9
+ /** `<wid>@<rev>/<key>@<gen>` → key and gen (a key may itself contain '/' or '@'). */
10
+ export function callParts(call) {
11
+ const m = /^[^@/]+@\d+\/(.*)@(\d+)$/s.exec(call);
12
+ return m ? { key: m[1], gen: Number(m[2]) } : undefined;
13
+ }
14
+ const echo = (id) => ({ ...(id.request !== undefined ? { request: id.request } : {}), ...(id.labels ? { labels: id.labels } : {}) });
15
+ const onCall = (call) => { const p = callParts(call); return p ? { key: p.key, gen: p.gen, call } : { call }; };
16
+ /** The labels of a run body: a non-empty plain object of strings (anything else, and `{}`, is not echoed — as describe
17
+ * and the wait collector treat them). */
18
+ export function labelsOf(body) {
19
+ const labels = body?.labels;
20
+ if (!labels || typeof labels !== "object" || Array.isArray(labels))
21
+ return undefined;
22
+ const entries = Object.entries(labels);
23
+ return entries.length && entries.every(([, v]) => typeof v === "string") ? Object.fromEntries(entries) : undefined;
24
+ }
25
+ /** `answered.by` from the answer request's sender: a pi session `main:<id>` → `session:<id>` (+ via "ui" when the
26
+ * subagent list sent it, SendBody.by "user"); the CLI sender `cli:<user>@<host>` as is; anything else `unknown`. */
27
+ export function answeredBy(req) {
28
+ const from = req?.from ?? "";
29
+ if (from.startsWith("main:"))
30
+ return { by: `session:${from.slice(5)}`, ...(req.body?.by === "user" ? { via: "ui" } : {}) };
31
+ if (/^cli:[^@]+@.+$/.test(from))
32
+ return { by: from };
33
+ return { by: "unknown" };
34
+ }
35
+ /** `submitted` from the orchestrator ledger's `created {rid, wid}` at index `i` (name from its create-intent). */
36
+ export function deriveCreated(orch, i, id) {
37
+ const e = orch[i], wid = String(e.wid), rid = String(e.rid);
38
+ let name;
39
+ for (let j = i - 1; j >= 0; j--) {
40
+ const c = orch[j];
41
+ if (c.type === "create-intent" && c.rid === rid) {
42
+ name = c.name;
43
+ break;
44
+ }
45
+ }
46
+ return { id: `${wid}:submitted`, ts: e.ts, type: "submitted", wid, ...echo({ ...id, request: requestId(rid) ?? id.request }), ...(typeof name === "string" ? { name } : {}) };
47
+ }
48
+ /** The request the answer to question (call, qid, rev) came with: an `answer-bound` (a hibernated asker) or a `forward`
49
+ * of kind answer (a live one) before index `limit` names its rid; the orchestrator ledger keeps the admitted envelope. */
50
+ function answerOf(entries, limit, orch, call, qid, rev) {
51
+ let rid, forwarded;
52
+ for (let j = limit - 1; j >= 0 && rid === undefined; j--) {
53
+ const e = entries[j];
54
+ if (e.type === "answer-bound" && e.call === call && e.qid === qid && e.rev === rev)
55
+ rid = String(e.rid);
56
+ else if (e.type === "forward" && e.dest === call) {
57
+ const env = e.envelope;
58
+ if (env?.kind === "answer" && env.cond?.qid === qid && env.cond?.rev === rev) {
59
+ rid = String(e.rid);
60
+ forwarded = typeof env.body?.message === "string" ? env.body.message : undefined;
61
+ }
62
+ }
63
+ }
64
+ const request = rid === undefined ? undefined : orch.findLast(e => e.type === "request" && e.request?.rid === rid)?.request;
65
+ const message = request?.body?.message;
66
+ return { request, text: typeof message === "string" ? message : forwarded ?? "" };
67
+ }
68
+ /** The ledger entries durable before journal entry `e` (by time: the ledger and the journal are separate files). A wall
69
+ * clock step back can change which ledger entries a re-derivation sees, and so `by`/`reason` of its event (ids stay). */
70
+ function before(orch, e) {
71
+ let n = orch.length;
72
+ while (n > 0 && Number(orch[n - 1].ts) > e.ts)
73
+ n--;
74
+ return n === orch.length ? orch : orch.slice(0, n);
75
+ }
76
+ /** The events of journal entry `i` of workflow `wid` (usually none). Reads only that journal's entries up to `i` and
77
+ * the ledger entries durable before it, so a re-derivation gives the same events. */
78
+ export function deriveEntry(wid, entries, i, orch, id) {
79
+ const e = entries[i], base = (type) => ({ id: `${wid}:${e.seq}:${type}`, ts: e.ts, wid, ...echo(id) });
80
+ if (isEntry(e, JT.exec)) {
81
+ // `started` once per call (generation): its first execution. A later execution follows a fence; `fenced` when
82
+ // that fence interrupted work (the classification `describe` uses for lastFence).
83
+ let previous;
84
+ for (let j = i - 1; j >= 0 && !previous; j--) {
85
+ const p = entries[j];
86
+ if (p.type === JT.exec && p.call === e.call)
87
+ previous = p;
88
+ }
89
+ if (!previous)
90
+ return [{ ...base("started"), type: "started", ...onCall(e.call), exec: e.exec }];
91
+ const fence = interruptingFence(entries, String(previous.exec), i);
92
+ if (!fence)
93
+ return [];
94
+ return [{ ...base("fenced"), type: "fenced", ...onCall(e.call), exec: String(previous.exec), reason: fenceReason(entries.slice(0, i), before(orch, e), fence), at: fence.ts }];
95
+ }
96
+ if (isEntry(e, JT.attention)) {
97
+ const item = e.item;
98
+ if (item?.kind !== "question" || !item.call || item.qid === undefined)
99
+ return [];
100
+ const parts = callParts(item.call);
101
+ return [{ ...base("asking"), type: "asking", ...onCall(item.call), qid: item.qid, rev: item.rev, question: item.text, to: `${wid}/${parts?.key ?? ""}` }];
102
+ }
103
+ if (isEntry(e, JT.attentionResolved)) {
104
+ if (e.resolution !== "answered")
105
+ return [];
106
+ let item;
107
+ for (let j = i - 1; j >= 0 && !item; j--) {
108
+ const a = entries[j];
109
+ if (isEntry(a, JT.attention) && a.item.id === e.id && a.item.rev === e.rev)
110
+ item = a;
111
+ }
112
+ const q = item?.item;
113
+ if (!q || q.kind !== "question" || !q.call || q.qid === undefined)
114
+ return [];
115
+ const answer = answerOf(entries, i, before(orch, e), q.call, q.qid, q.rev);
116
+ return [{ ...base("answered"), type: "answered", ...onCall(q.call), qid: q.qid, rev: q.rev, ...answeredBy(answer.request),
117
+ digest: createHash("sha256").update(answer.text, "utf8").digest("hex"), length: answer.text.length }];
118
+ }
119
+ if (isEntry(e, JT.sealed)) {
120
+ const r = (e.result ?? {}), data = Object.hasOwn(r, "data") && r.data !== undefined ? JSON.stringify(r.data) : undefined;
121
+ const bytes = data === undefined ? 0 : Buffer.byteLength(data);
122
+ return [{ ...base("sealed"), type: "sealed", ...onCall(e.call), status: String(r.status ?? "unknown"), ...(r.error ? { error: String(r.error) } : {}),
123
+ ...(data === undefined ? {} : bytes <= EVENT_DATA_INLINE_MAX ? { data: r.data } : { data_omitted: bytes }) }];
124
+ }
125
+ if (e.type === JT.done)
126
+ return [{ ...base("workflow-done"), type: "workflow-done", status: String(e.status), ...(e.error !== undefined ? { error: String(e.error) } : {}) }];
127
+ return [];
128
+ }
@@ -0,0 +1,61 @@
1
+ // Which fences interrupted work, shared by `describe` (lastFence) and the event deriver (`fenced`). Read-only over
2
+ // committed journal entries; `limit` restricts the view to the first `limit` entries (the deriver may read only entries
3
+ // before the one it derives, so a re-derivation after a crash sees the same history).
4
+ import { JT } from "../types.js";
5
+ /** Executions whose fence did NOT interrupt work: every execution ends with a fence; one interrupted work only when
6
+ * the execution neither settled (its turn ended) before it nor hibernated (it waits for an answer), and was not sealed
7
+ * on purpose: a seal ends an execution on purpose unless its outcome is `unknown` (a `once` call cut off in a tool) or
8
+ * the execution was recorded as lost (the loss bound sealed it), which are interruptions themselves. A seal for an
9
+ * execution that never ran (a launch failure) or that the call's stop, timeout or budget ended is on purpose. */
10
+ export function endedExecs(journal, limit = journal.length) {
11
+ const lost = new Set(), fencedAt = new Map(), ended = new Set();
12
+ for (let i = 0; i < limit; i++) {
13
+ const e = journal[i];
14
+ if (e.type === "loss")
15
+ lost.add(String(e.exec));
16
+ else if (e.type === JT.fenced)
17
+ fencedAt.set(String(e.exec), e.seq);
18
+ }
19
+ for (let i = 0; i < limit; i++) {
20
+ const e = journal[i], exec = String(e.exec);
21
+ // Recovery records `hibernated` after the fence for an execution cut off while only its question's ask ran (P28):
22
+ // it was waiting, not working, so that is no interruption either.
23
+ if (e.type === "hibernated")
24
+ ended.add(exec);
25
+ else if (e.type === "settled") {
26
+ if (e.seq < (fencedAt.get(exec) ?? Infinity))
27
+ ended.add(exec);
28
+ }
29
+ else if (e.type === JT.sealed && e.result?.status !== "unknown" && !lost.has(exec))
30
+ ended.add(exec);
31
+ }
32
+ return ended;
33
+ }
34
+ /** Best effort: why `fence` (a `fenced` entry that interrupted work) happened. restart-force: a forced restart listed
35
+ * the execution as live; orchestrator-crash: the execution was launched before an orchestrator start that is not
36
+ * preceded by a clean exit and fenced after it (startup recovery); otherwise process-died (the child or its host went
37
+ * away, or a drain fenced it). */
38
+ export function fenceReason(journal, orch, fence) {
39
+ const exec = String(fence.exec), at = Number(fence.ts);
40
+ if (orch.some(e => e.type === "restart" && e.force === true && Array.isArray(e.live) && e.live.includes(exec)))
41
+ return "restart-force";
42
+ const launched = journal.find(e => e.type === JT.exec && e.exec === exec), starts = orch.filter(e => e.type === "orchestrator");
43
+ const recovery = starts.findLast(s => Number(s.ts) <= at);
44
+ if (launched && recovery && Number(launched.ts) < Number(recovery.ts)) {
45
+ const prior = orch.filter(e => Number(e.seq) < Number(recovery.seq));
46
+ const lastStart = prior.findLast(e => e.type === "orchestrator"), cleanExit = lastStart && prior.some(e => e.type === "orchestrator-exit" && Number(e.seq) > Number(lastStart.seq));
47
+ if (!cleanExit)
48
+ return "orchestrator-crash";
49
+ }
50
+ return "process-died";
51
+ }
52
+ /** The fence of `exec` among the first `limit` entries when it interrupted work, else undefined. */
53
+ export function interruptingFence(journal, exec, limit = journal.length) {
54
+ let fence;
55
+ for (let i = limit - 1; i >= 0 && !fence; i--) {
56
+ const e = journal[i];
57
+ if (e.type === JT.fenced && e.exec === exec)
58
+ fence = e;
59
+ }
60
+ return fence && !endedExecs(journal, limit).has(exec) ? fence : undefined;
61
+ }
@@ -0,0 +1,53 @@
1
+ // Caller labels of a run — `run --labels <json>`, the tool's `labels`, and the
2
+ // orchestrator's admission all validate with this one function. Labels are part of RunBody, so they are part of the
3
+ // request's spec_digest (the same request id with other labels is a request-conflict) and are echoed by `describe` and
4
+ // on every event of the workflow.
5
+ /** At most this many keys. */
6
+ export const LABELS_MAX_KEYS = 32;
7
+ /** A key: 1-64 characters of [A-Za-z0-9_.:-]. */
8
+ export const LABEL_KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
9
+ /** A value: a string of at most this many UTF-16 units. */
10
+ export const LABEL_VALUE_MAX = 256;
11
+ /** The labels' JSON is at most this many UTF-8 bytes. */
12
+ export const LABELS_JSON_MAX = 4096;
13
+ /** What is wrong with `value` as run labels (naming the offending key), or undefined when they are valid. */
14
+ export function labelsProblem(value) {
15
+ if (!value || typeof value !== "object" || Array.isArray(value))
16
+ return "labels must be a JSON object of string values";
17
+ const proto = Object.getPrototypeOf(value);
18
+ if (proto !== Object.prototype && proto !== null)
19
+ return "labels must be a plain JSON object of string values";
20
+ const entries = Object.entries(value);
21
+ if (entries.length > LABELS_MAX_KEYS)
22
+ return `labels have ${entries.length} keys; at most ${LABELS_MAX_KEYS}`;
23
+ for (const [key, v] of entries) {
24
+ if (!LABEL_KEY.test(key))
25
+ return `label key ${JSON.stringify(key)} must be 1-64 characters [A-Za-z0-9_.:-]`;
26
+ if (typeof v !== "string")
27
+ return `label ${JSON.stringify(key)} must have a string value`;
28
+ if (v.length > LABEL_VALUE_MAX)
29
+ return `label ${JSON.stringify(key)} is ${v.length} characters long; at most ${LABEL_VALUE_MAX}`;
30
+ }
31
+ const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
32
+ if (bytes > LABELS_JSON_MAX)
33
+ return `labels are ${bytes} bytes of JSON; at most ${LABELS_JSON_MAX}`;
34
+ return undefined;
35
+ }
36
+ /** Valid labels as given (throws the problem otherwise). */
37
+ export function checkLabels(value) {
38
+ const problem = labelsProblem(value);
39
+ if (problem)
40
+ throw new Error(problem);
41
+ return value;
42
+ }
43
+ /** `--labels <json>`: parse and validate. */
44
+ export function parseLabels(json) {
45
+ let value;
46
+ try {
47
+ value = JSON.parse(json);
48
+ }
49
+ catch (error) {
50
+ throw new Error(`--labels is not JSON: ${error.message}`);
51
+ }
52
+ return checkLabels(value);
53
+ }