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 +1 -1
- package/skills/faberun/references/operations.md +7 -7
- package/src/campaign/chain.mjs +30 -1
- package/src/campaign/index.mjs +58 -49
- package/src/campaign/journal.mjs +8 -0
- package/src/campaign/layout.mjs +1 -0
- package/src/campaign/ledger.mjs +270 -0
- package/src/campaign/metrics-command.mjs +232 -0
- package/src/campaign/metrics.mjs +234 -153
- package/src/campaign/record.mjs +12 -0
- package/src/cli/campaign.mjs +40 -1
- package/src/cli/manual.mjs +13 -5
- package/src/cli.mjs +1 -1
- 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/bulk-read.mjs +2 -1
- package/src/engine/dispatch.mjs +6 -4
- package/src/engine/lifecycle.mjs +1 -2
- package/src/engine/process.mjs +1 -0
- package/src/engine/result-file.mjs +13 -1
- package/src/engine/scheduler.mjs +2 -1
- package/src/engine/settle.mjs +2 -1
- 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 +107 -3
- package/src/plan/pipeline.mjs +3 -2
- 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/final.mjs +5 -4
- package/src/report/metrics-report.mjs +16 -3
- package/src/report/render.mjs +4 -3
- package/src/run/usage.mjs +61 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "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 —
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
package/src/campaign/chain.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/campaign/index.mjs
CHANGED
|
@@ -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,
|
|
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 {{
|
|
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
|
-
|
|
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
|
-
|
package/src/campaign/journal.mjs
CHANGED
|
@@ -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") {
|
package/src/campaign/layout.mjs
CHANGED
|
@@ -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
|
+
}
|