faberun 0.21.0 → 0.23.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,22 +45,22 @@ attempt's sealed sha, the same continuation rule as any other retry.
45
45
  One controller drives a run, holding `<run-dir>/controller.lock`: `{pid,
46
46
  processStartToken, startedAt, hostname}`. Acquisition is an exclusive create
47
47
  with no TTL. A contender treats the lock as stale only once it can prove the
48
- holder dead — the pid is gone, or its process start token no longer matches
49
- (pid recycled); anything less is `controller_active` and it exits untouched.
50
- Takeover renames the lock aside, re-checks the captured record is stale, then
51
- installs its own; a capture that turns out live is handed back. Worker/judge/
48
+ holder dead — pid gone, or its start token no longer matches (pid recycled);
49
+ otherwise it exits `controller_active`, untouched. Takeover renames the lock
50
+ aside and re-checks it is stale before installing its own. Worker/judge/
52
51
  verification children run detached in their own process group, so before
53
52
  dispatching new work `resume`'s recovery pass terminates (`SIGTERM` then
54
53
  `SIGKILL`, same as `cancel`) every invocation recorded for a `running` node —
55
54
  unless it is still inside its deadline, when it is adopted and its result read.
56
- `cancel <run-dir>` signals a live controller first, so its own takeover never
57
- waits on an expiry.
55
+ `cancel` signals a live controller first, so its takeover never waits.
58
56
 
59
57
  `supervise <run-dir> [--detach] [--interval <sec>]` is the watchdog above that.
60
58
  It holds no lock and writes no state: every interval (default 30s) it launches
61
59
  `resume --detach` when a node is unfinished and no controller is live, exits 0
62
60
  once all are terminal, and stops after three failed launches. An empty run
63
- directory is never resumed — it has not proved it needs to be.
61
+ directory is never resumed. A detached controller outlives its launcher,
62
+ not the session scope (cgroup) holding it: run long work under `tmux`,
63
+ `systemd-run` or the seat.
64
64
 
65
65
  ## Runtime discovery
66
66
 
@@ -42,6 +42,7 @@ import { pidAlive, processStartToken } from "../run/lock.mjs";
42
42
  import { delay, errorCode, errorMessage } from "../util.mjs";
43
43
  import { writeJsonAtomic } from "../run/store.mjs";
44
44
  import { runDirectory } from "../run/paths.mjs";
45
+ import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
45
46
  import { gitArguments, killTarget } from "../host/platform.mjs";
46
47
 
47
48
  /** @typedef {import("../contract/index.mjs").ControllerIdentity} ControllerIdentity */
@@ -561,12 +562,14 @@ export async function driveCampaignChain(campaignPath, options = {}) {
561
562
  try {
562
563
  await launch(entry.path, { baseRef, controllerIdentity, runDir, contract });
563
564
  } catch (error) {
565
+ const stranded = strandedRunNote(runDir);
564
566
  return park({
565
567
  code: errorCode(error) ?? "launch_failed",
566
- message: errorMessage(error),
568
+ message: stranded ? `${errorMessage(error)}; ${stranded}` : errorMessage(error),
567
569
  contractPath: entry.path,
568
570
  contractId: id,
569
571
  runId: id,
572
+ ...(stranded ? { resume: `resume ${runDir}` } : {}),
570
573
  });
571
574
  }
572
575
  heartbeat.progress();
@@ -578,3 +581,29 @@ export async function driveCampaignChain(campaignPath, options = {}) {
578
581
  if (acquired) releaseCoordinatorLock(campaignPath, acquired.lock);
579
582
  }
580
583
  }
