faberun 0.20.0 → 0.22.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/src/run/usage.mjs CHANGED
@@ -341,3 +341,268 @@ function listNodeSnapshotFiles(runDir) {
341
341
  return [];
342
342
  }
343
343
  }
344
+
345
+ // --- R5 expense pool -------------------------------------------------------
346
+ //
347
+ // The Campaign Brief's cost and duration estimate may only use this target
348
+ // project's durable completed execution nodes. Collection is deliberately
349
+ // narrow: a node enters the pool only when its persisted snapshot says `done`,
350
+ // its run is an execution (not planning/discovery) contract, its completion
351
+ // falls inside the recorded window, and the node carries the worker evidence
352
+ // the comparability key needs. Nothing here estimates a missing value; a field
353
+ // without recorded evidence stays null so the estimate can say
354
+ // `insufficient data` instead of inventing a zero.
355
+
356
+ /** @typedef {{runtimeId: string|null, model: string|null, costUsd: number|null, costProvenance: string|null}} CompletedExecutionRole */
357
+ /**
358
+ * One completed execution node, with the worker (and optional judge) role
359
+ * evidence the estimate compares against a planned assignment. `costUsd` is
360
+ * non-null only when every invocation for that role is priced; `durationMs` is
361
+ * non-null only when both the node's actual elapsed time and its verification
362
+ * elapsed time are recorded. `timeoutSec` is never read: it is a ceiling, not a
363
+ * measurement.
364
+ *
365
+ * @typedef {object} CompletedExecutionNode
366
+ * @property {string} runId
367
+ * @property {string} nodeId
368
+ * @property {string} taskKind
369
+ * @property {string|null} completedAt
370
+ * @property {number|null} nodeElapsedMs
371
+ * @property {number|null} verificationElapsedMs
372
+ * @property {number|null} durationMs
373
+ * @property {CompletedExecutionRole} worker
374
+ * @property {CompletedExecutionRole|null} judge
375
+ */
376
+ /** @typedef {{runId: string|null, reason: string}} UnreadableRun */
377
+ /**
378
+ * @typedef {object} CompletedExecutionPool
379
+ * @property {CompletedExecutionNode[]} nodes
380
+ * @property {string[]} sourceRuns
381
+ * @property {UnreadableRun[]} unreadable
382
+ * @property {number} scannedRuns
383
+ * @property {boolean} readable
384
+ */
385
+
386
+ /**
387
+ * Collect this project's durable completed execution nodes for the 90 days
388
+ * before `cutoff`. Planning/discovery runs and incomplete nodes are excluded;
389
+ * a run whose contract or node directory cannot be read is named in
390
+ * `unreadable` rather than silently dropped, so the estimate can report
391
+ * `insufficient data` with a reason. Cost evidence comes from the run's
392
+ * `usage.jsonl` records when present, falling back to the persisted invocation
393
+ * records, and is admitted only when priced.
394
+ *
395
+ * @param {{runsRoot: string, cutoff: string, windowDays?: number}} options
396
+ * @returns {CompletedExecutionPool}
397
+ */
398
+ export function collectCompletedExecutionNodes(options) {
399
+ const runsRoot = typeof options?.runsRoot === "string" ? options.runsRoot : "";
400
+ const cutoff = typeof options?.cutoff === "string" ? options.cutoff : "";
401
+ const cutoffMs = Date.parse(cutoff);
402
+ const windowDays = typeof options?.windowDays === "number" && Number.isFinite(options.windowDays) && options.windowDays > 0
403
+ ? options.windowDays
404
+ : 90;
405
+ if (!runsRoot) {
406
+ return { nodes: [], sourceRuns: [], unreadable: [{ runId: null, reason: "no runs root was provided" }], scannedRuns: 0, readable: false };
407
+ }
408
+ if (!Number.isFinite(cutoffMs)) {
409
+ return { nodes: [], sourceRuns: [], unreadable: [{ runId: null, reason: `usage cutoff ${cutoff || "(missing)"} is not a valid date` }], scannedRuns: 0, readable: false };
410
+ }
411
+ let runIds;
412
+ try {
413
+ runIds = readdirSync(runsRoot, { withFileTypes: true })
414
+ .filter((entry) => entry.isDirectory())
415
+ .map((entry) => entry.name);
416
+ } catch (error) {
417
+ return { nodes: [], sourceRuns: [], unreadable: [{ runId: null, reason: `runs root ${runsRoot} is unreadable: ${errorMessage(error)}` }], scannedRuns: 0, readable: false };
418
+ }
419
+ const windowStartMs = cutoffMs - windowDays * 24 * 60 * 60 * 1000;
420
+ /** @type {CompletedExecutionNode[]} */
421
+ const nodes = [];
422
+ /** @type {UnreadableRun[]} */
423
+ const unreadable = [];
424
+ const sourceRuns = new Set();
425
+ for (const runId of runIds) {
426
+ const runDir = join(runsRoot, runId);
427
+ // The runs root also holds sibling trees (`campaigns/`, `worktrees/`).
428
+ // Only a directory carrying run metadata or node snapshots is a run; a
429
+ // run-shaped directory whose contract is gone is no longer silently
430
+ // skipped, it is named unreadable.
431
+ const isRun = existsSync(join(runDir, "run.json")) || existsSync(join(runDir, "bootstrap.json")) || existsSync(join(runDir, "nodes"));
432
+ if (!isRun) continue;
433
+ let contract;
434
+ try {
435
+ contract = JSON.parse(readFileSync(join(runDir, "contract.json"), "utf8"));
436
+ } catch (error) {
437
+ unreadable.push({ runId, reason: `contract.json is unreadable: ${errorMessage(error)}` });
438
+ continue;
439
+ }
440
+ if (!contract || typeof contract !== "object" || Array.isArray(contract)) {
441
+ unreadable.push({ runId, reason: "contract.json is not a JSON object" });
442
+ continue;
443
+ }
444
+ const contractNodes = /** @type {any[]} */ (Array.isArray(contract.nodes) ? contract.nodes : []);
445
+ const contractById = new Map(contractNodes.map((node) => [String(node?.id ?? ""), node]));
446
+ if (contractNodes.length > 0 && contractNodes.every((node) => node?.taskPacket?.mode === "discovery")) continue;
447
+ /** @type {Map<string, Record<string, unknown>[]>} */
448
+ let usageByNode;
449
+ try {
450
+ usageByNode = readUsageByNode(runDir);
451
+ } catch {
452
+ usageByNode = new Map();
453
+ }
454
+ let snapshotNames;
455
+ try {
456
+ snapshotNames = readdirSync(join(runDir, "nodes")).filter((name) => name.endsWith(".json"));
457
+ } catch (error) {
458
+ unreadable.push({ runId, reason: `nodes directory is unreadable: ${errorMessage(error)}` });
459
+ continue;
460
+ }
461
+ for (const name of snapshotNames) {
462
+ let snapshot;
463
+ try {
464
+ snapshot = JSON.parse(readFileSync(join(runDir, "nodes", name), "utf8"));
465
+ } catch {
466
+ // A torn snapshot contributes nothing; a measurement never fails
467
+ // because one run was mid-write.
468
+ continue;
469
+ }
470
+ if (!snapshot || snapshot.status !== "done") continue;
471
+ const nodeId = String(snapshot.id ?? "");
472
+ const contractNode = contractById.get(nodeId);
473
+ if (contractNode?.taskPacket?.mode === "discovery") continue;
474
+ const taskKind = typeof snapshot.type === "string" ? snapshot.type : "";
475
+ if (!taskKind) continue;
476
+ const completedAt = typeof snapshot.updatedAt === "string" ? snapshot.updatedAt : null;
477
+ const completedMs = completedAt ? Date.parse(completedAt) : NaN;
478
+ if (!Number.isFinite(completedMs) || completedMs > cutoffMs || completedMs < windowStartMs) continue;
479
+ const invocations = Array.isArray(snapshot.invocations) ? /** @type {Record<string, unknown>[]} */ (snapshot.invocations) : [];
480
+ const usageRecords = usageByNode.get(nodeId) ?? [];
481
+ const worker = roleEvidence("worker", usageRecords, invocations, contract, contractNode);
482
+ if (worker === null) continue;
483
+ const judge = roleEvidence("judge", usageRecords, invocations, contract, contractNode);
484
+ const nodeElapsedMs = elapsedBetween(snapshot.startedAt, snapshot.updatedAt);
485
+ const verificationElapsedMs = verificationElapsed(snapshot.verification);
486
+ nodes.push({
487
+ runId,
488
+ nodeId,
489
+ taskKind,
490
+ completedAt,
491
+ nodeElapsedMs,
492
+ verificationElapsedMs,
493
+ durationMs: nodeElapsedMs !== null && verificationElapsedMs !== null ? nodeElapsedMs + verificationElapsedMs : null,
494
+ worker,
495
+ judge,
496
+ });
497
+ sourceRuns.add(runId);
498
+ }
499
+ }
500
+ return { nodes, sourceRuns: [...sourceRuns], unreadable, scannedRuns: runIds.length, readable: true };
501
+ }
502
+
503
+ /**
504
+ * @param {string} runDir
505
+ * @returns {Map<string, Record<string, unknown>[]>}
506
+ */
507
+ function readUsageByNode(runDir) {
508
+ /** @type {Map<string, Record<string, unknown>[]>} */
509
+ const byNode = new Map();
510
+ const path = join(runDir, USAGE_LOG_NAME);
511
+ if (!existsSync(path)) return byNode;
512
+ for (const line of readFileSync(path, "utf8").split("\n")) {
513
+ if (!line.trim()) continue;
514
+ let record;
515
+ try {
516
+ record = JSON.parse(line);
517
+ } catch {
518
+ continue;
519
+ }
520
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
521
+ const nodeId = typeof record.nodeId === "string" ? record.nodeId : null;
522
+ if (nodeId === null) continue;
523
+ const list = byNode.get(nodeId) ?? [];
524
+ list.push(record);
525
+ byNode.set(nodeId, list);
526
+ }
527
+ return byNode;
528
+ }
529
+
530
+ /**
531
+ * The role evidence for one completed node. `usage.jsonl` records are the
532
+ * priced source; persisted snapshot invocations stand in only when the ledger
533
+ * has no record for the node, so provenance is still the recorded value and
534
+ * never a recomputation. A role whose invocations are not all priced has no
535
+ * cost evidence (null), rather than a partial sum.
536
+ *
537
+ * @param {"worker"|"judge"} role
538
+ * @param {Record<string, unknown>[]} usageRecords
539
+ * @param {Record<string, unknown>[]} invocations
540
+ * @param {Record<string, any>} contract
541
+ * @param {Record<string, any>|undefined} contractNode
542
+ * @returns {CompletedExecutionRole|null}
543
+ */
544
+ function roleEvidence(role, usageRecords, invocations, contract, contractNode) {
545
+ const fromUsage = usageRecords.filter((record) => record?.role === role);
546
+ const source = fromUsage.length > 0
547
+ ? fromUsage
548
+ : invocations.filter((invocation) => invocation?.role === role || invocation?.phase === role);
549
+ if (source.length === 0) return null;
550
+ const last = source[source.length - 1];
551
+ const defaultRuntime = role === "worker" ? contract.runtimeDefaults?.worker : contract.runtimeDefaults?.judge;
552
+ const gateRuntime = contractNode?.gate && typeof contractNode.gate === "object" ? contractNode.gate.runtime : null;
553
+ const declaredRuntime = role === "worker" ? contractNode?.runtime : gateRuntime;
554
+ const runtimeId = typeof last.runtimeId === "string" && last.runtimeId
555
+ ? last.runtimeId
556
+ : typeof declaredRuntime === "string" && declaredRuntime
557
+ ? declaredRuntime
558
+ : typeof defaultRuntime === "string" && defaultRuntime ? defaultRuntime : null;
559
+ const runtimes = contract.runtimes && typeof contract.runtimes === "object" && !Array.isArray(contract.runtimes)
560
+ ? /** @type {Record<string, any>} */ (contract.runtimes)
561
+ : {};
562
+ const declaredModel = runtimeId !== null && runtimes[runtimeId] && typeof runtimes[runtimeId].model === "string" ? runtimes[runtimeId].model : null;
563
+ const model = typeof last.model === "string" && last.model ? last.model : declaredModel;
564
+ const priced = source.every((record) => typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costProvenance === "priced");
565
+ const costUsd = priced ? source.reduce((total, record) => total + /** @type {number} */ (record.costUsd), 0) : null;
566
+ return { runtimeId, model, costUsd, costProvenance: costUsd === null ? null : "priced" };
567
+ }
568
+
569
+ /**
570
+ * The elapsed time between two ISO timestamps, or null when either is missing
571
+ * or the pair is not ordered.
572
+ *
573
+ * @param {unknown} start
574
+ * @param {unknown} end
575
+ * @returns {number|null}
576
+ */
577
+ function elapsedBetween(start, end) {
578
+ const startMs = typeof start === "string" ? Date.parse(start) : NaN;
579
+ const endMs = typeof end === "string" ? Date.parse(end) : NaN;
580
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null;
581
+ return endMs - startMs;
582
+ }
583
+
584
+ /**
585
+ * The recorded verification elapsed time: the sum of each attempt's reported
586
+ * `durationMs`, or its start/completed pair when the duration is absent. A
587
+ * verification object with no `attempts` array is not recorded and returns
588
+ * null; an empty attempts array is a recorded zero.
589
+ *
590
+ * @param {unknown} verification
591
+ * @returns {number|null}
592
+ */
593
+ function verificationElapsed(verification) {
594
+ if (!verification || typeof verification !== "object" || Array.isArray(verification)) return null;
595
+ const record = /** @type {Record<string, unknown>} */ (verification);
596
+ if (!Array.isArray(record.attempts)) return null;
597
+ let total = 0;
598
+ for (const attempt of record.attempts) {
599
+ if (!attempt || typeof attempt !== "object") return null;
600
+ const entry = /** @type {Record<string, unknown>} */ (attempt);
601
+ const result = entry.result && typeof entry.result === "object" ? /** @type {Record<string, unknown>} */ (entry.result) : null;
602
+ const duration = result && typeof result.durationMs === "number" && Number.isFinite(result.durationMs) ? result.durationMs : null;
603
+ const measured = duration !== null ? duration : elapsedBetween(entry.startedAt, entry.completedAt);
604
+ if (measured === null) return null;
605
+ total += measured;
606
+ }
607
+ return total;
608
+ }
@@ -0,0 +1,401 @@
1
+ /**
2
+ * The R8 loopback browser surface: a separate, minimal HTTP server for one
3
+ * frozen phase plan's already-generated Campaign Brief HTML. It is not the
4
+ * dashboard (`src/web/server.mjs`): it has no bearer token, no API, no write
5
+ * route and no run state. The CLI parses argv and calls `serveCampaignBrief`;
6
+ * this module owns every bit of verification and HTTP behavior.
7
+ *
8
+ * Startup is the refusal gate. Before a socket exists, `verifyPinnedBrief`
9
+ * reads and holds the phase plan's exact `plan.json` and pinned `spec.md`
10
+ * bytes, checking the plan against its independent `plan.json.sha256` sidecar,
11
+ * the contract against `plan.contractDigest`, and the spec against
12
+ * `plan.spec.digest`. A missing or changed source file, or a missing
13
+ * `campaign-brief.md.html` copy, throws a named `CampaignBriefServeError` and
14
+ * no URL is ever printed. The verified bytes are the bytes the read-only
15
+ * `/plan.json` and `/spec.md` drill-downs serve, so the brief's review facts do
16
+ * not depend on the live files after startup.
17
+ *
18
+ * The bound address is always loopback. The HTML copy itself is a
19
+ * self-contained file that stays readable from disk without this server.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { readFileSync } from "node:fs";
23
+ import http from "node:http";
24
+ import net from "node:net";
25
+ import { isAbsolute, join, resolve as resolvePath } from "node:path";
26
+ import { resolveCampaign } from "../campaign/index.mjs";
27
+ import { contractDigest } from "../contract/index.mjs";
28
+ import { runsRoot } from "../run/paths.mjs";
29
+ import { errorCode } from "../util.mjs";
30
+
31
+ /** @typedef {Record<string, any>} AnyRecord */
32
+
33
+ /**
34
+ * @typedef {object} CampaignBriefServeOptions
35
+ * @property {string} campaignId
36
+ * @property {unknown} phase
37
+ * @property {string} [cwd] repository whose runs root holds the campaign.
38
+ * @property {string} [runsDir] explicit runs root; defaults to `runsRoot(cwd)`.
39
+ * @property {string} [host] loopback bind host; defaults to `127.0.0.1`.
40
+ * @property {number} [port] port to bind; defaults to `0` (an ephemeral port).
41
+ * @property {(line: string) => void} [stdout] where the one success URL line goes.
42
+ * @property {boolean} [shutdownSignals] install SIGINT/SIGTERM close handlers; default true.
43
+ */
44
+
45
+ /**
46
+ * @typedef {object} CampaignBriefServeHandle
47
+ * @property {import("node:http").Server} server the listening server.
48
+ * @property {string} url the browser URL, printed once on success.
49
+ * @property {number} port the bound port.
50
+ * @property {string} host the bound loopback address.
51
+ * @property {string} campaignId
52
+ * @property {string} phase
53
+ * @property {string} planPath absolute path of the verified frozen plan.
54
+ * @property {string} specPath absolute path of the verified structured spec.
55
+ * @property {string} htmlPath absolute path of the served HTML copy.
56
+ * @property {() => Promise<void>} close stop listening and release the port.
57
+ */
58
+
59
+ /** The exact read-only drill-down routes and the one HTML entry route. */
60
+ const HTML_ROUTES = new Set(["/", "/campaign-brief.md.html"]);
61
+ const PLAN_ROUTE = "/plan.json";
62
+ const SPEC_ROUTE = "/spec.md";
63
+
64
+ /**
65
+ * A named refusal to serve. `code` distinguishes the exact startup failure so
66
+ * a caller can tell a stale sidecar from a changed spec or a missing HTML copy.
67
+ */
68
+ export class CampaignBriefServeError extends Error {
69
+ /** @param {string} code @param {string} message */
70
+ constructor(code, message) {
71
+ super(message);
72
+ this.name = "CampaignBriefServeError";
73
+ this.code = code;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Verify one frozen phase plan and start its loopback server. Reads and holds
79
+ * the pinned plan and spec bytes, checks the contract digest and the HTML
80
+ * copy, then binds `host:port` (loopback only). On success one line naming the
81
+ * URL is written to `stdout`; on any verification failure a
82
+ * `CampaignBriefServeError` is thrown before a socket opens and nothing is
83
+ * printed.
84
+ *
85
+ * @param {CampaignBriefServeOptions} options
86
+ * @returns {Promise<CampaignBriefServeHandle>}
87
+ */
88
+ export async function serveCampaignBrief(options) {
89
+ const stdout = options.stdout ?? ((line) => process.stdout.write(`${line}\n`));
90
+ const verified = verifyPinnedBrief(options);
91
+ const host = requireLoopbackHost(options.host ?? "127.0.0.1");
92
+ const requestedPort = options.port ?? 0;
93
+ const server = http.createServer((request, response) => {
94
+ handleRequest(request, response, verified);
95
+ });
96
+ await listen(server, host, requestedPort);
97
+ const address = server.address();
98
+ const port = typeof address === "object" && address !== null ? address.port : requestedPort;
99
+ const url = `http://${hostForUrl(host)}:${port}/`;
100
+ if (options.shutdownSignals !== false) installShutdown(server);
101
+ stdout(`[brief] serving ${verified.campaignId} · ${verified.phase} · ${url}`);
102
+ return {
103
+ server,
104
+ url,
105
+ port,
106
+ host,
107
+ campaignId: verified.campaignId,
108
+ phase: verified.phase,
109
+ planPath: verified.planPath,
110
+ specPath: verified.specPath,
111
+ htmlPath: verified.htmlPath,
112
+ close: () => closeServer(server),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * The refusal gate. The plan is checked against its independent sidecar first,
118
+ * then parsed for its spec and contract identity, then the contract and spec
119
+ * digests are verified, then the HTML copy must exist. The exact bytes read
120
+ * here are returned and served, never re-read.
121
+ *
122
+ * @param {CampaignBriefServeOptions} options
123
+ * @returns {{campaignId: string, phase: string, planPath: string, specPath: string, htmlPath: string, planBytes: Buffer, specBytes: Buffer, htmlBytes: Buffer}}
124
+ */
125
+ function verifyPinnedBrief(options) {
126
+ const campaignId = requireString(options.campaignId, "campaignId");
127
+ const phase = requirePhase(options.phase);
128
+ const cwd = resolvePath(options.cwd ?? ".");
129
+ const runsDir = options.runsDir ?? runsRoot(cwd);
130
+ const { path: campaignPath } = resolveCampaign(runsDir, campaignId);
131
+ const planDir = join(campaignPath, "plans", phase);
132
+ const planPath = join(planDir, "plan.json");
133
+ const contractPath = join(planDir, "contract.json");
134
+ const htmlPath = join(planDir, "campaign-brief.md.html");
135
+
136
+ const planBytes = readBytes(planPath, "brief_plan_missing", `frozen plan ${planPath}`);
137
+ const planDigest = sha256Hex(planBytes);
138
+ const sidecarText = readText(`${planPath}.sha256`, "brief_plan_missing", `plan digest sidecar for ${planPath}`);
139
+ if (planDigest !== sidecarText.trim()) {
140
+ throw new CampaignBriefServeError(
141
+ "brief_plan_changed",
142
+ `frozen plan ${planPath} does not match its sidecar: the file is ${planDigest}, the sidecar records ${sidecarText.trim() || "(empty)"}`,
143
+ );
144
+ }
145
+
146
+ const plan = parseJson(planBytes.toString("utf8"), "brief_plan_invalid", `frozen plan ${planPath}`);
147
+ if (plan.formatVersion !== 1) {
148
+ throw new CampaignBriefServeError("brief_plan_invalid", `frozen plan ${planPath} has no formatVersion 1; refreeze it before serving a brief`);
149
+ }
150
+ const specIdentity = plan.spec;
151
+ if (!specIdentity || typeof specIdentity.path !== "string" || typeof specIdentity.digest !== "string") {
152
+ throw new CampaignBriefServeError("brief_plan_invalid", `frozen plan ${planPath} records no spec path and digest; refreeze it before serving a brief`);
153
+ }
154
+ const expectedContractDigest = typeof plan.contractDigest === "string" ? plan.contractDigest : "";
155
+ if (!expectedContractDigest) {
156
+ throw new CampaignBriefServeError("brief_plan_invalid", `frozen plan ${planPath} records no contractDigest; refreeze it before serving a brief`);
157
+ }
158
+
159
+ const contractBytes = readBytes(contractPath, "brief_contract_missing", `contract ${contractPath}`);
160
+ const contract = parseJson(contractBytes.toString("utf8"), "brief_contract_invalid", `contract ${contractPath}`);
161
+ const rawContractDigest = contractDigest(contract);
162
+ if (rawContractDigest !== expectedContractDigest) {
163
+ throw new CampaignBriefServeError(
164
+ "brief_contract_changed",
165
+ `contract ${contractPath} does not match plan.contractDigest: computed ${rawContractDigest}, plan records ${expectedContractDigest}`,
166
+ );
167
+ }
168
+
169
+ const specPath = isAbsolute(specIdentity.path) ? specIdentity.path : resolvePath(cwd, specIdentity.path);
170
+ const specBytes = readBytes(specPath, "brief_spec_missing", `structured spec ${specPath}`);
171
+ if (sha256Hex(specBytes) !== specIdentity.digest) {
172
+ throw new CampaignBriefServeError(
173
+ "brief_spec_changed",
174
+ `structured spec ${specPath} does not match plan.spec.digest: the bytes changed since the plan froze`,
175
+ );
176
+ }
177
+
178
+ const htmlBytes = readBytes(htmlPath, "brief_html_missing", `rendered brief ${htmlPath}`);
179
+
180
+ return { campaignId, phase, planPath, specPath, htmlPath, planBytes, specBytes, htmlBytes };
181
+ }
182
+
183
+ /**
184
+ * The whole HTTP surface: GET (and HEAD) only, three exact routes, everything
185
+ * else 404. No path is ever joined to a filesystem path, so no directory
186
+ * listing or arbitrary file can be requested, and any other method is refused
187
+ * as a write attempt.
188
+ *
189
+ * @param {import("node:http").IncomingMessage} request
190
+ * @param {import("node:http").ServerResponse} response
191
+ * @param {{campaignId: string, phase: string, planPath: string, specPath: string, htmlPath: string, planBytes: Buffer, specBytes: Buffer, htmlBytes: Buffer}} context
192
+ */
193
+ function handleRequest(request, response, context) {
194
+ try {
195
+ const method = request.method ?? "GET";
196
+ if (method !== "GET" && method !== "HEAD") return sendText(response, 405, "method not allowed\n", { allow: "GET, HEAD" }, false);
197
+ const headOnly = method === "HEAD";
198
+ const url = new URL(request.url ?? "/", "http://localhost");
199
+ if (HTML_ROUTES.has(url.pathname)) return sendBytes(response, "text/html; charset=utf-8", context.htmlBytes, headOnly);
200
+ if (url.pathname === PLAN_ROUTE) return sendBytes(response, "application/json; charset=utf-8", context.planBytes, headOnly);
201
+ if (url.pathname === SPEC_ROUTE) return sendBytes(response, "text/markdown; charset=utf-8", context.specBytes, headOnly);
202
+ return sendText(response, 404, "not found\n", {}, headOnly);
203
+ } catch {
204
+ if (response.headersSent) {
205
+ response.destroy();
206
+ return;
207
+ }
208
+ sendText(response, 500, "server error\n", {}, false);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * @param {import("node:http").ServerResponse} response
214
+ * @param {string} type
215
+ * @param {Buffer} bytes
216
+ * @param {boolean} headOnly
217
+ */
218
+ function sendBytes(response, type, bytes, headOnly) {
219
+ response.writeHead(200, {
220
+ "content-type": type,
221
+ "content-length": String(bytes.length),
222
+ "cache-control": "no-store",
223
+ "x-content-type-options": "nosniff",
224
+ });
225
+ response.end(headOnly ? undefined : bytes);
226
+ }
227
+
228
+ /**
229
+ * @param {import("node:http").ServerResponse} response
230
+ * @param {number} status
231
+ * @param {string} body
232
+ * @param {import("node:http").OutgoingHttpHeaders} [extra]
233
+ * @param {boolean} [headOnly]
234
+ */
235
+ function sendText(response, status, body, extra = {}, headOnly = false) {
236
+ response.writeHead(status, {
237
+ "content-type": "text/plain; charset=utf-8",
238
+ "cache-control": "no-store",
239
+ ...extra,
240
+ });
241
+ response.end(headOnly ? undefined : body);
242
+ }
243
+
244
+ /**
245
+ * `localhost` resolves to the IPv4 loopback; an IPv4-mapped IPv6 literal is
246
+ * judged by the IPv4 address it carries. Everything else, including the
247
+ * wildcard and every RFC1918 address, is refused before a socket exists.
248
+ *
249
+ * @param {string} host
250
+ * @returns {string}
251
+ */
252
+ function requireLoopbackHost(host) {
253
+ const normalized = host === "localhost" ? "127.0.0.1" : host;
254
+ const plain = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/iu.exec(normalized)?.[1] ?? normalized;
255
+ const isLoopback = (net.isIPv4(plain) && plain.startsWith("127.")) || (net.isIPv6(plain) && plain === "::1");
256
+ if (!isLoopback) {
257
+ throw new CampaignBriefServeError(
258
+ "brief_bind_not_loopback",
259
+ `refusing to bind ${host}: the brief server binds only to loopback (127.0.0.1 or ::1)`,
260
+ );
261
+ }
262
+ return normalized;
263
+ }
264
+
265
+ /** @param {string} host @returns {string} */
266
+ function hostForUrl(host) {
267
+ return host.includes(":") ? `[${host}]` : host;
268
+ }
269
+
270
+ /**
271
+ * @param {import("node:http").Server} server
272
+ * @param {string} host
273
+ * @param {number} port
274
+ * @returns {Promise<void>}
275
+ */
276
+ function listen(server, host, port) {
277
+ return new Promise((resolveListen, rejectListen) => {
278
+ const onError = (/** @type {Error} */ error) => rejectListen(error);
279
+ server.once("error", onError);
280
+ server.listen(port, host, () => {
281
+ server.removeListener("error", onError);
282
+ resolveListen();
283
+ });
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Stop accepting connections and release the port. Idle keep-alive sockets are
289
+ * destroyed so the close settles promptly.
290
+ *
291
+ * @param {import("node:http").Server} server
292
+ * @returns {Promise<void>}
293
+ */
294
+ function closeServer(server) {
295
+ return new Promise((resolveClose) => {
296
+ if (!server.listening) {
297
+ resolveClose();
298
+ return;
299
+ }
300
+ server.close(() => resolveClose());
301
+ server.closeAllConnections?.();
302
+ });
303
+ }
304
+
305
+ /**
306
+ * The CLI's server runs until the operator stops it. The handlers close the
307
+ * socket and exit; the `close` listener removes them so a stop that already
308
+ * happened does not leave a live signal listener behind.
309
+ *
310
+ * @param {import("node:http").Server} server
311
+ */
312
+ function installShutdown(server) {
313
+ const shutdown = () => {
314
+ closeServer(server).then(() => process.exit(0));
315
+ };
316
+ const remove = () => {
317
+ process.removeListener("SIGINT", shutdown);
318
+ process.removeListener("SIGTERM", shutdown);
319
+ };
320
+ process.once("SIGINT", shutdown);
321
+ process.once("SIGTERM", shutdown);
322
+ server.once("close", remove);
323
+ }
324
+
325
+ /**
326
+ * @param {unknown} value
327
+ * @param {string} label
328
+ * @returns {string}
329
+ */
330
+ function requireString(value, label) {
331
+ if (typeof value !== "string" || !value.trim()) {
332
+ throw new CampaignBriefServeError("brief_invalid_request", `campaign brief serve requires ${label}`);
333
+ }
334
+ return value;
335
+ }
336
+
337
+ /**
338
+ * `--phase` names one directory under `<campaignDir>/plans/`; anything with a
339
+ * path separator or a dot segment would escape it.
340
+ *
341
+ * @param {unknown} value
342
+ * @returns {string}
343
+ */
344
+ function requirePhase(value) {
345
+ if (typeof value !== "string" || !value.trim()) {
346
+ throw new CampaignBriefServeError("brief_phase_required", "campaign brief serve requires --phase <phase>");
347
+ }
348
+ if (value === "." || value === ".." || value.includes("/") || value.includes("\\")) {
349
+ throw new CampaignBriefServeError("brief_phase_invalid", "--phase must be a single path segment");
350
+ }
351
+ return value;
352
+ }
353
+
354
+ /**
355
+ * @param {string} path
356
+ * @param {string} missingCode
357
+ * @param {string} label
358
+ * @returns {Buffer}
359
+ */
360
+ function readBytes(path, missingCode, label) {
361
+ try {
362
+ return readFileSync(path);
363
+ } catch (error) {
364
+ if (errorCode(error) === "ENOENT") throw new CampaignBriefServeError(missingCode, `${label} is missing`);
365
+ throw error;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * @param {string} path
371
+ * @param {string} missingCode
372
+ * @param {string} label
373
+ * @returns {string}
374
+ */
375
+ function readText(path, missingCode, label) {
376
+ return readBytes(path, missingCode, label).toString("utf8");
377
+ }
378
+
379
+ /**
380
+ * @param {string} text
381
+ * @param {string} invalidCode
382
+ * @param {string} label
383
+ * @returns {AnyRecord}
384
+ */
385
+ function parseJson(text, invalidCode, label) {
386
+ let value;
387
+ try {
388
+ value = JSON.parse(text);
389
+ } catch {
390
+ throw new CampaignBriefServeError(invalidCode, `${label} is not valid JSON`);
391
+ }
392
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
393
+ throw new CampaignBriefServeError(invalidCode, `${label} must be a JSON object`);
394
+ }
395
+ return /** @type {AnyRecord} */ (value);
396
+ }
397
+
398
+ /** @param {Buffer} bytes @returns {string} */
399
+ function sha256Hex(bytes) {
400
+ return createHash("sha256").update(bytes).digest("hex");
401
+ }