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/package.json +2 -1
- package/skills/faberun/references/operations.md +7 -7
- package/src/campaign/brief-cli.mjs +174 -0
- package/src/campaign/brief-text.mjs +96 -0
- package/src/campaign/campaign-brief.mjs +772 -0
- package/src/campaign/chain.mjs +30 -1
- package/src/campaign/projection.mjs +34 -0
- package/src/cli/campaign.mjs +22 -2
- package/src/contract/index.mjs +5 -3
- package/src/contract/runtime.mjs +22 -1
- package/src/contract/scope-findings.mjs +12 -0
- package/src/contract/snapshot.mjs +16 -5
- package/src/engine/dispatch.mjs +6 -4
- package/src/engine/process.mjs +1 -0
- package/src/engine/result-file.mjs +13 -1
- package/src/engine/settle.mjs +1 -0
- package/src/engine/verify.mjs +37 -2
- package/src/harnesses/codex/index.mjs +1 -0
- package/src/harnesses/index.mjs +18 -1
- package/src/plan/freeze.mjs +240 -35
- package/src/plan/pipeline-shape.mjs +100 -0
- package/src/plan/pipeline.mjs +68 -116
- package/src/plan/template.mjs +88 -25
- package/src/repo/declared-paths.mjs +16 -0
- package/src/repo/workspace.mjs +42 -3
- package/src/repo/worktree.mjs +34 -2
- package/src/report/campaign-brief-estimate.mjs +450 -0
- package/src/report/campaign-brief-html.mjs +439 -0
- package/src/report/campaign-brief.mjs +409 -0
- package/src/report/final.mjs +5 -4
- package/src/report/mdhtml-release.json +30 -0
- package/src/report/render.mjs +4 -3
- package/src/run/usage.mjs +265 -0
- package/src/web/campaign-brief-server.mjs +401 -0
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
|
+
}
|