584
+
585
+ /**
586
+ * What a launch that died before readiness left on disk. RM-053, measured on
587
+ * `rec-audit-remediation`: the controller died with the launcher's cgroup,
588
+ * the park said only `detached bootstrap did not become ready`, and the run
589
+ * directory it left, every node pending, finished under a durable `resume`.
590
+ *
591
+ * @param {string} runDir
592
+ * @returns {string|null}
593
+ */
594
+ function strandedRunNote(runDir) {
595
+ if (!runDir || !existsSync(runDir)) return null;
596
+ /** @type {Map<string, number>} */
597
+ const counts = new Map();
598
+ for (const name of listNodeSnapshots(runDir)) {
599
+ let status = "unreadable";
600
+ try {
601
+ status = String(/** @type {{status?: unknown}} */ (readNodeSnapshot(runDir, name.replace(/\.json$/u, ""))).status ?? "unknown");
602
+ } catch {
603
+ // A snapshot caught mid-write is counted as unreadable, not dropped.
604
+ }
605
+ counts.set(status, (counts.get(status) ?? 0) + 1);
606
+ }
607
+ const nodes = [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([status, count]) => `${count} ${status}`).join(" · ");
608
+ return `run directory ${runDir} exists (nodes: ${nodes || "none written yet"}); \`faberun resume ${runDir}\` completes it, under tmux, systemd-run or faberun seat so it outlives this shell`;
609
+ }
@@ -1,5 +1,4 @@
1
1
  import {
2
- copyFileSync,
3
2
  existsSync,
4
3
  mkdirSync,
5
4
  readFileSync,
@@ -11,11 +10,14 @@ import { writeJsonAtomic } from "../run/store.mjs";
11
10
  import { projectIdForRunsDir, repositoryForRunsDir } from "../run/paths.mjs";
12
11
  import { requireId, requirePacketHash, requireString, requireTimestamp } from "../contract/assert.mjs";
13
12
  import { promoteRun } from "../repo/integrate.mjs";
14
- import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, JOURNAL_FILE, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
13
+ import { CAMPAIGN_FILE, GOAL_TEXT_BYTES, PROJECTION_FILE, campaignDir, campaignsDir } from "./layout.mjs";
15
14
  import { readCampaign } from "./record.mjs";
16
15
  import { appendJournal, normalizeText, readJournalForDedupe } from "./journal.mjs";
17
16
  import { readProjectionState } from "./projection.mjs";
18
17
  import { handoffFromState, materializeHandoff } from "./handoff.mjs";
18
+ import { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
19
+
20
+ export { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
19
21
 
20
22
  /** @typedef {Record<string, unknown>} JsonObject */
21
23
  /** @typedef {{path: string, digest: string}} CampaignContract */
@@ -23,7 +25,8 @@ import { handoffFromState, materializeHandoff } from "./handoff.mjs";
23
25
  /** @typedef {{code: string, message: string, at: string, contractPath?: string, contractId?: string, runId?: string, node?: string|null, status?: string|null, resume?: string}} CampaignAttention */
24
26
  /** @typedef {{runId: string, node: string, passed: boolean|null, verdict: string|null}} RequirementNodeEvidence */
25
27
  /** @typedef {{requirementId: string, status: "covered"|"open", nodes: RequirementNodeEvidence[]}} RequirementClosure */
26
- /** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, requirements?: RequirementClosure[], createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
28
+ /** @typedef {{oldPath: string, newPath: string, runIds: string[], at: string}} ContractReplacement */
29
+ /** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], replacements?: ContractReplacement[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, requirements?: RequirementClosure[], createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
27
30
  /** @typedef {{type: string, eventId: string, at: string, sessionId?: string, text?: string, tool?: string, transcript?: string|null, transcriptUnavailable?: boolean, format?: string|null, cursor?: string|null, decisionId?: string, supersedes?: string, runId?: string, questionId?: string, campaignId?: string, nodeId?: string|null, phase?: string, checkpointsDone?: number, checkpointsTotal?: number, runtime?: string|null, state?: string, lastProgressAt?: string, attention?: string|null}} JournalEntry */
28
31
  /** @typedef {{updatedAt: string|null, decisions: Record<string, JournalEntry>, questions: Record<string, JournalEntry>, constraints: JournalEntry[], intents: JournalEntry[], outcomes: JournalEntry[], sessions: JournalEntry[], next: JournalEntry|null, evicted: Record<string, number>}} Projection */
29
32
  /** @typedef {{cursor: number, byte: number, size: number, projection: Projection}} ProjectionRecord */
@@ -55,6 +58,7 @@ export function initializeCampaign(runsDir, { campaignId, goal, at = new Date().
55
58
  status: "active",
56
59
  linkedRunIds: [],
57
60
  contracts: validatedContracts,
61
+ replacements: [],
58
62
  landBranch: branch,
59
63
  promotions: [],
60
64
  createdAt: at,
@@ -126,7 +130,7 @@ export function resolveCampaign(runsDir, campaignId) {
126
130
  /**
127
131
  * @param {string} campaignPath
128
132
  * @param {{at?: string, eventId?: string}} options
129
- * @returns {{path: string, campaign: Campaign, ledgerFiles: string[]}}
133
+ * @returns {{path: string, campaign: Campaign, ledgerFiles: string[], ledgerSkipped: {runId: string, source: string}[]}}
130
134
  */
131
135
  export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
132
136
  requireTimestamp(at, "at");
@@ -140,8 +144,6 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
140
144
  if (unacknowledged.length) {
141
145
  throw new Error(`campaign ${campaign.id} has judge findings no note has answered: ${unacknowledged.join("; ")}. Read them with \`faberun findings <run-dir>\`, then name the node in a note (\`campaign note ${campaign.id} --kind outcome --run-id <run-id> --text "...<node>..."\`) before close`);
142
146
  }
143
- const repoRoot = campaignRepoRoot(campaignPath);
144
- const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
145
147
  // The closure travels on the record itself, computed in one deterministic
146
148
  // pass over the linked runs' own files before the close is written.
147
149
  const requirements = buildRequirementClosure(campaignPath, campaign);
@@ -149,7 +151,24 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
149
151
  const closed = { ...campaign, status: "closed", closedAt: at, updatedAt: at, requirements };
150
152
  writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
151
153
  appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
152
- return { path: campaignPath, campaign: closed, ledgerFiles };
154
+ // Preserve after the closed record exists: the ledger is the recomputable
155
+ // closed record, including requirements used by the north-star projector.
156
+ const ledger = preserveCampaignLedger(campaignPath, campaignRepoRoot(campaignPath));
157
+ return { path: campaignPath, campaign: closed, ledgerFiles: ledger.written, ledgerSkipped: ledger.skipped };
158
+ }
159
+
160
+ /**
161
+ * Complete the versioned evidence ledger of a closed campaign. Unlike close,
162
+ * this operation never updates the campaign record or journal: it only adds
163
+ * surviving run sources beneath the repository's ledger directory.
164
+ *
165
+ * @param {string} campaignPath
166
+ * @returns {import("./ledger.mjs").LedgerReledger & {ledgerDir: string}}
167
+ */
168
+ export function reledgerCampaign(campaignPath) {
169
+ const campaign = readCampaign(campaignPath);
170
+ if (campaign.status !== "closed") throw new Error(`campaign is not closed: ${campaign.id}`);
171
+ return reledgerCampaignLedger(campaignPath, campaignRepoRoot(campaignPath));
153
172
  }
154
173
 
155
174
  /**
@@ -290,46 +309,6 @@ function campaignRepoRoot(campaignPath) {
290
309
  return resolve(campaignPath, "..", "..", "..");
291
310
  }
292
311
 
293
- /**
294
- * Copy a campaign's journal, record and each linked run's usage into
295
- * `<repoRoot>/docs/campaigns/<id>/ledger/` so the comparative arm of the
296
- * planner has a session-side baseline even after `.runs/` (gitignored) is
297
- * pruned. Nothing in this tree redacts token counts, costs or operator notes
298
- * before this point, so the copy is verbatim; the pre-commit secret scan is
299
- * the guard against anything that should not land in git.
300
- *
301
- * Idempotent: re-running it (a second `close` on an already-closed campaign
302
- * cannot reach this, but a direct call can) overwrites the same destination
303
- * files rather than duplicating them. A linked run without a `usage.jsonl`
304
- * (never launched, or pruned) is skipped rather than thrown.
305
- *
306
- * @param {string} campaignPath
307
- * @param {string} repoRoot
308
- * @returns {string[]}
309
- */
310
- export function preserveCampaignLedger(campaignPath, repoRoot) {
311
- const campaign = readCampaign(campaignPath);
312
- const runsDir = resolve(campaignPath, "..", "..");
313
- const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
314
- mkdirSync(ledgerDir, { recursive: true });
315
- const written = [];
316
- for (const name of [JOURNAL_FILE, CAMPAIGN_FILE]) {
317
- const source = join(campaignPath, name);
318
- if (!existsSync(source)) continue;
319
- const destination = join(ledgerDir, name);
320
- copyFileSync(source, destination);
321
- written.push(destination);
322
- }
323
- for (const runId of campaign.linkedRunIds) {
324
- const source = join(runsDir, runId, "usage.jsonl");
325
- if (!existsSync(source)) continue;
326
- const destination = join(ledgerDir, `${runId}.usage.jsonl`);
327
- copyFileSync(source, destination);
328
- written.push(destination);
329
- }
330
- return written;
331
- }
332
-
333
312
  /**
334
313
  * @param {string} campaignPath
335
314
  * @param {string} runId
@@ -422,6 +401,7 @@ export function addContractToCampaign(campaignPath, contractPath, { at = new Dat
422
401
  : campaign.contracts.map((existing, index) => (index === existingIndex ? entry : existing));
423
402
  const updated = /** @type {Campaign} */ ({ ...campaign, contracts, updatedAt: at });
424
403
  writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
404
+ appendJournal(campaignPath, { type: "operator.command", eventId: randomUUID(), at, command: "campaign add-contract" });
425
405
  return { campaign: updated, added: true };
426
406
  }
427
407
 
@@ -458,13 +438,43 @@ export function replaceContractInCampaign(campaignPath, oldPath, newPath, { at =
458
438
  const contracts = campaign.contracts.map((entry, position) => (position === index ? replaced : entry));
459
439
  const attention = campaign.attention;
460
440
  const clearAttention = attention !== undefined && attention.contractPath === oldPath;
441
+ const replacementRunIds = replacedRunIds(campaignPath, campaign, oldPath, newPath, attention?.runId);
442
+ const replacement = { oldPath, newPath, runIds: replacementRunIds, at };
461
443
  /** @type {Campaign} */
462
- const updated = { ...campaign, contracts, updatedAt: at };
444
+ const updated = { ...campaign, contracts, replacements: [...(campaign.replacements ?? []), replacement], updatedAt: at };
463
445
  if (clearAttention) delete updated.attention;
464
446
  writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
465
447
  return { campaign: updated, replaced, clearedAttention: clearAttention ? /** @type {CampaignAttention} */ (attention) : null };
466
448
  }
467
449
 
450
+ /**
451
+ * Identify the run whose authored contract is being re-issued. The parked
452
+ * attention is the authoritative link when present; matching the old
453
+ * contract id also covers a replacement requested before the run was parked.
454
+ *
455
+ * @param {string} campaignPath
456
+ * @param {Campaign} campaign
457
+ * @param {string} oldPath
458
+ * @param {string} newPath
459
+ * @param {string|undefined} attentionRunId
460
+ * @returns {string[]}
461
+ */
462
+ function replacedRunIds(campaignPath, campaign, oldPath, newPath, attentionRunId) {
463
+ const ids = new Set();
464
+ if (attentionRunId !== undefined && campaign.linkedRunIds.includes(attentionRunId)) ids.add(attentionRunId);
465
+ const oldContract = readRunJson(oldPath);
466
+ const oldId = oldContract !== null && typeof oldContract.id === "string" ? oldContract.id : null;
467
+ if (oldId !== null && campaign.linkedRunIds.includes(oldId)) ids.add(oldId);
468
+ const runsDir = resolve(campaignPath, "..", "..");
469
+ for (const runId of campaign.linkedRunIds) {
470
+ const runContract = readRunJson(join(runsDir, runId, "contract.json"));
471
+ if (runContract !== null && oldId !== null && runContract.id === oldId) ids.add(runId);
472
+ }
473
+ const replacementContract = readRunJson(newPath);
474
+ const replacementId = replacementContract !== null && typeof replacementContract.id === "string" ? replacementContract.id : null;
475
+ return [...ids].filter((runId) => replacementId === null || runId !== replacementId).sort();
476
+ }
477
+
468
478
  /**
469
479
  * Persist one promotion in the campaign record. Idempotent by run id and the
470
480
  * sha it landed: a re-invocation after a crash between the branch move and
@@ -584,4 +594,3 @@ export function renderRunHandoff(runDir) {
584
594
  if (!existsSync(join(path, CAMPAIGN_FILE))) return null;
585
595
  return renderHandoff(path, runsDir);
586
596
  }
587
-
@@ -33,6 +33,7 @@ const JOURNAL_TYPES = new Set([
33
33
  "next",
34
34
  "open-question",
35
35
  "question.resolved",
36
+ "operator.command",
36
37
  "retrospective",
37
38
  "liveness",
38
39
  "seat.allowance",
@@ -63,6 +64,7 @@ const ENTRY_SHAPES = {
63
64
  next: ["at", "type", "eventId", "sessionId", "text"],
64
65
  "open-question": ["at", "type", "eventId", "sessionId", "questionId", "text"],
65
66
  "question.resolved": ["at", "type", "eventId", "sessionId", "questionId", "text"],
67
+ "operator.command": ["at", "type", "eventId", "command", "sessionId", "runId"],
66
68
  retrospective: ["at", "type", "eventId", "sessionId", "text"],
67
69
  liveness: ["at", "type", "eventId", "campaignId", "runId", "nodeId", "phase", "checkpointsDone", "checkpointsTotal", "runtime", "state", "lastProgressAt", "attention"],
68
70
  "seat.allowance": ["at", "type", "eventId", "sample", "harness", "remaining", "limit", "resetsAt", "delta", "window"],
@@ -276,6 +278,12 @@ export function validateJournalEntry(entry) {
276
278
  // rejects an unexpected key; no deeper shape validation is needed for a type
277
279
  // nothing produces.
278
280
  if (type === "liveness") return;
281
+ if (type === "operator.command") {
282
+ requireText(record.command, "entry.command");
283
+ if (record.sessionId !== undefined) requireText(record.sessionId, "entry.sessionId");
284
+ if (record.runId !== undefined) requireText(record.runId, "entry.runId");
285
+ return;
286
+ }
279
287
  if (type === "seat.allowance") return validateSeatAllowanceEntry(record);
280
288
  if (type === "session.attached") return validateSessionEntry(record);
281
289
  if (type === "run.registered") {
@@ -13,6 +13,7 @@ import { requireId } from "../contract/assert.mjs";
13
13
  export const CAMPAIGN_DIR_NAME = "campaigns";
14
14
  export const CAMPAIGN_FILE = "campaign.json";
15
15
  export const JOURNAL_FILE = "journal.jsonl";
16
+ export const LEDGER_SOURCE_MANIFEST_FILE = "sources.json";
16
17
  export const HANDOFF_FILE = "HANDOFF.md";
17
18
  export const PROJECTION_FILE = "projection.json";
18
19
  export const JOURNAL_WATCH_CURSOR_DIR = "watch-cursors";
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The closed campaign evidence copy: only the files consumed by campaign and
3
+ * eval projectors cross from the operator home into the versioned ledger.
4
+ */
5
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+
8
+ import { readMetricNodeSnapshots } from "./metrics-command.mjs";
9
+ import { CAMPAIGN_FILE, JOURNAL_FILE, LEDGER_SOURCE_MANIFEST_FILE } from "./layout.mjs";
10
+ import { readCampaign } from "./record.mjs";
11
+
12
+ /** @typedef {{runId: string, source: string}} LedgerSkip */
13
+ /** @typedef {{written: string[], skipped: LedgerSkip[]}} LedgerPreservation */
14
+ /** @typedef {{runId: string, source: string}} LedgerChange */
15
+ /** @typedef {{copied: LedgerChange[], gone: LedgerChange[]}} LedgerReledger */
16
+
17
+ const RUN_SOURCES = [
18
+ ["events.jsonl", "events.jsonl"],
19
+ ["usage.jsonl", "usage.jsonl"],
20
+ ["notify.jsonl", "notify.jsonl"],
21
+ ];
22
+
23
+ /** Fields consumed by `projectMetrics` and `projectEvalIndicators`; worker prose never crosses the ledger boundary. */
24
+ export const EVENT_PROJECTION_FIELDS = Object.freeze([
25
+ "at", "node", "to", "phase", "runtime", "verdict", "proofApproved", "passed", "override", "recovery", "error", "revisions",
26
+ ]);
27
+ /** Fields consumed by `projectMetrics`; notification messages and transport details never cross the ledger boundary. */
28
+ export const NOTIFY_PROJECTION_FIELDS = Object.freeze(["at", "dedupeKey", "attempt", "status"]);
29
+
30
+ /**
31
+ * Copy a campaign's projector sources into its versioned ledger. Missing run
32
+ * sources are reported so a pruned run cannot make the close fail or look
33
+ * complete by accident. Repeated calls overwrite the same paths.
34
+ *
35
+ * @param {string} campaignPath
36
+ * @param {string} repoRoot
37
+ * @returns {LedgerPreservation}
38
+ */
39
+ export function preserveCampaignLedger(campaignPath, repoRoot) {
40
+ const campaign = readCampaign(campaignPath);
41
+ const runsDir = resolve(campaignPath, "..", "..");
42
+ const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
43
+ mkdirSync(ledgerDir, { recursive: true });
44
+ /** @type {string[]} */
45
+ const written = [];
46
+ /** @type {LedgerSkip[]} */
47
+ const skipped = [];
48
+ const priorManifest = readSourceManifest(ledgerDir);
49
+ const absent = new Set(priorManifest.absent);
50
+ const preserved = new Set(priorManifest.preserved);
51
+ for (const name of [JOURNAL_FILE, CAMPAIGN_FILE]) {
52
+ const source = join(campaignPath, name);
53
+ if (!existsSync(source)) continue;
54
+ const destination = join(ledgerDir, name);
55
+ copyFileSync(source, destination);
56
+ written.push(destination);
57
+ }
58
+ for (const runId of campaign.linkedRunIds) {
59
+ const runDir = join(runsDir, runId);
60
+ for (const [sourceName, destinationSuffix] of RUN_SOURCES) {
61
+ const source = join(runDir, sourceName);
62
+ const destinationName = `${runId}.${destinationSuffix}`;
63
+ if (!existsSync(source)) {
64
+ skipped.push({ runId, source: sourceName });
65
+ if (!preserved.has(destinationName)) absent.add(destinationName);
66
+ continue;
67
+ }
68
+ absent.delete(destinationName);
69
+ preserved.add(destinationName);
70
+ const destination = join(ledgerDir, destinationName);
71
+ if (sourceName === "events.jsonl" || sourceName === "notify.jsonl") {
72
+ writeFileSync(destination, projectJsonl(source, sourceName === "events.jsonl" ? EVENT_PROJECTION_FIELDS : NOTIFY_PROJECTION_FIELDS));
73
+ } else {
74
+ copyFileSync(source, destination);
75
+ }
76
+ written.push(destination);
77
+ }
78
+ const nodesSource = join(runDir, "nodes");
79
+ if (!existsSync(nodesSource)) {
80
+ skipped.push({ runId, source: "nodes" });
81
+ if (!preserved.has(`${runId}.nodes.json`)) absent.add(`${runId}.nodes.json`);
82
+ continue;
83
+ }
84
+ absent.delete(`${runId}.nodes.json`);
85
+ preserved.add(`${runId}.nodes.json`);
86
+ const nodesDestination = join(ledgerDir, `${runId}.nodes.json`);
87
+ writeFileSync(nodesDestination, `${JSON.stringify(readMetricNodeSnapshots(runDir))}\n`);
88
+ written.push(nodesDestination);
89
+ }
90
+ const proposalsSource = join(campaignPath, "proposals");
91
+ if (existsSync(proposalsSource)) {
92
+ const proposalsDestination = join(ledgerDir, "proposals");
93
+ cpSync(proposalsSource, proposalsDestination, { recursive: true });
94
+ written.push(...filesUnder(proposalsSource).map((path) => join(proposalsDestination, path)));
95
+ }
96
+ const manifest = join(ledgerDir, LEDGER_SOURCE_MANIFEST_FILE);
97
+ writeFileSync(manifest, `${JSON.stringify({ schemaVersion: 1, absent: [...absent].sort(), preserved: [...preserved].sort() })}\n`);
98
+ written.push(manifest);
99
+ return { written, skipped };
100
+ }
101
+
102
+ /**
103
+ * Complete an already closed ledger from the run files still in the operator
104
+ * home. A destination is replaced only when the source strictly extends its
105
+ * bytes; this keeps a ledger's evidence stable when a run was rewritten or
106
+ * truncated after close. Missing run sources are reported as gone.
107
+ *
108
+ * @param {string} campaignPath
109
+ * @param {string} repoRoot
110
+ * @returns {LedgerReledger & {ledgerDir: string}}
111
+ */
112
+ export function reledgerCampaignLedger(campaignPath, repoRoot) {
113
+ const campaign = readCampaign(campaignPath);
114
+ const runsDir = resolve(campaignPath, "..", "..");
115
+ const ledgerDir = join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
116
+ mkdirSync(ledgerDir, { recursive: true });
117
+ /** @type {LedgerChange[]} */
118
+ const copied = [];
119
+ /** @type {LedgerChange[]} */
120
+ const gone = [];
121
+ const sourceManifest = readSourceManifest(ledgerDir);
122
+ const preserved = new Set(sourceManifest.preserved);
123
+ const absent = new Set(sourceManifest.absent);
124
+ for (const runId of campaign.linkedRunIds) {
125
+ const runDir = join(runsDir, runId);
126
+ for (const [sourceName, destinationSuffix] of RUN_SOURCES) {
127
+ const source = join(runDir, sourceName);
128
+ const change = { runId, source: sourceName };
129
+ if (!existsSync(source)) {
130
+ gone.push(change);
131
+ continue;
132
+ }
133
+ const destination = join(ledgerDir, `${runId}.${destinationSuffix}`);
134
+ const projected = sourceName === "events.jsonl" || sourceName === "notify.jsonl"
135
+ ? projectJsonl(source, sourceName === "events.jsonl" ? EVENT_PROJECTION_FIELDS : NOTIFY_PROJECTION_FIELDS)
136
+ : source;
137
+ if (sourceName === "events.jsonl" || sourceName === "notify.jsonl") {
138
+ if (writeIfStrictExtension(destination, projected)) copied.push(change);
139
+ } else if (copyIfStrictExtension(projected, destination)) copied.push(change);
140
+ if (existsSync(destination)) {
141
+ preserved.add(`${runId}.${destinationSuffix}`);
142
+ absent.delete(`${runId}.${destinationSuffix}`);
143
+ }
144
+ }
145
+ const nodesSource = join(runDir, "nodes");
146
+ const nodesChange = { runId, source: "nodes" };
147
+ if (!existsSync(nodesSource)) {
148
+ gone.push(nodesChange);
149
+ continue;
150
+ }
151
+ const nodesDestination = join(ledgerDir, `${runId}.nodes.json`);
152
+ const projection = `${JSON.stringify(readMetricNodeSnapshots(runDir))}\n`;
153
+ if (writeIfStrictExtension(nodesDestination, projection)) copied.push(nodesChange);
154
+ if (existsSync(nodesDestination)) {
155
+ preserved.add(`${runId}.nodes.json`);
156
+ absent.delete(`${runId}.nodes.json`);
157
+ }
158
+ }
159
+ const proposalsSource = join(campaignPath, "proposals");
160
+ if (existsSync(proposalsSource)) {
161
+ for (const relative of filesUnder(proposalsSource)) {
162
+ const source = join(proposalsSource, relative);
163
+ const destination = join(ledgerDir, "proposals", relative);
164
+ if (copyIfStrictExtension(source, destination)) copied.push({ runId: campaign.id, source: `proposals/${relative}` });
165
+ }
166
+ }
167
+ const ledgerCampaign = join(ledgerDir, CAMPAIGN_FILE);
168
+ if (existsSync(ledgerCampaign)) {
169
+ const existing = readJsonObject(ledgerCampaign);
170
+ if (existing?.id === campaign.id && existing.status !== "closed" && campaign.status === "closed") {
171
+ writeFileSync(ledgerCampaign, readFileSync(join(campaignPath, CAMPAIGN_FILE)));
172
+ }
173
+ } else if (existsSync(join(campaignPath, CAMPAIGN_FILE))) {
174
+ copyFileSync(join(campaignPath, CAMPAIGN_FILE), ledgerCampaign);
175
+ }
176
+ writeFileSync(join(ledgerDir, LEDGER_SOURCE_MANIFEST_FILE), `${JSON.stringify({ schemaVersion: 1, absent: [...absent].sort(), preserved: [...preserved].sort() })}\n`);
177
+ return { ledgerDir, copied, gone };
178
+ }
179
+
180
+ /**
181
+ * @param {string} source
182
+ * @param {string} destination
183
+ * @returns {boolean}
184
+ */
185
+ function copyIfStrictExtension(source, destination) {
186
+ const bytes = readFileSync(source);
187
+ return writeIfStrictExtension(destination, bytes);
188
+ }
189
+
190
+ /**
191
+ * @param {string} destination
192
+ * @param {Buffer|string} source
193
+ * @returns {boolean}
194
+ */
195
+ function writeIfStrictExtension(destination, source) {
196
+ const bytes = Buffer.isBuffer(source) ? source : Buffer.from(source);
197
+ mkdirSync(dirname(destination), { recursive: true });
198
+ if (!existsSync(destination)) {
199
+ writeFileSync(destination, bytes);
200
+ return true;
201
+ }
202
+ const existing = readFileSync(destination);
203
+ if (bytes.length <= existing.length || !bytes.subarray(0, existing.length).equals(existing)) return false;
204
+ writeFileSync(destination, bytes);
205
+ return true;
206
+ }
207
+
208
+ /** @param {string} path @param {readonly string[]} fields @returns {string} */
209
+ function projectJsonl(path, fields) {
210
+ const text = readFileSync(path, "utf8");
211
+ const lines = text.split(/\r?\n/u);
212
+ const projected = [];
213
+ for (let index = 0; index < lines.length; index += 1) {
214
+ if (!lines[index].trim()) continue;
215
+ if (index === lines.length - 1 && !text.endsWith("\n")) continue;
216
+ const value = JSON.parse(lines[index]);
217
+ if (value === null || typeof value !== "object" || Array.isArray(value)) continue;
218
+ const source = /** @type {Record<string, unknown>} */ (value);
219
+ /** @type {Record<string, unknown>} */
220
+ const record = {};
221
+ for (const field of fields) if (Object.hasOwn(source, field)) record[field] = projectedField(source[field], field);
222
+ projected.push(JSON.stringify(record));
223
+ }
224
+ return projected.length === 0 ? "" : `${projected.join("\n")}\n`;
225
+ }
226
+
227
+ /** @param {unknown} value @param {string} field @returns {unknown} */
228
+ function projectedField(value, field) {
229
+ if (field !== "override" || value === null || typeof value !== "object" || Array.isArray(value)) return value;
230
+ const object = /** @type {Record<string, unknown>} */ (value);
231
+ return Object.hasOwn(object, "kind") ? { kind: object.kind } : {};
232
+ }
233
+
234
+ /** @param {string} root @returns {{absent: string[], preserved: string[]}} */
235
+ function readSourceManifest(root) {
236
+ try {
237
+ const value = JSON.parse(readFileSync(join(root, LEDGER_SOURCE_MANIFEST_FILE), "utf8"));
238
+ return {
239
+ absent: Array.isArray(value?.absent) ? /** @type {unknown[]} */ (value.absent).filter((entry) => typeof entry === "string") : [],
240
+ preserved: Array.isArray(value?.preserved) ? /** @type {unknown[]} */ (value.preserved).filter((entry) => typeof entry === "string") : [],
241
+ };
242
+ } catch {
243
+ // An old ledger has no origin manifest; its files remain subject to the legacy missing-source rules.
244
+ return { absent: [], preserved: [] };
245
+ }
246
+ }
247
+
248
+ /** @param {string} path @returns {Record<string, unknown>|null} */
249
+ function readJsonObject(path) {
250
+ try {
251
+ const value = JSON.parse(readFileSync(path, "utf8"));
252
+ return value !== null && typeof value === "object" && !Array.isArray(value)
253
+ ? /** @type {Record<string, unknown>} */ (value)
254
+ : null;
255
+ } catch {
256
+ // A malformed old ledger record cannot prove that it is already closed.
257
+ return null;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * @param {string} root
263
+ * @returns {string[]}
264
+ */
265
+ function filesUnder(root) {
266
+ return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
267
+ if (entry.isDirectory()) return filesUnder(join(root, entry.name)).map((path) => join(entry.name, path));
268
+ return [entry.name];
269
+ });
270
+ }