faberun 0.22.0 → 0.23.1
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/src/campaign/chain.mjs +21 -8
- package/src/campaign/index.mjs +70 -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 +43 -3
- package/src/cli/manual.mjs +13 -5
- package/src/cli.mjs +1 -1
- package/src/engine/bulk-read.mjs +2 -1
- package/src/engine/dispatch.mjs +23 -1
- package/src/engine/lifecycle.mjs +6 -8
- package/src/engine/scheduler.mjs +2 -1
- package/src/engine/settle.mjs +1 -1
- package/src/plan/proof-run.mjs +55 -12
- package/src/repo/worktree.mjs +4 -1
- package/src/report/metrics-report.mjs +16 -3
- package/src/run/lock.mjs +2 -12
- package/src/run/store.mjs +30 -2
- package/src/run/usage.mjs +61 -4
- package/src/util.mjs +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
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": {
|
package/src/campaign/chain.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { execFileSync } from "node:child_process";
|
|
|
32
32
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
33
33
|
import { hostname, tmpdir } from "node:os";
|
|
34
34
|
import { dirname, join, relative, resolve } from "node:path";
|
|
35
|
-
import { assertContractManifestIntact, parkCampaign, promoteRunInCampaign } from "./index.mjs";
|
|
35
|
+
import { assertContractManifestIntact, parkCampaign, promoteRunInCampaign, recordOperatorCommand } from "./index.mjs";
|
|
36
36
|
import { readCampaign } from "./record.mjs";
|
|
37
37
|
import { validateContract } from "../contract/index.mjs";
|
|
38
38
|
import { defaultControllerIdentity, storedContractDigest, verifyControllerIdentity } from "../engine/run-identity.mjs";
|
|
@@ -386,6 +386,7 @@ function storedContractId(stored) {
|
|
|
386
386
|
* maxTicks?: number,
|
|
387
387
|
* coordination?: boolean,
|
|
388
388
|
* controllerIdentity?: ControllerIdentity,
|
|
389
|
+
* refreshController?: boolean,
|
|
389
390
|
* launch?: (contractPath: string, context: {baseRef: string|undefined, controllerIdentity: ControllerIdentity, runDir: string, contract: ValidatedContract}) => Promise<void>|void,
|
|
390
391
|
* validate?: (entry: CampaignContract, context: {repo: string|undefined, baseRef: string|undefined, contractPath: string}) => ValidatedContract,
|
|
391
392
|
* progress?: (runDir: string) => RunProgress,
|
|
@@ -551,13 +552,25 @@ export async function driveCampaignChain(campaignPath, options = {}) {
|
|
|
551
552
|
try {
|
|
552
553
|
verifyControllerIdentity(controllerIdentity);
|
|
553
554
|
} catch (error) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
555
|
+
// A campaign whose contracts change faberun's own src/ changes the
|
|
556
|
+
// controller between launches (measured on evidence-you-can-recompute).
|
|
557
|
+
// Adopting the new snapshot is the operator's call, so it happens only
|
|
558
|
+
// under --refresh-controller and is recorded as an operator command.
|
|
559
|
+
if (options.refreshController === true && errorCode(error) === "controller_snapshot_changed") {
|
|
560
|
+
controllerIdentity = defaultControllerIdentity();
|
|
561
|
+
recordOperatorCommand(campaignPath, "campaign supervise --refresh-controller");
|
|
562
|
+
emit(`[campaign] controller snapshot refreshed · ${controllerIdentity.sha.slice(0, 12)}`);
|
|
563
|
+
} else {
|
|
564
|
+
return park({
|
|
565
|
+
code: errorCode(error) ?? "controller_snapshot_changed",
|
|
566
|
+
message: errorCode(error) === "controller_snapshot_changed"
|
|
567
|
+
? `${errorMessage(error)}: \`faberun campaign supervise ${readCampaign(campaignPath).id} --refresh-controller\``
|
|
568
|
+
: errorMessage(error),
|
|
569
|
+
contractPath: entry.path,
|
|
570
|
+
contractId: id,
|
|
571
|
+
runId: id,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
561
574
|
}
|
|
562
575
|
try {
|
|
563
576
|
await launch(entry.path, { baseRef, controllerIdentity, runDir, contract });
|
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,9 +401,22 @@ 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
|
+
recordOperatorCommand(campaignPath, "campaign add-contract", at);
|
|
425
405
|
return { campaign: updated, added: true };
|
|
426
406
|
}
|
|
427
407
|
|
|
408
|
+
/**
|
|
409
|
+
* The one writer of `operator.command` (docs/FIELD-OWNERSHIP.md): an operator
|
|
410
|
+
* command that changed campaign state, which `humanTouches` counts.
|
|
411
|
+
*
|
|
412
|
+
* @param {string} campaignPath
|
|
413
|
+
* @param {string} command
|
|
414
|
+
* @param {string} [at]
|
|
415
|
+
*/
|
|
416
|
+
export function recordOperatorCommand(campaignPath, command, at = new Date().toISOString()) {
|
|
417
|
+
appendJournal(campaignPath, { type: "operator.command", eventId: randomUUID(), at, command });
|
|
418
|
+
}
|
|
419
|
+
|
|
428
420
|
/**
|
|
429
421
|
* Replace a manifest entry -- matched by its current, recorded path -- with a
|
|
430
422
|
* freshly authored contract. Re-authoring a phase after a blocked or failed
|
|
@@ -458,13 +450,43 @@ export function replaceContractInCampaign(campaignPath, oldPath, newPath, { at =
|
|
|
458
450
|
const contracts = campaign.contracts.map((entry, position) => (position === index ? replaced : entry));
|
|
459
451
|
const attention = campaign.attention;
|
|
460
452
|
const clearAttention = attention !== undefined && attention.contractPath === oldPath;
|
|
453
|
+
const replacementRunIds = replacedRunIds(campaignPath, campaign, oldPath, newPath, attention?.runId);
|
|
454
|
+
const replacement = { oldPath, newPath, runIds: replacementRunIds, at };
|
|
461
455
|
/** @type {Campaign} */
|
|
462
|
-
const updated = { ...campaign, contracts, updatedAt: at };
|
|
456
|
+
const updated = { ...campaign, contracts, replacements: [...(campaign.replacements ?? []), replacement], updatedAt: at };
|
|
463
457
|
if (clearAttention) delete updated.attention;
|
|
464
458
|
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
|
|
465
459
|
return { campaign: updated, replaced, clearedAttention: clearAttention ? /** @type {CampaignAttention} */ (attention) : null };
|
|
466
460
|
}
|
|
467
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Identify the run whose authored contract is being re-issued. The parked
|
|
464
|
+
* attention is the authoritative link when present; matching the old
|
|
465
|
+
* contract id also covers a replacement requested before the run was parked.
|
|
466
|
+
*
|
|
467
|
+
* @param {string} campaignPath
|
|
468
|
+
* @param {Campaign} campaign
|
|
469
|
+
* @param {string} oldPath
|
|
470
|
+
* @param {string} newPath
|
|
471
|
+
* @param {string|undefined} attentionRunId
|
|
472
|
+
* @returns {string[]}
|
|
473
|
+
*/
|
|
474
|
+
function replacedRunIds(campaignPath, campaign, oldPath, newPath, attentionRunId) {
|
|
475
|
+
const ids = new Set();
|
|
476
|
+
if (attentionRunId !== undefined && campaign.linkedRunIds.includes(attentionRunId)) ids.add(attentionRunId);
|
|
477
|
+
const oldContract = readRunJson(oldPath);
|
|
478
|
+
const oldId = oldContract !== null && typeof oldContract.id === "string" ? oldContract.id : null;
|
|
479
|
+
if (oldId !== null && campaign.linkedRunIds.includes(oldId)) ids.add(oldId);
|
|
480
|
+
const runsDir = resolve(campaignPath, "..", "..");
|
|
481
|
+
for (const runId of campaign.linkedRunIds) {
|
|
482
|
+
const runContract = readRunJson(join(runsDir, runId, "contract.json"));
|
|
483
|
+
if (runContract !== null && oldId !== null && runContract.id === oldId) ids.add(runId);
|
|
484
|
+
}
|
|
485
|
+
const replacementContract = readRunJson(newPath);
|
|
486
|
+
const replacementId = replacementContract !== null && typeof replacementContract.id === "string" ? replacementContract.id : null;
|
|
487
|
+
return [...ids].filter((runId) => replacementId === null || runId !== replacementId).sort();
|
|
488
|
+
}
|
|
489
|
+
|
|
468
490
|
/**
|
|
469
491
|
* Persist one promotion in the campaign record. Idempotent by run id and the
|
|
470
492
|
* sha it landed: a re-invocation after a crash between the branch move and
|
|
@@ -584,4 +606,3 @@ export function renderRunHandoff(runDir) {
|
|
|
584
606
|
if (!existsSync(join(path, CAMPAIGN_FILE))) return null;
|
|
585
607
|
return renderHandoff(path, runsDir);
|
|
586
608
|
}
|
|
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
|
+
}
|