portable-agent-layer 0.69.0 → 0.70.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/README.md CHANGED
@@ -89,6 +89,7 @@ pal cli status # check your setup
89
89
  | `pal cli machine [label <name>]` | Show or rename this install — where a record was written. Never leaves the machine |
90
90
  | `pal cli knowledge` | Query & manage the knowledge store (search, graph, stats, hubs, find, show, add, ls, ingest) |
91
91
  | `pal cli ledger` | Query the action ledger — `log`, `show <id>`, `stats`, filtered by `--project`, `--since`, `--actor`, `--machine`, `--runtime`, `--outcome`, `--tool`, `--target` |
92
+ | `pal cli server` | Local page over the action ledger for showing the log to a person — `start [--port <n>]`, `stop`, `status`. Loopback only, default port 7250; project and date window are chosen on the page |
92
93
  | `pal cli skill link <name>` | Link a personal `~/.pal/skills/<name>/` into every installed agent so it is discoverable |
93
94
  | `pal cli skill doctor <name>` | Evaluate a skill against the authoring best practices (folder/file-name match, name, description, body length, point-of-view, reference depth) |
94
95
  | `pal cli subagent link <name>` | Install a personal `~/.pal/agents/<name>.md` (merged multi-platform definition) into every installed agent, split per platform |
