pi-legion 3.0.3 → 4.0.0
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/CHANGELOG.md +263 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +29 -22
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/spawn-mode-visibility-v1.md +206 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +633 -84
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +43 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +6 -6
- package/src/extension/recycle.ts +6 -2
- package/src/extension/runtime.ts +422 -18
- package/src/extension/slash.ts +0 -21
- package/src/extension/spawn-render.ts +183 -0
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/live-overlay.ts +14 -0
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +103 -21
- package/src/tui/tree-view.ts +36 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/railway.ts +28 -25
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI renderer for the `subagent` / `vexillatio` tool results.
|
|
3
|
+
*
|
|
4
|
+
* Pi never renders a tool result's `details` on its own: `renderResult` is the
|
|
5
|
+
* only consumer, and a tool registered without one falls back to the plain
|
|
6
|
+
* `content` text. So the arbitrated `spawnMode` (#209) stays invisible until a
|
|
7
|
+
* renderer names it — that is all this file does.
|
|
8
|
+
*
|
|
9
|
+
* The summary line is derived from `details`, never from `content`: `content`
|
|
10
|
+
* is model-facing, `details` is TUI-facing (`docs/design.md:258`). Keeping the
|
|
11
|
+
* line out of `content` keeps it out of the LLM context.
|
|
12
|
+
*
|
|
13
|
+
* Renderers are called on every repaint, so nothing here may throw: the host
|
|
14
|
+
* swallows a throwing renderer and falls back to the default block, silently
|
|
15
|
+
* erasing the very fact this file exists to show. Every `details` read is
|
|
16
|
+
* therefore optional-chained and shape-checked, AND both entry points are
|
|
17
|
+
* wrapped in `try/catch`: a shape check is no defence against a getter or a
|
|
18
|
+
* Proxy trap that throws (host: `tool-execution.js` catch → fallback card).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
22
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
23
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
25
|
+
|
|
26
|
+
/** How many `content` lines a collapsed card keeps before the expand hint. */
|
|
27
|
+
const COLLAPSED_LINES = 10;
|
|
28
|
+
|
|
29
|
+
const SPAWN_MODES = new Set(["rpc", "inprocess"]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Binary control bytes that must never reach the terminal renderer.
|
|
33
|
+
*
|
|
34
|
+
* Same set as the host fallback (`shell.js:sanitizeBinaryOutput`, used by
|
|
35
|
+
* `render-utils.js:getTextOutput`): TAB, LF and CR are excluded on purpose —
|
|
36
|
+
* the carriage return is a line break here, see {@link resultLines}.
|
|
37
|
+
*/
|
|
38
|
+
const BINARY_CONTROL = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g;
|
|
39
|
+
|
|
40
|
+
export interface SpawnRenderOptions {
|
|
41
|
+
expanded: boolean;
|
|
42
|
+
isPartial: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isSpawnMode(value: unknown): value is "rpc" | "inprocess" {
|
|
46
|
+
return typeof value === "string" && SPAWN_MODES.has(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
50
|
+
return typeof value === "object" && value !== null
|
|
51
|
+
? (value as Record<string, unknown>)
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One `name=mode` entry from a `spawns[]` array (launch-family details). */
|
|
56
|
+
function entryFromSpawn(value: unknown): string | undefined {
|
|
57
|
+
const spawn = asRecord(value);
|
|
58
|
+
if (!spawn) return undefined;
|
|
59
|
+
const name = spawn.name;
|
|
60
|
+
const mode = spawn.spawnMode;
|
|
61
|
+
if (typeof name !== "string" || !name) return undefined;
|
|
62
|
+
// An unknown/absent mode is skipped, never printed as a guess: a legacy
|
|
63
|
+
// handle must render as "no fact", not as `rpc`.
|
|
64
|
+
if (!isSpawnMode(mode)) return undefined;
|
|
65
|
+
return `${name}=${mode}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One `name=mode` entry from a `handles[]` object (vexillatio details). */
|
|
69
|
+
function entryFromHandle(value: unknown): string | undefined {
|
|
70
|
+
const handle = asRecord(value);
|
|
71
|
+
if (!handle) return undefined;
|
|
72
|
+
return entryFromSpawn(handle);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `spawn: scout-0=inprocess` or `spawn: worker-0=rpc, scout-1=inprocess`.
|
|
77
|
+
*
|
|
78
|
+
* Reads both documented detail shapes defensively:
|
|
79
|
+
* - launch family: `details.spawns[]` (`{ name, agent, spawnMode }`)
|
|
80
|
+
* - vexillatio: `details.handles[]` (`{ name, agent, sessionFile, spawnMode? }`)
|
|
81
|
+
*
|
|
82
|
+
* One entry per child, first occurrence wins: a result that carries the same
|
|
83
|
+
* handle in both arrays must not print `w=rpc, w=rpc`.
|
|
84
|
+
*
|
|
85
|
+
* Returns `undefined` when nothing survives — no details, wrong shape, or every
|
|
86
|
+
* entry's mode unknown. Unknown modes are omitted rather than defaulted.
|
|
87
|
+
*
|
|
88
|
+
* Never throws: a `details` object with a throwing getter (or a Proxy) would
|
|
89
|
+
* otherwise take the whole card down, because the host swallows renderer
|
|
90
|
+
* exceptions and paints its fallback instead.
|
|
91
|
+
*/
|
|
92
|
+
export function spawnModeSummary(details: unknown): string | undefined {
|
|
93
|
+
try {
|
|
94
|
+
const record = asRecord(details);
|
|
95
|
+
if (!record) return undefined;
|
|
96
|
+
const entries: string[] = [];
|
|
97
|
+
const seen = new Set<string>();
|
|
98
|
+
const push = (entry: string | undefined): void => {
|
|
99
|
+
if (!entry || seen.has(entry)) return;
|
|
100
|
+
seen.add(entry);
|
|
101
|
+
entries.push(entry);
|
|
102
|
+
};
|
|
103
|
+
const spawns = record.spawns;
|
|
104
|
+
if (Array.isArray(spawns)) {
|
|
105
|
+
for (const spawn of spawns) push(entryFromSpawn(spawn));
|
|
106
|
+
}
|
|
107
|
+
// `handles` may be a `string[]` (launch-family) or an object array
|
|
108
|
+
// (vexillatio); string entries simply never match, so both shapes pass.
|
|
109
|
+
const handles = record.handles;
|
|
110
|
+
if (Array.isArray(handles)) {
|
|
111
|
+
for (const handle of handles) push(entryFromHandle(handle));
|
|
112
|
+
}
|
|
113
|
+
if (entries.length === 0) return undefined;
|
|
114
|
+
return `spawn: ${entries.join(", ")}`;
|
|
115
|
+
} catch {
|
|
116
|
+
// A hostile/throwing getter is not a rendering failure: no fact, no line.
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The model-facing text of a result, normalized the way it will be painted.
|
|
123
|
+
*
|
|
124
|
+
* Line counting has to happen on the text that actually renders, so
|
|
125
|
+
* normalizing runs BEFORE the split: a carriage return is how a progress
|
|
126
|
+
* stream redraws one line, not 20 rows — but `Text` treats `\r` as a line
|
|
127
|
+
* break, so leaving it in lets 20 segments paint 20 rows and bypass
|
|
128
|
+
* {@link COLLAPSED_LINES} with no hint. `\r` therefore becomes `\n` (so all
|
|
129
|
+
* 20 segments stay readable instead of being concatenated into one blob) and
|
|
130
|
+
* binary control bytes are dropped, matching the host fallback's intent
|
|
131
|
+
* (`render-utils.js:40` sanitizes binary output and removes raw `\r`).
|
|
132
|
+
*/
|
|
133
|
+
function resultLines(result: AgentToolResult<unknown> | undefined): string[] {
|
|
134
|
+
const content = result?.content;
|
|
135
|
+
if (!Array.isArray(content)) return [];
|
|
136
|
+
const text = content
|
|
137
|
+
.flatMap((part) => {
|
|
138
|
+
if (asRecord(part)?.type !== "text") return [];
|
|
139
|
+
const value = asRecord(part)?.text;
|
|
140
|
+
return typeof value === "string" ? [value] : [];
|
|
141
|
+
})
|
|
142
|
+
.join("\n");
|
|
143
|
+
const normalized = stripTerminalSequences(text)
|
|
144
|
+
.replace(/\r\n/g, "\n")
|
|
145
|
+
.replace(/\r/g, "\n")
|
|
146
|
+
.replace(BINARY_CONTROL, "");
|
|
147
|
+
return normalized.split("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the card: the spawn summary line (when known) followed by the result
|
|
152
|
+
* text, capped at {@link COLLAPSED_LINES} lines until `ctrl+o` expands it.
|
|
153
|
+
*
|
|
154
|
+
* Never throws by design; see the file comment.
|
|
155
|
+
*/
|
|
156
|
+
export function renderSpawnResult(
|
|
157
|
+
result: AgentToolResult<unknown>,
|
|
158
|
+
opts: SpawnRenderOptions,
|
|
159
|
+
_theme: Theme,
|
|
160
|
+
) {
|
|
161
|
+
const lines: string[] = [];
|
|
162
|
+
// Double insurance: `spawnModeSummary` is already total, but this call site
|
|
163
|
+
// must not depend on the callee's implementation staying that way (the host
|
|
164
|
+
// silences a throw and drops the card). The `details` READ is inside the
|
|
165
|
+
// guard too — a Proxy can throw on the property access itself.
|
|
166
|
+
let summary: string | undefined;
|
|
167
|
+
try {
|
|
168
|
+
summary = spawnModeSummary(result?.details);
|
|
169
|
+
} catch {
|
|
170
|
+
summary = undefined;
|
|
171
|
+
}
|
|
172
|
+
if (summary) lines.push(summary);
|
|
173
|
+
lines.push(...resultLines(result));
|
|
174
|
+
// `opts` is host-supplied; optional-chained so a partial/absent options
|
|
175
|
+
// object degrades to the collapsed view instead of throwing.
|
|
176
|
+
if (opts?.expanded || lines.length <= COLLAPSED_LINES) {
|
|
177
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
178
|
+
}
|
|
179
|
+
const hidden = lines.length - COLLAPSED_LINES;
|
|
180
|
+
const visible = lines.slice(0, COLLAPSED_LINES);
|
|
181
|
+
visible.push(`... (${hidden} more lines, ctrl+o to expand)`);
|
|
182
|
+
return new Text(visible.join("\n"), 0, 0);
|
|
183
|
+
}
|
package/src/extension/summary.ts
CHANGED
|
@@ -617,7 +617,7 @@ export function formatSubagentDetail(input: SummaryDetailInput): string {
|
|
|
617
617
|
if (child.thinking !== undefined) stateBits.push(`thinking: ${child.thinking}`);
|
|
618
618
|
const lines = [title, ` ${stateBits.join(" · ")}`];
|
|
619
619
|
|
|
620
|
-
const runBits = [`run: ${run.runId}`, `supervisor: ${run.herdr.supervisor ?? "
|
|
620
|
+
const runBits = [`run: ${run.runId}`, `supervisor: ${run.herdr.supervisor ?? "unknown"}`];
|
|
621
621
|
lines.push(` ${runBits.join(" · ")}`);
|
|
622
622
|
|
|
623
623
|
const startedAtMs = parseTimestamp(child.spawnedAt);
|
package/src/legion/db.ts
CHANGED
|
@@ -5,6 +5,24 @@ import { createRequire } from "node:module";
|
|
|
5
5
|
export const LEGION_DB_USER_VERSION = 1;
|
|
6
6
|
export const LEGION_DB_BUSY_TIMEOUT_MS = 5_000;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Bound on one connection's compiled-statement cache.
|
|
10
|
+
*
|
|
11
|
+
* The ledger has ~29 distinct static SQL texts behind the 46 `prepare()` call
|
|
12
|
+
* sites (many sites share a text), plus a handful of dynamically-shaped
|
|
13
|
+
* families — `usage.ts`'s chunked `IN (?,?,…)` (stable chunk length at runtime)
|
|
14
|
+
* and `hygiene.ts`'s retention `NOT IN (…)` lists. 64 holds everything a
|
|
15
|
+
* connection reuses with 2x headroom, while an unbounded map keyed on SQL text
|
|
16
|
+
* would grow with every distinct chunk size ever requested.
|
|
17
|
+
*/
|
|
18
|
+
export const LEGION_STMT_CACHE_LIMIT = 64;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `exec()` texts that can change the schema and therefore invalidate compiled
|
|
22
|
+
* statements. Deliberately matched anywhere in the text (see `attachStatementCache`).
|
|
23
|
+
*/
|
|
24
|
+
const SCHEMA_MUTATION_PATTERN = /\b(?:ALTER|CREATE|DROP|REINDEX|VACUUM)\b/i;
|
|
25
|
+
|
|
8
26
|
export type SqliteBindValue = string | number | bigint | null | Uint8Array;
|
|
9
27
|
export type SqliteRow = Record<string, string | number | bigint | null | Uint8Array>;
|
|
10
28
|
|
|
@@ -41,6 +59,7 @@ const SQLITE_DRIVERS: readonly SqliteDriver[] = [
|
|
|
41
59
|
];
|
|
42
60
|
let cachedSqliteConstructor: SqliteDatabaseConstructor | null | undefined;
|
|
43
61
|
const transactionDepth = new WeakMap<DatabaseSync, number>();
|
|
62
|
+
const statementCaches = new WeakMap<DatabaseSync, Map<string, LegionStatementSync>>();
|
|
44
63
|
let savepointSequence = 0;
|
|
45
64
|
|
|
46
65
|
const SCHEMA_V1 = `
|
|
@@ -99,6 +118,10 @@ CREATE TABLE messages (
|
|
|
99
118
|
created_at INTEGER NOT NULL
|
|
100
119
|
);
|
|
101
120
|
CREATE INDEX idx_messages_inbox ON messages(to_node, delivered, id);
|
|
121
|
+
-- Serves the per-sender rate-limit COUNT that sendMail runs inside its write
|
|
122
|
+
-- transaction (mail.ts): without it that COUNT is a full SCAN of messages, which
|
|
123
|
+
-- degrades monotonically because messages has no retention policy.
|
|
124
|
+
CREATE INDEX idx_messages_rate ON messages(from_node, created_at);
|
|
102
125
|
`;
|
|
103
126
|
|
|
104
127
|
/**
|
|
@@ -109,6 +132,54 @@ export function sqliteHasRow(row: SqliteRow | null | undefined): boolean {
|
|
|
109
132
|
return row != null;
|
|
110
133
|
}
|
|
111
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The journal mode the ledger must actually run in (contract §4.1).
|
|
137
|
+
*/
|
|
138
|
+
export const LEGION_DB_REQUIRED_JOURNAL_MODE = "wal";
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Refuse a connection whose journal mode is not the one the ledger assumes.
|
|
142
|
+
*
|
|
143
|
+
* `PRAGMA journal_mode = WAL` is a *query*: it returns the mode now in effect,
|
|
144
|
+
* and that mode need not be `wal` — SQLite is entitled to answer with the mode
|
|
145
|
+
* it settled on rather than to fail, which is how a connection ends up on a
|
|
146
|
+
* rollback journal while the caller believes WAL is live. (Mounts without
|
|
147
|
+
* working shared memory and read-only media are the usual causes; probing
|
|
148
|
+
* `node:sqlite` here, those conditions happened to raise instead, and a
|
|
149
|
+
* conflicting lock raised too — so the silent path is not something this
|
|
150
|
+
* repository can reproduce on demand, which is exactly why the check is a
|
|
151
|
+
* precondition on the returned value rather than a test fixture.)
|
|
152
|
+
*
|
|
153
|
+
* Every durability argument the ledger makes (single-writer serialisation,
|
|
154
|
+
* readers that never block the writer, the WAL checkpoint that
|
|
155
|
+
* `runStartupHygiene` performs on each ledger it sweeps at startup, and the one
|
|
156
|
+
* `stopLegionRuntime` folds in before close) assumes WAL is actually live, so
|
|
157
|
+
* the reported value is treated as a precondition rather than a best effort.
|
|
158
|
+
* Failing at `openLegionDb` surfaces it at startup instead of as data loss.
|
|
159
|
+
*
|
|
160
|
+
* `:memory:` is the one deliberate exemption: SQLite does not implement WAL for
|
|
161
|
+
* in-memory databases and reports `memory` forever, which is correct there
|
|
162
|
+
* because the database cannot outlive the process. `memoryDatabase` is what
|
|
163
|
+
* distinguishes that benign case from a *file* that reported `memory` (a real
|
|
164
|
+
* failure — it means no rollback journal and no WAL).
|
|
165
|
+
*/
|
|
166
|
+
export function assertLegionJournalMode(
|
|
167
|
+
mode: unknown,
|
|
168
|
+
memoryDatabase: boolean,
|
|
169
|
+
database: string = "legion.db",
|
|
170
|
+
): void {
|
|
171
|
+
const observed = typeof mode === "string" ? mode : String(mode);
|
|
172
|
+
if (memoryDatabase && observed === "memory") return;
|
|
173
|
+
if (observed === LEGION_DB_REQUIRED_JOURNAL_MODE) return;
|
|
174
|
+
throw new Error(
|
|
175
|
+
`${database} is not in WAL mode: PRAGMA journal_mode = WAL reported "${observed}" ` +
|
|
176
|
+
`(expected "${LEGION_DB_REQUIRED_JOURNAL_MODE}"). WAL is a precondition of the ledger's ` +
|
|
177
|
+
"durability and reader/writer isolation, so this connection is refused rather than " +
|
|
178
|
+
"silently running on a rollback journal. A mount without working shared-memory " +
|
|
179
|
+
"support, or read-only media, is the usual cause.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
112
183
|
/** Resolve the tree ledger location specified by contract §4.1. */
|
|
113
184
|
export function legionDbPath(rootRunDir: string): string {
|
|
114
185
|
if (typeof rootRunDir !== "string" || rootRunDir.trim().length === 0) {
|
|
@@ -120,6 +191,11 @@ export function legionDbPath(rootRunDir: string): string {
|
|
|
120
191
|
/**
|
|
121
192
|
* Open one supervisor's connection to the shared tree ledger. SQLite is loaded
|
|
122
193
|
* lazily so importing the extension does not require a driver until v2 is used.
|
|
194
|
+
*
|
|
195
|
+
* The WAL switch is — unusually for a PRAGMA — a request, not a command, so its
|
|
196
|
+
* returned mode is verified rather than assumed; see
|
|
197
|
+
* {@link assertLegionJournalMode}. A file database that cannot reach `wal`
|
|
198
|
+
* throws here, at startup, instead of quietly running on a rollback journal.
|
|
123
199
|
*/
|
|
124
200
|
export function openLegionDb(dbPath: string): DatabaseSync {
|
|
125
201
|
if (typeof dbPath !== "string" || dbPath.trim().length === 0) {
|
|
@@ -131,9 +207,13 @@ export function openLegionDb(dbPath: string): DatabaseSync {
|
|
|
131
207
|
const Database = sqliteDatabaseConstructor();
|
|
132
208
|
const db = new Database(dbPath);
|
|
133
209
|
try {
|
|
210
|
+
// Compiled statements are reused for every one of the call sites above.
|
|
211
|
+
attachStatementCache(db);
|
|
134
212
|
db.exec(`PRAGMA busy_timeout = ${LEGION_DB_BUSY_TIMEOUT_MS};`);
|
|
135
213
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
136
|
-
|
|
214
|
+
// One query both requests WAL and reports the mode that actually resulted.
|
|
215
|
+
const journalMode = db.prepare("PRAGMA journal_mode = WAL;").get()?.journal_mode;
|
|
216
|
+
assertLegionJournalMode(journalMode, isMemoryLegionDb(db), dbPath);
|
|
137
217
|
migrate(db);
|
|
138
218
|
return db;
|
|
139
219
|
} catch (error) {
|
|
@@ -142,6 +222,76 @@ export function openLegionDb(dbPath: string): DatabaseSync {
|
|
|
142
222
|
}
|
|
143
223
|
}
|
|
144
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Serve every `prepare()` on this connection from a bounded per-connection LRU.
|
|
227
|
+
*
|
|
228
|
+
* The cache sits *inside* `prepare` rather than at the call sites: each call
|
|
229
|
+
* still enters the data layer (so instrumentation that counts
|
|
230
|
+
* `prepare(sql)` keeps counting), and all call sites get the reuse for free.
|
|
231
|
+
*
|
|
232
|
+
* Statements are keyed on the exact SQL text: two texts that differ only in
|
|
233
|
+
* whitespace are deliberately two entries, because SQL text is the only
|
|
234
|
+
* identity SQLite offers.
|
|
235
|
+
*
|
|
236
|
+
* Reuse is safe for data changes — `.get()`/`.all()`/`.run()` re-execute with
|
|
237
|
+
* the new bindings, so a cached statement always sees committed rows. It is NOT
|
|
238
|
+
* safe across a schema change: after `ALTER TABLE ... ADD COLUMN`, a cached
|
|
239
|
+
* `SELECT *` executed with `.all()` keeps reporting the schema it compiled
|
|
240
|
+
* against (`.get()` happens to refresh, which makes the hazard easy to miss).
|
|
241
|
+
* Any `exec()` whose text mentions a schema-mutating keyword therefore drops
|
|
242
|
+
* every cached statement, so the next `prepare()` recompiles against the new
|
|
243
|
+
* schema. That covers `migrate()`'s `SCHEMA_V1` (`CREATE TABLE/INDEX`),
|
|
244
|
+
* `hygiene.ts`'s `VACUUM`, and any future `ALTER`/`DROP`. `rebuild.ts` only
|
|
245
|
+
* issues `DELETE FROM`, which is a data change, so it needs no invalidation.
|
|
246
|
+
*
|
|
247
|
+
* Invalidation protects future `prepare()` calls; a handle the caller already
|
|
248
|
+
* holds stays live with the schema it compiled against, as it would without
|
|
249
|
+
* this cache.
|
|
250
|
+
*/
|
|
251
|
+
function attachStatementCache(db: DatabaseSync): void {
|
|
252
|
+
if (statementCaches.has(db)) return;
|
|
253
|
+
const prepare = db.prepare as (sql: string, ...rest: unknown[]) => LegionStatementSync;
|
|
254
|
+
const exec = db.exec as (sql: string, ...rest: unknown[]) => void;
|
|
255
|
+
const close = db.close as (...rest: unknown[]) => void;
|
|
256
|
+
const cache = new Map<string, LegionStatementSync>();
|
|
257
|
+
statementCaches.set(db, cache);
|
|
258
|
+
db.prepare = (sql: string, ...rest: unknown[]): LegionStatementSync => {
|
|
259
|
+
const cached = cache.get(sql);
|
|
260
|
+
if (cached) {
|
|
261
|
+
// Re-insert so Map order stays least-recently-used first.
|
|
262
|
+
cache.delete(sql);
|
|
263
|
+
cache.set(sql, cached);
|
|
264
|
+
return cached;
|
|
265
|
+
}
|
|
266
|
+
const statement = prepare.call(db, sql, ...rest);
|
|
267
|
+
cache.set(sql, statement);
|
|
268
|
+
if (cache.size > LEGION_STMT_CACHE_LIMIT) {
|
|
269
|
+
const oldest = cache.keys().next();
|
|
270
|
+
if (!oldest.done) cache.delete(oldest.value);
|
|
271
|
+
}
|
|
272
|
+
return statement;
|
|
273
|
+
};
|
|
274
|
+
db.exec = (sql: string, ...rest: unknown[]): void => {
|
|
275
|
+
// Match anywhere in the text: `exec` runs multi-statement SQL (SCHEMA_V1)
|
|
276
|
+
// and over-matching only costs a recompile, while under-matching would
|
|
277
|
+
// serve statements compiled against a schema that no longer exists.
|
|
278
|
+
if (SCHEMA_MUTATION_PATTERN.test(sql)) cache.clear();
|
|
279
|
+
exec.call(db, sql, ...rest);
|
|
280
|
+
};
|
|
281
|
+
db.close = (...rest: unknown[]): void => {
|
|
282
|
+
// Drop our references before the driver finalizes them, so the cache
|
|
283
|
+
// cannot outlive the connection and nothing is released twice.
|
|
284
|
+
cache.clear();
|
|
285
|
+
statementCaches.delete(db);
|
|
286
|
+
close.call(db, ...rest);
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Compiled statements a connection currently holds; 0 once it is closed. */
|
|
291
|
+
export function legionStatementCacheSize(db: DatabaseSync): number {
|
|
292
|
+
return statementCaches.get(db)?.size ?? 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
145
295
|
/** Run a short synchronous write transaction. Nested ledger helpers use savepoints. */
|
|
146
296
|
export function withTransaction<T>(db: DatabaseSync, run: () => T): T {
|
|
147
297
|
const depth = transactionDepth.get(db) ?? 0;
|
|
@@ -236,6 +386,20 @@ function sqliteDatabaseConstructor(): SqliteDatabaseConstructor {
|
|
|
236
386
|
throw new AggregateError(failures, "no supported synchronous SQLite driver is available");
|
|
237
387
|
}
|
|
238
388
|
|
|
389
|
+
/**
|
|
390
|
+
* True when this connection's `main` database lives in memory rather than on
|
|
391
|
+
* disk. `PRAGMA database_list` reports an empty `file` for `:memory:` and for
|
|
392
|
+
* the `file::memory:` URIs, and the resolved path for a real file — asking
|
|
393
|
+
* SQLite beats pattern-matching the path the caller passed, because the driver
|
|
394
|
+
* is what decides. A missing row means we cannot tell, so it is treated as a
|
|
395
|
+
* file: the WAL check then applies and the failure is loud rather than silent.
|
|
396
|
+
*/
|
|
397
|
+
function isMemoryLegionDb(db: DatabaseSync): boolean {
|
|
398
|
+
const row = db.prepare("PRAGMA database_list;").get();
|
|
399
|
+
if (row == null) return false;
|
|
400
|
+
return String(row.file ?? "").length === 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
239
403
|
function readUserVersion(db: DatabaseSync): number {
|
|
240
404
|
const row = db.prepare("PRAGMA user_version;").get();
|
|
241
405
|
return Number(row?.user_version ?? 0);
|
package/src/legion/events.ts
CHANGED
|
@@ -7,6 +7,12 @@ export const LEGION_EVENT_TYPES = [
|
|
|
7
7
|
"node_retired",
|
|
8
8
|
"node_resumed",
|
|
9
9
|
"node_blocked",
|
|
10
|
+
/**
|
|
11
|
+
* Written by hygiene's `markAbandoned` (#233). It was missing from this set
|
|
12
|
+
* while both the writer and `RETAINED_EVENT_TYPES` already treated it as a
|
|
13
|
+
* real event, so `mapEvent` threw on read and froze the db event cursor.
|
|
14
|
+
*/
|
|
15
|
+
"node_abandoned",
|
|
10
16
|
"phase_change",
|
|
11
17
|
"phase_timeout",
|
|
12
18
|
"mail_sent",
|
|
@@ -36,6 +42,13 @@ export interface ReadEventsOptions {
|
|
|
36
42
|
limit?: number;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
export interface ReadEventRowsOptions {
|
|
46
|
+
nodeId?: string;
|
|
47
|
+
limit?: number;
|
|
48
|
+
/** Called for a row that cannot be decoded; the row is skipped, not thrown. */
|
|
49
|
+
onInvalidRow?: (error: unknown, rowId: number) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
export function appendEvent<T>(
|
|
40
53
|
db: DatabaseSync,
|
|
41
54
|
nodeId: string,
|
|
@@ -73,23 +86,28 @@ export function readEventsAfter(
|
|
|
73
86
|
cursor: number,
|
|
74
87
|
options: ReadEventsOptions = {},
|
|
75
88
|
): LegionEvent[] {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
89
|
+
return selectEventRows(db, cursor, options).map(mapEvent);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Per-row tolerant read: an undecodable row is reported through `onInvalidRow` and
|
|
94
|
+
* skipped instead of throwing, so one poisoned row cannot stall an event consumer.
|
|
95
|
+
* `readEventsAfter` stays strict for callers that must fail loudly.
|
|
96
|
+
*/
|
|
97
|
+
export function readEventsAfterTolerant(
|
|
98
|
+
db: DatabaseSync,
|
|
99
|
+
cursor: number,
|
|
100
|
+
options: ReadEventRowsOptions = {},
|
|
101
|
+
): LegionEvent[] {
|
|
102
|
+
const events: LegionEvent[] = [];
|
|
103
|
+
for (const row of selectEventRows(db, cursor, options)) {
|
|
104
|
+
try {
|
|
105
|
+
events.push(mapEvent(row));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
options.onInvalidRow?.(error, Number(row.id));
|
|
108
|
+
}
|
|
80
109
|
}
|
|
81
|
-
|
|
82
|
-
? db
|
|
83
|
-
.prepare(
|
|
84
|
-
"SELECT id, node_id, type, data, ts FROM events WHERE id > ? AND node_id = ? ORDER BY id LIMIT ?",
|
|
85
|
-
)
|
|
86
|
-
.all(cursor, options.nodeId, limit)
|
|
87
|
-
: db
|
|
88
|
-
.prepare(
|
|
89
|
-
"SELECT id, node_id, type, data, ts FROM events WHERE id > ? ORDER BY id LIMIT ?",
|
|
90
|
-
)
|
|
91
|
-
.all(cursor, limit);
|
|
92
|
-
return rows.map(mapEvent);
|
|
110
|
+
return events;
|
|
93
111
|
}
|
|
94
112
|
|
|
95
113
|
export function getEventById(db: DatabaseSync, id: number): LegionEvent | null {
|
|
@@ -102,6 +120,30 @@ export function getEventById(db: DatabaseSync, id: number): LegionEvent | null {
|
|
|
102
120
|
return row ? mapEvent(row) : null;
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Highest persisted event id, or `0` for an empty log.
|
|
125
|
+
*
|
|
126
|
+
* #238: a caller that attaches to a ledger it did not just write uses this to
|
|
127
|
+
* seed `createDbEventSource`'s `cursor` at the current tail, so attach does not
|
|
128
|
+
* replay the whole history. The SQL deliberately does NOT begin with the
|
|
129
|
+
* `SELECT id, node_id, type, data, ts FROM events WHERE id > ?` prefix: the
|
|
130
|
+
* instrumented `DatabaseSync` doubles in `test/unit/legion-event-bus.test.ts`
|
|
131
|
+
* count event SELECTs by that exact prefix and assert exact counts, so a
|
|
132
|
+
* differently-shaped statement must stay outside that counter.
|
|
133
|
+
*/
|
|
134
|
+
export function maxEventId(db: DatabaseSync): number {
|
|
135
|
+
const value = Number(
|
|
136
|
+
db.prepare("SELECT COALESCE(MAX(id), 0) AS id FROM events").get()?.id ?? 0,
|
|
137
|
+
);
|
|
138
|
+
// Only two shapes can reach here: 0 (empty log) or a positive safe integer.
|
|
139
|
+
// The column is an INTEGER PRIMARY KEY, a non-numeric write is refused with
|
|
140
|
+
// "datatype mismatch", and `node:sqlite` refuses to return an id it cannot
|
|
141
|
+
// represent exactly as a JS number ("Value is too large to be represented as a
|
|
142
|
+
// JavaScript number"). So this is a narrowing, not a recovery path — and a seed
|
|
143
|
+
// of 0 is safe regardless, because it only means "no history to skip".
|
|
144
|
+
return Number.isSafeInteger(value) && value > 0 ? value : 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
105
147
|
/** PRAGMA data_version changes only when another connection commits. */
|
|
106
148
|
export function getDataVersion(db: DatabaseSync): number {
|
|
107
149
|
return Number(db.prepare("PRAGMA data_version;").get()?.data_version ?? 0);
|
|
@@ -143,6 +185,29 @@ function mapEvent(row: SqliteRow): LegionEvent {
|
|
|
143
185
|
};
|
|
144
186
|
}
|
|
145
187
|
|
|
188
|
+
function selectEventRows(
|
|
189
|
+
db: DatabaseSync,
|
|
190
|
+
cursor: number,
|
|
191
|
+
options: ReadEventsOptions,
|
|
192
|
+
): SqliteRow[] {
|
|
193
|
+
assertCursor(cursor);
|
|
194
|
+
const limit = options.limit ?? 1_000;
|
|
195
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
196
|
+
throw new TypeError("event limit must be a positive safe integer");
|
|
197
|
+
}
|
|
198
|
+
return options.nodeId
|
|
199
|
+
? db
|
|
200
|
+
.prepare(
|
|
201
|
+
"SELECT id, node_id, type, data, ts FROM events WHERE id > ? AND node_id = ? ORDER BY id LIMIT ?",
|
|
202
|
+
)
|
|
203
|
+
.all(cursor, options.nodeId, limit)
|
|
204
|
+
: db
|
|
205
|
+
.prepare(
|
|
206
|
+
"SELECT id, node_id, type, data, ts FROM events WHERE id > ? ORDER BY id LIMIT ?",
|
|
207
|
+
)
|
|
208
|
+
.all(cursor, limit);
|
|
209
|
+
}
|
|
210
|
+
|
|
146
211
|
function assertCursor(cursor: number): void {
|
|
147
212
|
if (!Number.isSafeInteger(cursor) || cursor < 0) {
|
|
148
213
|
throw new TypeError("event cursor must be a non-negative safe integer");
|
package/src/legion/nodes.ts
CHANGED
|
@@ -366,12 +366,23 @@ export function updateNode(
|
|
|
366
366
|
return updated;
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Touch only `updated_at` for a live child. Empty-patch heartbeats must not
|
|
371
|
+
* run the full `updateNode` CASE ladder or a `SELECT *` read-back — orphan
|
|
372
|
+
* detection / `staleNodeHours` only need the timestamp (#236 P1).
|
|
373
|
+
*/
|
|
369
374
|
export function heartbeatNode(
|
|
370
375
|
db: DatabaseSync,
|
|
371
376
|
id: string,
|
|
372
377
|
now: () => number = () => Date.now(),
|
|
373
|
-
):
|
|
374
|
-
|
|
378
|
+
): { id: string; updatedAt: number } {
|
|
379
|
+
const timestamp = now();
|
|
380
|
+
assertTimestamp(timestamp, "clock");
|
|
381
|
+
const result = db
|
|
382
|
+
.prepare("UPDATE nodes SET updated_at = ? WHERE id = ?")
|
|
383
|
+
.run(timestamp, id);
|
|
384
|
+
if (Number(result.changes) === 0) throw new Error(`node not found: ${id}`);
|
|
385
|
+
return { id, updatedAt: timestamp };
|
|
375
386
|
}
|
|
376
387
|
|
|
377
388
|
export function listNodesByStatus(
|
package/src/legion/rebuild.ts
CHANGED
|
@@ -305,6 +305,7 @@ function collectSources(
|
|
|
305
305
|
for (const child of run.children) {
|
|
306
306
|
try {
|
|
307
307
|
const childSource = sourceFromChild(currentId, child, file, run, clock);
|
|
308
|
+
if (!childSource) continue;
|
|
308
309
|
const nestedRun = runs.find((entry) => nodeIdFromPath(entry.run.path) === childSource.id);
|
|
309
310
|
if (nestedRun) {
|
|
310
311
|
childSource.runId = nestedRun.run.runId;
|
|
@@ -336,7 +337,12 @@ function sourceFromChild(
|
|
|
336
337
|
runFile: string,
|
|
337
338
|
parentRun: RunRecord,
|
|
338
339
|
clock: () => number,
|
|
339
|
-
): NodeSource {
|
|
340
|
+
): NodeSource | undefined {
|
|
341
|
+
// #266 D3/§4.4: a `queued` child was never started — it has no ledger row, no
|
|
342
|
+
// run directory and only a placeholder session path. Rebuilding it into the
|
|
343
|
+
// tree would invent a node (and a phantom leaf) for a launch that may still be
|
|
344
|
+
// waiting or may have been dropped with the previous process.
|
|
345
|
+
if (child.state === "queued") return undefined;
|
|
340
346
|
const slug = sanitizeNodeSlug(child.name);
|
|
341
347
|
const id = nodeIdForChild(parentId, slug);
|
|
342
348
|
let status: NodeSource["status"] = "settled";
|
package/src/legion/usage.ts
CHANGED
|
@@ -16,6 +16,13 @@ export interface UpsertUsageInput {
|
|
|
16
16
|
costUsd?: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Chunk size for `WHERE node_id IN (...)` in {@link attachUsageTotals}.
|
|
21
|
+
* Stays under the common `SQLITE_MAX_VARIABLE_NUMBER` of 999 (some builds use
|
|
22
|
+
* 32766); contract trees are far smaller, but `getSubtree` is unbounded.
|
|
23
|
+
*/
|
|
24
|
+
export const USAGE_IN_CHUNK = 500;
|
|
25
|
+
|
|
19
26
|
export function upsertUsage(
|
|
20
27
|
db: DatabaseSync,
|
|
21
28
|
input: UpsertUsageInput,
|
|
@@ -61,23 +68,70 @@ export function usageTotalTokens(db: DatabaseSync, nodeId: string): number {
|
|
|
61
68
|
return usage ? usage.tokensIn + usage.tokensOut : 0;
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Annotate a node snapshot with per-node usage totals (#235).
|
|
73
|
+
*
|
|
74
|
+
* Signature and output shape are unchanged from the previous N+1 path:
|
|
75
|
+
* - `root` is never annotated (even if a usage row exists)
|
|
76
|
+
* - `tokensTotal` / `tokensOut` appear only when strictly > 0
|
|
77
|
+
* - the input array / objects are not mutated
|
|
78
|
+
*
|
|
79
|
+
* Implementation: one (or chunked) `WHERE node_id IN (...)` query + Map lookup,
|
|
80
|
+
* instead of one `getUsage` prepare/execute per node.
|
|
81
|
+
*/
|
|
64
82
|
export function attachUsageTotals<T extends { id: string }>(
|
|
65
83
|
db: DatabaseSync,
|
|
66
84
|
nodes: T[],
|
|
67
85
|
): Array<T & { tokensTotal?: number; tokensOut?: number }> {
|
|
86
|
+
type Annotated = T & { tokensTotal?: number; tokensOut?: number };
|
|
87
|
+
if (nodes.length === 0) return [];
|
|
88
|
+
|
|
89
|
+
const ids: string[] = [];
|
|
90
|
+
const seen = new Set<string>();
|
|
91
|
+
for (const node of nodes) {
|
|
92
|
+
if (node.id === "root") continue;
|
|
93
|
+
assertNodeId(node.id);
|
|
94
|
+
if (seen.has(node.id)) continue;
|
|
95
|
+
seen.add(node.id);
|
|
96
|
+
ids.push(node.id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const usageById = loadUsageByNodeIds(db, ids);
|
|
100
|
+
|
|
68
101
|
return nodes.map((node) => {
|
|
69
|
-
if (node.id === "root") return { ...node } as
|
|
70
|
-
const usage =
|
|
71
|
-
if (!usage) return { ...node } as
|
|
102
|
+
if (node.id === "root") return { ...node } as Annotated;
|
|
103
|
+
const usage = usageById.get(node.id);
|
|
104
|
+
if (!usage) return { ...node } as Annotated;
|
|
72
105
|
const total = usage.tokensIn + usage.tokensOut;
|
|
73
106
|
return {
|
|
74
107
|
...node,
|
|
75
108
|
...(total > 0 ? { tokensTotal: total } : {}),
|
|
76
109
|
...(usage.tokensOut > 0 ? { tokensOut: usage.tokensOut } : {}),
|
|
77
|
-
} as
|
|
110
|
+
} as Annotated;
|
|
78
111
|
});
|
|
79
112
|
}
|
|
80
113
|
|
|
114
|
+
/** Batch-load usage rows for the given node ids (chunked for SQLite bind limits). */
|
|
115
|
+
function loadUsageByNodeIds(db: DatabaseSync, ids: string[]): Map<string, UsageSnapshot> {
|
|
116
|
+
const usageById = new Map<string, UsageSnapshot>();
|
|
117
|
+
if (ids.length === 0) return usageById;
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < ids.length; i += USAGE_IN_CHUNK) {
|
|
120
|
+
const chunk = ids.slice(i, i + USAGE_IN_CHUNK);
|
|
121
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
122
|
+
const rows = db
|
|
123
|
+
.prepare(
|
|
124
|
+
`SELECT node_id, tokens_in, tokens_out, cost_usd, updated_at FROM usage WHERE node_id IN (${placeholders})`,
|
|
125
|
+
)
|
|
126
|
+
.all(...chunk);
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
const usage = mapUsage(row);
|
|
129
|
+
usageById.set(usage.nodeId, usage);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return usageById;
|
|
133
|
+
}
|
|
134
|
+
|
|
81
135
|
function mapUsage(row: SqliteRow): UsageSnapshot {
|
|
82
136
|
return {
|
|
83
137
|
nodeId: String(row.node_id),
|