portable-agent-layer 0.68.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 +2 -0
- package/assets/templates/ledger-page.html +213 -0
- package/package.json +1 -1
- package/src/cli/index.ts +17 -0
- package/src/cli/ledger.ts +313 -0
- package/src/cli/server.ts +195 -0
- package/src/hooks/lib/agent.ts +63 -7
- package/src/hooks/lib/paths.ts +2 -0
- package/src/tools/agent/project.ts +28 -3
- package/src/tools/ledger/query.ts +318 -0
- package/src/tools/ledger/server.ts +111 -0
- package/src/tools/ledger/view.ts +130 -0
|
@@ -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
|
+
}
|