@@ -0,0 +1,213 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Action Ledger</title>
7
+ <style>
8
+ :root {
9
+ --bg: #eef0f2;
10
+ --panel: #ffffff;
11
+ --ink: #1b2026;
12
+ --muted: #6b7480;
13
+ --line: #d5dae0;
14
+ --accent: #0f6e6e;
15
+ --accent-ink: #ffffff;
16
+ --block: #b4471c;
17
+ --block-bg: #fbf0ea;
18
+ --ok: #2f7a3e;
19
+ --ok-bg: #edf6ee;
20
+ --pending: #8a6d00;
21
+ --pending-bg: #fbf5df;
22
+ --agent: #4a3fa3;
23
+ --agent-bg: #efedf9;
24
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
25
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
26
+ }
27
+ * { box-sizing: border-box; }
28
+ html, body { height: 100%; }
29
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 13px/1.45 var(--sans); }
30
+ header {
31
+ display: flex; align-items: center; justify-content: space-between;
32
+ padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--line);
33
+ }
34
+ header h1 { font-size: 14px; margin: 0; font-weight: 600; letter-spacing: .01em; }
35
+ header .model { font-family: var(--mono); font-size: 11px; color: var(--muted); }
36
+ header .who { font-size: 12px; color: var(--muted); }
37
+ header .who b { color: var(--ink); font-weight: 600; }
38
+
39
+ main { background: var(--panel); min-height: calc(100% - 45px); }
40
+ h2 {
41
+ font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
42
+ margin: 0; padding: 12px 14px 8px; font-weight: 600;
43
+ }
44
+
45
+ .filters { display: flex; gap: 10px; padding: 0 14px 12px; flex-wrap: wrap; align-items: end; }
46
+ .filters label { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
47
+ select, input { font: inherit; padding: 4px 6px; border: 1px solid var(--line); border-radius: 3px; }
48
+
49
+ .stats { display: grid; grid-template-columns: 1.4fr repeat(4, 1fr); gap: 1px; background: var(--line); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
50
+ .stat { background: var(--panel); padding: 12px 14px; }
51
+ .stat label { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
52
+ .stat .n { font-size: 26px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.2; }
53
+ .stat.headline { background: var(--block-bg); }
54
+ .stat.headline .n { font-size: 40px; color: var(--block); }
55
+ .stat.applied .n { color: var(--ok); }
56
+ .stat.failed .n { color: var(--pending); }
57
+ .stat.denied .n, .stat.blocked .n { color: var(--block); }
58
+ .st { font-size: 11px; color: var(--muted); }
59
+
60
+ table { width: 100%; border-collapse: collapse; }
61
+ td, th { text-align: left; padding: 7px 14px; border-top: 1px solid var(--line); vertical-align: top; font-variant-numeric: tabular-nums; }
62
+ th { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 0; font-weight: 600; }
63
+ td.ts { font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; }
64
+ td.target { font-family: var(--mono); font-size: 11px; word-break: break-all; }
65
+ td.diff { font-family: var(--mono); font-size: 11px; }
66
+ .actor { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
67
+ .actor.human { background: #eef0f2; }
68
+ .actor.agent { background: var(--agent-bg); color: var(--agent); }
69
+ .out { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; }
70
+ .out.applied { background: var(--ok-bg); color: var(--ok); }
71
+ .out.failed { background: var(--pending-bg); color: var(--pending); }
72
+ .out.denied { background: var(--block-bg); color: var(--block); }
73
+ .out.blocked { background: var(--block-bg); color: var(--block); }
74
+ footer { font-family: var(--mono); font-size: 10.5px; color: var(--muted); padding: 10px 14px; border-top: 1px solid var(--line); }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <header>
79
+ <h1>Action Ledger</h1>
80
+ <span class="model" id="modelLine"></span>
81
+ <span class="who" id="who"></span>
82
+ </header>
83
+
84
+ <main>
85
+ <h2>Window</h2>
86
+ <div class="filters">
87
+ <div><label for="project">Project</label><select id="project"><option value="">all projects</option></select></div>
88
+ <div><label for="since">From</label><input type="date" id="since"></div>
89
+ <div><label for="until">To</label><input type="date" id="until"></div>
90
+ </div>
91
+
92
+ <div class="stats" id="stats"></div>
93
+
94
+ <h2>Action log</h2>
95
+ <table>
96
+ <thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>Change</th><th>Outcome</th></tr></thead>
97
+ <tbody id="log"></tbody>
98
+ </table>
99
+ <footer id="footer">Every row is one action an agent tried to make. The ledger saw each from both sides, before and after. Nothing here was written by hand.</footer>
100
+ </main>
101
+
102
+ <script>
103
+ const $ = id => document.getElementById(id);
104
+ const OUTCOMES = ['applied', 'failed', 'denied', 'blocked'];
105
+ let ledgerFiles = 0;
106
+
107
+ function el(tag, cls, text) {
108
+ const node = document.createElement(tag);
109
+ if (cls) node.className = cls;
110
+ if (text !== undefined) node.textContent = text;
111
+ return node;
112
+ }
113
+
114
+ const pad = n => String(n).padStart(2, '0');
115
+ function when(ts) {
116
+ const d = new Date(ts);
117
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
118
+ }
119
+
120
+ const authorityWord = key => key === 'user' ? 'human' : key;
121
+ function tally(counts, word = k => k) {
122
+ const parts = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${word(k)} ${v}`);
123
+ return parts.length ? parts.join(' · ') : '—';
124
+ }
125
+
126
+ function query() {
127
+ const p = new URLSearchParams();
128
+ if ($('project').value) p.set('project', $('project').value);
129
+ if ($('since').value) p.set('since', $('since').value);
130
+ if ($('until').value) p.set('until', `${$('until').value}T23:59:59.999Z`);
131
+ return p.toString();
132
+ }
133
+
134
+ function windowText() {
135
+ const from = $('since').value, to = $('until').value;
136
+ if (!from && !to) return 'all time';
137
+ return `${from || 'start'} → ${to || 'now'}`;
138
+ }
139
+
140
+ function renderStats(s) {
141
+ const strip = $('stats');
142
+ strip.replaceChildren();
143
+ const headline = el('div', 'stat headline');
144
+ headline.append(el('label', '', 'Refusals'), el('div', 'n', String(s.refusals)),
145
+ el('div', 'st', `denied ${s.outcomes.denied.total} · blocked ${s.outcomes.blocked.total}`));
146
+ strip.append(headline);
147
+ for (const key of OUTCOMES) {
148
+ const o = s.outcomes[key];
149
+ const card = el('div', `stat ${key}`);
150
+ card.append(el('label', '', key), el('div', 'n', String(o.total)),
151
+ el('div', 'st', tally(o.byAuthority, authorityWord)), el('div', 'st', tally(o.byRuntime)));
152
+ strip.append(card);
153
+ }
154
+ }
155
+
156
+ function actorCell(r) {
157
+ const td = el('td');
158
+ const agent = r.authority === 'agent';
159
+ td.append(el('span', `actor ${agent ? 'agent' : 'human'}`, agent ? `agent · ${r.runtime}` : r.actor));
160
+ td.append(el('div', 'st', agent ? `on behalf of ${r.actor}` : `via ${r.runtime}`));
161
+ return td;
162
+ }
163
+
164
+ function changeCell(r) {
165
+ const td = el('td', 'diff');
166
+ td.append(r.reason ? el('span', 'st', r.reason) : document.createTextNode(r.change));
167
+ return td;
168
+ }
169
+
170
+ function renderRows(rows) {
171
+ const body = $('log');
172
+ body.replaceChildren();
173
+ if (!rows.length) {
174
+ const td = el('td', 'st', 'No actions in this window.');
175
+ td.colSpan = 6;
176
+ const tr = el('tr'); tr.append(td); body.append(tr);
177
+ return;
178
+ }
179
+ for (const r of rows) {
180
+ const tr = el('tr');
181
+ const outcome = el('td'); outcome.append(el('span', `out ${r.outcome}`, r.outcome));
182
+ tr.append(el('td', 'ts', when(r.ts)), actorCell(r), el('td', '', r.tool), el('td', 'target', r.target), changeCell(r), outcome);
183
+ body.append(tr);
184
+ }
185
+ }
186
+
187
+ async function load() {
188
+ const res = await fetch(`/api/ledger?${query()}`);
189
+ const body = await res.json();
190
+ if (!res.ok) { $('modelLine').textContent = body.error; return; }
191
+ renderStats(body.stats);
192
+ renderRows(body.rows);
193
+ $('modelLine').textContent = `${body.stats.total} actions · ${windowText()} · ${ledgerFiles} ledger file(s)`;
194
+ }
195
+
196
+ async function loadProjects() {
197
+ const list = await (await fetch('/api/projects')).json();
198
+ for (const { slug } of list) {
199
+ const o = el('option', '', slug); o.value = slug; $('project').append(o);
200
+ }
201
+ }
202
+
203
+ async function loadStatus() {
204
+ const s = await (await fetch('/api/status')).json();
205
+ ledgerFiles = s.ledgerFiles;
206
+ $('who').replaceChildren('machine ', Object.assign(el('b', '', s.machine)));
207
+ }
208
+
209
+ for (const id of ['project', 'since', 'until']) $(id).addEventListener('change', load);
210
+ Promise.all([loadProjects(), loadStatus()]).then(load);
211
+ </script>
212
+ </body>
213
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.69.0",
3
+ "version": "0.70.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * doctor Check prerequisites and system health
18
18
  * usage Summarize token usage and cost
19
19
  * ledger <sub> [filters] Query the action ledger (log · show · stats)
20
+ * server start|stop|status Local page over the ledger
20
21
  * skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
21
22
  * skill doctor <name|--all> Evaluate one skill, or every installed skill, against the authoring best practices
22
23
  * subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
@@ -239,6 +240,12 @@ async function runCli(command: string | undefined, args: string[]) {
239
240
  if (code !== 0) process.exit(code);
240
241
  break;
241
242
  }
243
+ case "server": {
244
+ const { runServer } = await import("./server");
245
+ const code = await runServer(args);
246
+ if (code !== 0) process.exit(code);
247
+ break;
248
+ }
242
249
  case "subagent": {
243
250
  const { runSubagent } = await import("./subagent");
244
251
  const code = await runSubagent(args);
@@ -308,6 +315,7 @@ function showHelp() {
308
315
  (search · graph · stats · hubs · find · show · add · ls)
309
316
  pal cli ledger <sub> [filters] Query the action ledger (log · show · stats)
310
317
  e.g. ledger log --project X --since 7d
318
+ pal cli server start|stop|status Local page over the ledger, for showing the log to a person
311
319
  pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
312
320
  pal cli skill doctor <name|--all> Evaluate one skill, or every installed skill
313
321
  pal cli skill author-model Print the flagship model that authors skills for the active agent
package/src/cli/ledger.ts CHANGED
@@ -16,6 +16,7 @@ import type { LedgerEntry } from "../hooks/lib/ledger";
16
16
  import {
17
17
  type ChainVerdict,
18
18
  chainVerdict,
19
+ changedLines,
19
20
  changeShape,
20
21
  findEntry,
21
22
  type LedgerFilter,
@@ -145,23 +146,6 @@ function shortId(id: string): string {
145
146
  return id.slice(0, 11).padEnd(11);
146
147
  }
147
148
 
148
- function changedLines(entry: LedgerEntry): string {
149
- const shape = changeShape(entry);
150
- switch (shape.kind) {
151
- case "redacted":
152
- return "withheld";
153
- case "truncated":
154
- return "too large";
155
- case "none":
156
- return "no change";
157
- default: {
158
- const added = shape.delta.hunks.reduce((n, h) => n + h.insert.length, 0);
159
- const removed = shape.delta.hunks.reduce((n, h) => n + h.remove, 0);
160
- return `+${added} -${removed}`;
161
- }
162
- }
163
- }
164
-
165
149
  function cmdLog(args: string[]): number {
166
150
  const parsed = parseFilters(args);
167
151
  if (typeof parsed === "string") return fail(parsed);
@@ -0,0 +1,195 @@
1
+ /**
2
+ * pal cli server — start, stop and inspect the local ledger page.
3
+ *
4
+ * The server itself is src/tools/ledger/server.ts, run detached so it
5
+ * outlives the shell that started it. This file only owns the lifecycle:
6
+ * spawning, waiting for it to answer, remembering its pid, and killing it.
7
+ */
8
+
9
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { parseArgs } from "node:util";
12
+ import { spawnDetachedInference } from "../hooks/lib/detached-inference";
13
+ import { paths } from "../hooks/lib/paths";
14
+ import { DEFAULT_PORT, LOOPBACK, type ServerStatus } from "../tools/ledger/server";
15
+
16
+ interface ServerState {
17
+ pid: number;
18
+ port: number;
19
+ startedAt: string;
20
+ }
21
+
22
+ const SERVER_SCRIPT = resolve(import.meta.dir, "..", "tools", "ledger", "server.ts");
23
+ const STARTUP_TIMEOUT_MS = 3000;
24
+ const PROBE_TIMEOUT_MS = 500;
25
+
26
+ export async function runServer(args: string[]): Promise<number> {
27
+ const [sub, ...rest] = args;
28
+ switch (sub) {
29
+ case "start":
30
+ return cmdStart(rest);
31
+ case "stop":
32
+ return cmdStop();
33
+ case "status":
34
+ return cmdStatus();
35
+ case undefined:
36
+ case "help":
37
+ case "--help":
38
+ case "-h":
39
+ showHelp();
40
+ return 0;
41
+ default:
42
+ console.error(`Unknown subcommand: ${sub}\n`);
43
+ showHelp();
44
+ return 1;
45
+ }
46
+ }
47
+
48
+ function showHelp(): void {
49
+ console.log(`
50
+ Usage:
51
+ pal cli server <subcommand>
52
+
53
+ Subcommands:
54
+ start [--port <n>] Start the ledger page in the background (default port ${DEFAULT_PORT})
55
+ stop Stop it
56
+ status Show whether it is running, and where
57
+
58
+ The page listens on ${LOOPBACK} only.
59
+ `);
60
+ }
61
+
62
+ function url(port: number): string {
63
+ return `http://${LOOPBACK}:${port}/`;
64
+ }
65
+
66
+ function readState(): ServerState | null {
67
+ const file = paths.serverState();
68
+ if (!existsSync(file)) return null;
69
+ try {
70
+ return JSON.parse(readFileSync(file, "utf-8")) as ServerState;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function writeState(state: ServerState): void {
77
+ writeFileSync(paths.serverState(), JSON.stringify(state, null, 2), "utf-8");
78
+ }
79
+
80
+ function clearState(): void {
81
+ const file = paths.serverState();
82
+ if (existsSync(file)) unlinkSync(file);
83
+ }
84
+
85
+ function alive(pid: number): boolean {
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ async function probe(port: number): Promise<ServerStatus | null> {
95
+ try {
96
+ const res = await fetch(`${url(port)}api/status`, {
97
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
98
+ });
99
+ return res.ok ? ((await res.json()) as ServerStatus) : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ async function waitUntilAnswering(port: number): Promise<ServerStatus | null> {
106
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
107
+ while (Date.now() < deadline) {
108
+ const status = await probe(port);
109
+ if (status) return status;
110
+ await Bun.sleep(100);
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function parsePort(args: string[]): number | string {
116
+ const { values } = parseArgs({ args, options: { port: { type: "string" } } });
117
+ if (values.port === undefined) return DEFAULT_PORT;
118
+ const port = Number(values.port);
119
+ return Number.isInteger(port) && port > 0 && port < 65536
120
+ ? port
121
+ : `--port must be a port number, got ${values.port}`;
122
+ }
123
+
124
+ async function cmdStart(args: string[]): Promise<number> {
125
+ const port = parsePort(args);
126
+ if (typeof port === "string") return fail(port);
127
+
128
+ const running = await runningServer();
129
+ if (running) {
130
+ console.log(`Already running at ${url(running.port)} (pid ${running.pid})`);
131
+ return 0;
132
+ }
133
+
134
+ spawnDetachedInference(SERVER_SCRIPT, [`--port=${port}`], "ledger-server");
135
+ const status = await waitUntilAnswering(port);
136
+ if (!status)
137
+ return fail(
138
+ `The ledger page did not answer on port ${port} within ${STARTUP_TIMEOUT_MS / 1000}s. Is the port free?`
139
+ );
140
+
141
+ writeState({ pid: status.pid, port, startedAt: status.startedAt });
142
+ console.log(url(port));
143
+ return 0;
144
+ }
145
+
146
+ /** The state file is a claim; the process answering on that port is the fact. */
147
+ async function runningServer(): Promise<ServerState | null> {
148
+ const state = readState();
149
+ if (!state || !alive(state.pid)) return null;
150
+ return (await probe(state.port)) ? state : null;
151
+ }
152
+
153
+ async function cmdStop(): Promise<number> {
154
+ const state = readState();
155
+ if (!state) {
156
+ console.log("Not running.");
157
+ return 0;
158
+ }
159
+ if (alive(state.pid)) {
160
+ process.kill(state.pid);
161
+ console.log(`Stopped pid ${state.pid}.`);
162
+ } else {
163
+ console.log(`Pid ${state.pid} was already gone; cleared the stale record.`);
164
+ }
165
+ clearState();
166
+ return 0;
167
+ }
168
+
169
+ async function cmdStatus(): Promise<number> {
170
+ const state = readState();
171
+ if (!state) {
172
+ console.log("Not running.");
173
+ return 1;
174
+ }
175
+ const status = alive(state.pid) ? await probe(state.port) : null;
176
+ if (!status) {
177
+ console.log(
178
+ `Not running (stale record for pid ${state.pid}; run \`pal cli server stop\`).`
179
+ );
180
+ return 1;
181
+ }
182
+ console.log(`
183
+ ${url(status.port)}
184
+ pid ${status.pid}
185
+ started ${status.startedAt}
186
+ ledger ${status.ledgerFiles} file(s)
187
+ machine ${status.machine}
188
+ `);
189
+ return 0;
190
+ }
191
+
192
+ function fail(message: string): number {
193
+ console.error(message);
194
+ return 1;
195
+ }
@@ -62,6 +62,7 @@ export const paths = {
62
62
  work: () => ensureDir(home("memory", "work")),
63
63
  backups: () => ensureDir(home("backups")),
64
64
  debug: () => ensureDir(home("debug")),
65
+ serverState: () => home("server.json"),
65
66
  } as const;
66
67
 
67
68
  // Platform directories (env override or cross-platform defaults)
@@ -87,6 +88,7 @@ export const assets = {
87
88
  copilotHooksTemplate: () => pkg("assets", "templates", "hooks.copilot.json"),
88
89
  codexHooksTemplate: () => pkg("assets", "templates", "hooks.codex.json"),
89
90
  codexRulesTemplate: () => pkg("assets", "templates", "rules.codex.rules"),
91
+ ledgerPageTemplate: () => pkg("assets", "templates", "ledger-page.html"),
90
92
  statuslineScriptBash: () => pkg("assets", "statusline.sh"),
91
93
  statuslineScriptPs1: () => pkg("assets", "statusline.ps1"),
92
94
  agentTools: () => pkg("src", "tools", "agent"),
@@ -158,6 +158,24 @@ export function changeShape(entry: LedgerEntry): ChangeShape {
158
158
  * its before-state again. `reverted` is exactly that case, and there the delta
159
159
  * can be run forward for real, which is why it reports whether it did.
160
160
  */
161
+ /** The change as a reader would want it summarised: a line count, or why there is none. */
162
+ export function changedLines(entry: LedgerEntry): string {
163
+ const shape = changeShape(entry);
164
+ switch (shape.kind) {
165
+ case "redacted":
166
+ return "withheld";
167
+ case "truncated":
168
+ return "too large";
169
+ case "none":
170
+ return "no change";
171
+ default: {
172
+ const added = shape.delta.hunks.reduce((n, h) => n + h.insert.length, 0);
173
+ const removed = shape.delta.hunks.reduce((n, h) => n + h.remove, 0);
174
+ return `+${added} -${removed}`;
175
+ }
176
+ }
177
+ }
178
+
161
179
  export type Standing =
162
180
  | { state: "in-place" }
163
181
  | { state: "reverted"; replays: boolean }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A local page over the ledger, for showing the action log to someone who
3
+ * will not read a terminal.
4
+ *
5
+ * Loopback only, stateless, no store of its own: every request reads the
6
+ * ledger afresh through the same query the CLI uses, so the page and
7
+ * `pal cli ledger` can never disagree. The browser holds nothing but the
8
+ * current filter selection.
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { loadMachine } from "../../hooks/lib/machine";
13
+ import { assets } from "../../hooks/lib/paths";
14
+ import { readAllProjects } from "../../hooks/lib/projects";
15
+ import { type LedgerFilter, ledgerFiles, parseSince } from "./query";
16
+ import { ledgerView } from "./view";
17
+
18
+ export const DEFAULT_PORT = 7250;
19
+ export const LOOPBACK = "127.0.0.1";
20
+
21
+ export interface ServerStatus {
22
+ pid: number;
23
+ port: number;
24
+ startedAt: string;
25
+ ledgerFiles: number;
26
+ machine: string;
27
+ }
28
+
29
+ function json(body: unknown, status = 200): Response {
30
+ return Response.json(body, { status });
31
+ }
32
+
33
+ /** A window the page cannot parse is an error, not the whole ledger. */
34
+ function filterFromQuery(params: URLSearchParams): LedgerFilter | string {
35
+ const filter: LedgerFilter = {};
36
+ const project = params.get("project");
37
+ if (project) filter.project = project;
38
+ for (const key of ["since", "until"] as const) {
39
+ const spec = params.get(key);
40
+ if (!spec) continue;
41
+ const at = parseSince(spec);
42
+ if (!at) return `Unrecognised ${key}: ${spec}`;
43
+ filter[key] = at;
44
+ }
45
+ return filter;
46
+ }
47
+
48
+ function page(): Response {
49
+ return new Response(readFileSync(assets.ledgerPageTemplate()), {
50
+ headers: { "content-type": "text/html; charset=utf-8" },
51
+ });
52
+ }
53
+
54
+ function projects(): Response {
55
+ const slugs = readAllProjects()
56
+ .map((p) => p.name)
57
+ .sort((a, b) => a.localeCompare(b));
58
+ return json(slugs.map((slug) => ({ slug })));
59
+ }
60
+
61
+ function ledger(url: URL): Response {
62
+ const filter = filterFromQuery(url.searchParams);
63
+ if (typeof filter === "string") return json({ error: filter }, 400);
64
+ return json(ledgerView(filter));
65
+ }
66
+
67
+ function status(port: number, startedAt: string): Response {
68
+ const body: ServerStatus = {
69
+ pid: process.pid,
70
+ port,
71
+ startedAt,
72
+ ledgerFiles: ledgerFiles().length,
73
+ machine: loadMachine().label,
74
+ };
75
+ return json(body);
76
+ }
77
+
78
+ export function startLedgerServer(port: number = DEFAULT_PORT) {
79
+ const startedAt = new Date().toISOString();
80
+ return Bun.serve({
81
+ hostname: LOOPBACK,
82
+ port,
83
+ fetch(request, server) {
84
+ if (request.method !== "GET") return json({ error: "read only" }, 405);
85
+ const url = new URL(request.url);
86
+ switch (url.pathname) {
87
+ case "/":
88
+ return page();
89
+ case "/api/ledger":
90
+ return ledger(url);
91
+ case "/api/projects":
92
+ return projects();
93
+ case "/api/status":
94
+ return status(server.port ?? port, startedAt);
95
+ default:
96
+ return json({ error: "not found" }, 404);
97
+ }
98
+ },
99
+ });
100
+ }
101
+
102
+ function portFromArgv(argv: string[]): number {
103
+ const flag = argv.find((arg) => arg.startsWith("--port="));
104
+ const port = flag ? Number(flag.slice("--port=".length)) : DEFAULT_PORT;
105
+ return Number.isInteger(port) && port >= 0 ? port : DEFAULT_PORT;
106
+ }
107
+
108
+ if (import.meta.main) {
109
+ const server = startLedgerServer(portFromArgv(process.argv.slice(2)));
110
+ console.log(`http://${LOOPBACK}:${server.port}/`);
111
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The ledger as a page reads it: counts first, rows second, every field
3
+ * already in the words a person would use. Ids become labels, anchors become
4
+ * "project / path", deltas become line counts. Nothing here is computed in
5
+ * the browser, so the numbers on the page are the numbers the tests check.
6
+ */
7
+
8
+ import {
9
+ type ActorRegistryEntry,
10
+ actorDisplayName,
11
+ loadActor,
12
+ readActorRegistry,
13
+ } from "../../hooks/lib/actor";
14
+ import type { LedgerEntry } from "../../hooks/lib/ledger";
15
+ import { anchorSlugOf, changedLines, type LedgerFilter, queryLedger } from "./query";
16
+
17
+ export const PAGE_OUTCOMES = ["applied", "failed", "denied", "blocked"] as const;
18
+ export type PageOutcome = (typeof PAGE_OUTCOMES)[number];
19
+
20
+ export interface OutcomeCount {
21
+ total: number;
22
+ byAuthority: Record<string, number>;
23
+ byRuntime: Record<string, number>;
24
+ }
25
+
26
+ export interface LedgerViewStats {
27
+ total: number;
28
+ refusals: number;
29
+ outcomes: Record<PageOutcome, OutcomeCount>;
30
+ }
31
+
32
+ export interface LedgerViewRow {
33
+ id: string;
34
+ ts: string;
35
+ actor: string;
36
+ authority: string;
37
+ runtime: string;
38
+ tool: string;
39
+ target: string;
40
+ change: string;
41
+ outcome: string;
42
+ reason?: string;
43
+ }
44
+
45
+ export interface LedgerView {
46
+ window: { since: string | null; until: string | null };
47
+ stats: LedgerViewStats;
48
+ rows: LedgerViewRow[];
49
+ }
50
+
51
+ function emptyCount(): OutcomeCount {
52
+ return { total: 0, byAuthority: {}, byRuntime: {} };
53
+ }
54
+
55
+ function bump(counts: Record<string, number>, key: string): void {
56
+ counts[key] = (counts[key] ?? 0) + 1;
57
+ }
58
+
59
+ function isPageOutcome(outcome: string): outcome is PageOutcome {
60
+ return (PAGE_OUTCOMES as readonly string[]).includes(outcome);
61
+ }
62
+
63
+ export function viewStats(entries: LedgerEntry[]): LedgerViewStats {
64
+ const outcomes = Object.fromEntries(
65
+ PAGE_OUTCOMES.map((outcome) => [outcome, emptyCount()])
66
+ ) as Record<PageOutcome, OutcomeCount>;
67
+
68
+ for (const entry of entries) {
69
+ if (!isPageOutcome(entry.outcome)) continue;
70
+ const count = outcomes[entry.outcome];
71
+ count.total++;
72
+ bump(count.byAuthority, entry.authority);
73
+ bump(count.byRuntime, entry.runtime);
74
+ }
75
+
76
+ return {
77
+ total: entries.length,
78
+ refusals: outcomes.denied.total + outcomes.blocked.total,
79
+ outcomes,
80
+ };
81
+ }
82
+
83
+ export function displayTarget(target: string): string {
84
+ const slug = anchorSlugOf(target);
85
+ if (!slug) return target;
86
+ const rest = target.slice(`{proj:${slug}}`.length);
87
+ return rest ? `${slug} ${rest}` : slug;
88
+ }
89
+
90
+ function toRow(entry: LedgerEntry, registry: ActorRegistryEntry[]): LedgerViewRow {
91
+ const row: LedgerViewRow = {
92
+ id: entry.id,
93
+ ts: entry.ts,
94
+ actor: actorDisplayName(entry.actor, registry),
95
+ authority: entry.authority,
96
+ runtime: entry.runtime,
97
+ tool: entry.tool,
98
+ target: displayTarget(entry.target),
99
+ change: changedLines(entry),
100
+ outcome: entry.outcome,
101
+ };
102
+ if (entry.reason) row.reason = entry.reason;
103
+ return row;
104
+ }
105
+
106
+ /** The local actor first: on a fresh install the registry may not list them yet. */
107
+ function knownActors(): ActorRegistryEntry[] {
108
+ const self = loadActor();
109
+ return [{ id: self.id, label: self.label }, ...readActorRegistry()];
110
+ }
111
+
112
+ export function viewRows(entries: LedgerEntry[]): LedgerViewRow[] {
113
+ const registry = knownActors();
114
+ return entries
115
+ .slice()
116
+ .reverse()
117
+ .map((entry) => toRow(entry, registry));
118
+ }
119
+
120
+ export function ledgerView(filter: LedgerFilter = {}): LedgerView {
121
+ const entries = queryLedger(filter);
122
+ return {
123
+ window: {
124
+ since: filter.since?.toISOString() ?? null,
125
+ until: filter.until?.toISOString() ?? null,
126
+ },
127
+ stats: viewStats(entries),
128
+ rows: viewRows(entries),
129
+ };
130
+ }