faberun 0.27.0 → 0.29.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/README.md +9 -3
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +31 -4
- package/src/cli/manual.mjs +24 -8
- package/src/cli/plan.mjs +56 -5
- package/src/cli/setup.mjs +5 -0
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +72 -8
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +164 -0
- package/src/contract/index.mjs +8 -5
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +20 -6
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +37 -11
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +117 -18
- package/src/engine/prompts.mjs +58 -4
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +15 -9
- package/src/engine/scheduler.mjs +8 -1
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +29 -0
- package/src/harnesses/claude/index.mjs +98 -1
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +14 -0
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/index.mjs +13 -0
- package/src/harnesses/index.mjs +21 -10
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +32 -9
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/freeze.mjs +2 -0
- package/src/plan/pipeline.mjs +56 -4
- package/src/plan/progress.mjs +101 -0
- package/src/plan/proof-check.mjs +27 -0
- package/src/plan/proof-scope.mjs +1 -1
- package/src/plan/repo-facts.mjs +129 -14
- package/src/plan/rounds.mjs +27 -0
- package/src/plan/spec.mjs +82 -6
- package/src/plan/template.mjs +9 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/repo/worktree-prune.mjs +13 -5
- package/src/repo/worktree.mjs +54 -0
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- package/src/util.mjs +18 -2
package/README.md
CHANGED
|
@@ -108,8 +108,9 @@ checkout. The operator keeps working while the campaign runs.
|
|
|
108
108
|
### Durable campaigns
|
|
109
109
|
|
|
110
110
|
A run is not tied to the terminal that launched it. The controller runs
|
|
111
|
-
detached, state is persisted under
|
|
112
|
-
|
|
111
|
+
detached, state is persisted under the project's runs directory in
|
|
112
|
+
`$FABERUN_HOME` (default `~/.faberun`), `supervise` resumes a controller that
|
|
113
|
+
died, and a campaign carries its intent across many runs and sessions. If a
|
|
113
114
|
provider goes down or a harness session ends, the work does not have to be
|
|
114
115
|
re-authored.
|
|
115
116
|
|
|
@@ -218,6 +219,11 @@ An installed copy updates itself from the newest GitHub release:
|
|
|
218
219
|
faberun update
|
|
219
220
|
```
|
|
220
221
|
|
|
222
|
+
To leave cleanly, `faberun uninstall --dry-run` lists what the install wrote
|
|
223
|
+
outside a repository — registered skills, status-line and hook entries, and
|
|
224
|
+
`$FABERUN_HOME` — and `faberun uninstall` removes it after confirming, refusing
|
|
225
|
+
while a campaign's ledger is not preserved at its repository unless `--force`.
|
|
226
|
+
|
|
221
227
|
## Quickstart
|
|
222
228
|
|
|
223
229
|
The full walkthrough, from a fresh machine to a first verified node, is in
|
|
@@ -226,7 +232,7 @@ The full walkthrough, from a fresh machine to a first verified node, is in
|
|
|
226
232
|
| Step | Command | What it does |
|
|
227
233
|
| --- | --- | --- |
|
|
228
234
|
| 1 | `faberun setup` | Onboards the machine: checks node and git, discovers the harnesses, and writes the default worker and judge. |
|
|
229
|
-
| 2 | `faberun init` | Prepares a repository: confirms a git work tree, ignores
|
|
235
|
+
| 2 | `faberun init` | Prepares a repository: confirms a git work tree, ignores the legacy in-tree runs directory, and installs the `faberun` skill. |
|
|
230
236
|
| 3 | `faberun campaign init <id> --goal "..."` | Opens the durable campaign that carries the intent across runs. |
|
|
231
237
|
| 4 | write a contract | Fixes `contract.json`: the node DAG, each packet's read and write scope, and each definition of done. |
|
|
232
238
|
| 5 | `faberun validate contract.json` | Parses the contract and prints the report the authoring turn reads. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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": {
|
package/skills/faberun/SKILL.md
CHANGED
|
@@ -16,11 +16,11 @@ Read [rules](references/rules.md) first.
|
|
|
16
16
|
| Dispatch a node | [handoffs](references/handoffs.md) |
|
|
17
17
|
| Supervise, answer | [operations](references/operations.md), [handoffs](references/handoffs.md) |
|
|
18
18
|
| Verify, judge | [engineering](references/engineering.md) |
|
|
19
|
-
| Install, set up, update | [operations](references/operations.md) |
|
|
19
|
+
| Install, set up, update, uninstall | [operations](references/operations.md) |
|
|
20
|
+
| Doctor --env | [local-env](references/local-env.md) |
|
|
20
21
|
|
|
21
|
-
Watchdog
|
|
22
|
+
Watchdog:
|
|
22
23
|
|
|
23
|
-
faberun supervise <run-dir>
|
|
24
24
|
faberun supervise campaign <id> [--allow-main]
|
|
25
25
|
|
|
26
26
|
launchd: `StartInterval 300`; `launchctl load -w ~/Library/LaunchAgents/faberun.plist`.
|
|
@@ -187,17 +187,15 @@ never zero.
|
|
|
187
187
|
|
|
188
188
|
Non-empty `taskPacket.verification` is rejected when the resolved worker or a
|
|
189
189
|
worker fallback cannot execute commands. Adapters declare
|
|
190
|
-
`permissionExecution`: `claude`
|
|
191
|
-
`
|
|
192
|
-
`
|
|
193
|
-
`
|
|
194
|
-
|
|
195
|
-
also declares `signalsProcesses` (`true`, `false`, or `null` when unmeasured):
|
|
190
|
+
`permissionExecution`: `claude` `bypassPermissions` (default `acceptEdits`),
|
|
191
|
+
`zcode` `yolo` (also default), `dsh` both its default `workspace-write` and
|
|
192
|
+
`danger-full-access`; every `codex` sandbox mode executes, and
|
|
193
|
+
`agy`/`exec-jsonl`/`replay` expose no denying mode. Each adapter
|
|
194
|
+
also declares `signalsProcesses` (`true`, `false`, or unmeasured `null`):
|
|
196
195
|
a worker whose adapter declares `false` gets a `## Sandbox` prompt warning not
|
|
197
196
|
to run tests that start and terminate child processes, and
|
|
198
197
|
`requiredCapabilities.signalsProcesses: true` admits only an adapter declaring
|
|
199
|
-
`true`. Judge modes
|
|
200
|
-
are excluded because judges review captured results.
|
|
198
|
+
`true`. Judge modes are excluded: they review captured results.
|
|
201
199
|
|
|
202
200
|
- `claude`: `permissionMode` (a node that runs commands needs
|
|
203
201
|
`bypassPermissions`, or the worker can only return `blocked_context`).
|
|
@@ -237,7 +235,9 @@ are excluded because judges review captured results.
|
|
|
237
235
|
(`deepseek-flash` is the default); unknown ids fail in the harness.
|
|
238
236
|
Authentication stays in `DEEPSEEK_API_KEY`;
|
|
239
237
|
`config["api_key.env_key"]` only names it for `preflight`. `sandbox` maps to
|
|
240
|
-
`DSH_PERMISSION_MODE`, default `workspace-write
|
|
238
|
+
`DSH_PERMISSION_MODE`, default `workspace-write`: a toolchain cache outside
|
|
239
|
+
the worktree gets `ReadOnlyFileSystem`, so compiling needs
|
|
240
|
+
`danger-full-access`. Every attempt loads
|
|
241
241
|
`dsh/closed-packet.patch.yml`; `config.patch` stacks one layer. Executable
|
|
242
242
|
override: `executable` or `FABERUN_DSH_BIN`. No default vendor,
|
|
243
243
|
continuation (`session/resume` is ACP-only), or native schema flag; the judge
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Local environment
|
|
2
|
+
|
|
3
|
+
A worker or judge process gets only the environment it is allowed — the base
|
|
4
|
+
set (`PATH`, `HOME`, `USER`, `SHELL`, `LANG`, `LC_*`, `TERM`, `TMPDIR`, `TZ`,
|
|
5
|
+
plus the Windows names), the names a harness adapter declares, its runtimes'
|
|
6
|
+
`env_key` names and any `envPassthrough` — while this phase's
|
|
7
|
+
definition-of-done commands and `finalVerification` keep today's environment.
|
|
8
|
+
|
|
9
|
+
`faberun doctor --env [<contract.json>]` lists, per runtime, the names that
|
|
10
|
+
would pass and the names excluded, never the values; a credential-shaped
|
|
11
|
+
exclusion is marked retained.
|
|
@@ -9,17 +9,11 @@ source `gitHead`. Every worker attempt gets a linked worktree at
|
|
|
9
9
|
`faberun/<run-id>/<node-id>/<attempt>`, cut from that ref; the node snapshot records
|
|
10
10
|
`worktree.path`, `.branch`, `.baseSha` and the sealed `.commit`. Provider,
|
|
11
11
|
scope, verification and judge processes all use that path; `contract.cwd` stays
|
|
12
|
-
the home of run/control artifacts.
|
|
13
|
-
symlinked into every attempt worktree, never copied.
|
|
12
|
+
the home of run/control artifacts.
|
|
14
13
|
|
|
15
14
|
A retried attempt never discards the previous one's edits: the controller seals
|
|
16
15
|
the previous worktree first and, when that seal has a diff, cuts the next
|
|
17
|
-
attempt from that sha (`worktree.previousAttempt`)
|
|
18
|
-
the run ref tip.
|
|
19
|
-
|
|
20
|
-
`contract.maxParallel` bounds concurrent nodes; each tick dispatches every
|
|
21
|
-
`pending` node whose dependencies are `done`, up to the free slots, each into
|
|
22
|
-
its own worktree.
|
|
16
|
+
attempt from that sha (`worktree.previousAttempt`).
|
|
23
17
|
|
|
24
18
|
## Integration transaction
|
|
25
19
|
|
|
@@ -36,9 +30,8 @@ untouched, and keeps the attempt worktree. A conflict marks the node `attention`
|
|
|
36
30
|
with the conflicting paths and cleans the scratch worktree.
|
|
37
31
|
|
|
38
32
|
Resume replays `integration.jsonl`, never ancestry, to identify the one
|
|
39
|
-
unfinished transaction and complete it idempotently
|
|
40
|
-
|
|
41
|
-
attempt's sealed sha, the same continuation rule as any other retry.
|
|
33
|
+
unfinished transaction and complete it idempotently; a re-dispatch follows the
|
|
34
|
+
same continuation rule as any other retry.
|
|
42
35
|
|
|
43
36
|
## Controller lock and takeover
|
|
44
37
|
|
|
@@ -50,17 +43,16 @@ otherwise it exits `controller_active`, untouched. Takeover renames the lock
|
|
|
50
43
|
aside and re-checks it is stale before installing its own. Worker/judge/
|
|
51
44
|
verification children run detached in their own process group, so before
|
|
52
45
|
dispatching new work `resume`'s recovery pass terminates (`SIGTERM` then
|
|
53
|
-
`SIGKILL
|
|
54
|
-
unless
|
|
46
|
+
`SIGKILL`) every invocation recorded for a `running` node —
|
|
47
|
+
unless still inside its deadline, when it is adopted and its result read.
|
|
55
48
|
`cancel` signals a live controller first, so its takeover never waits.
|
|
49
|
+
`resume --reauthor <node-id>` re-widens a refused packet under a hard round
|
|
50
|
+
budget.
|
|
56
51
|
|
|
57
|
-
`supervise <run-dir> [--detach] [--interval <sec>]` is the watchdog
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
52
|
+
`supervise <run-dir> [--detach] [--interval <sec>]` is the watchdog;
|
|
53
|
+
it holds no lock and writes no state. A detached controller outlives its
|
|
54
|
+
launcher, not the session scope (cgroup) holding it: run long work under
|
|
55
|
+
`tmux`, `systemd-run` or the seat.
|
|
64
56
|
|
|
65
57
|
## Runtime discovery
|
|
66
58
|
|
|
@@ -101,7 +93,7 @@ The same server exposes the phone surface under `/api/*`, behind the
|
|
|
101
93
|
same token and bind. Reads: `GET /api/campaigns`, `/api/campaigns/<id>`,
|
|
102
94
|
`…/brief`, `/api/seats`, and `…/events?after=<cursor>` (one bounded page, ≤32 KiB
|
|
103
95
|
and ≤100 entries, so a client starting at zero never drags the whole journal).
|
|
104
|
-
Writes shell out to the runner CLI
|
|
96
|
+
Writes shell out to the runner CLI:
|
|
105
97
|
`POST …/decisions/<id>` → `campaign resolve`; `…/note` → `campaign note`;
|
|
106
98
|
`…/pause` and `…/resume` → `cancel` / `resume --detach`; `/api/seats/<id>/switch`
|
|
107
99
|
→ `seat switch`. No replan, contract, routing or gate route exists on purpose:
|
|
@@ -110,7 +102,7 @@ the contract is frozen with a digest, and the phone's middle ground is a note.
|
|
|
110
102
|
## Notify
|
|
111
103
|
|
|
112
104
|
On `node.terminal`, `run.terminal` and `attention` the controller renders one
|
|
113
|
-
message from persisted state, in the operator's
|
|
105
|
+
message from persisted state, in the operator's language (from
|
|
114
106
|
the campaign goal, journal notes and node objectives, or `FABERUN_NOTIFY_LANG`;
|
|
115
107
|
English otherwise). Only `run.terminal`, `attention` and `advisory` leave by
|
|
116
108
|
default -- a phase settling and a person being needed -- while a node settling
|
|
@@ -118,31 +110,31 @@ gets a `filtered` receipt; `FABERUN_NOTIFY_EVENTS` widens or narrows the list:
|
|
|
118
110
|
line one is the outcome (`✅ <node> · done in 8m · $0.09`, `🏁 run 15 · <name>
|
|
119
111
|
· 2/2 done`, or `👀 <node> needs you · <error>`), then asked / done / proof
|
|
120
112
|
for a node, what every node delivered for a run, or why / asked / do for
|
|
121
|
-
attention, then a progress bar, a rule and the `🐦 faberun` signature
|
|
122
|
-
|
|
113
|
+
attention, then a progress bar, a rule and the `🐦 faberun` signature (campaign
|
|
114
|
+
percent, cost, elapsed); an `⬆️` line names a newer release when
|
|
123
115
|
the cached update check has one. ≤2 KiB. It delivers that
|
|
124
|
-
|
|
125
|
-
(`delivered`, `failed`, `no_transport`,
|
|
116
|
+
text to every bound transport at once, appending one receipt
|
|
117
|
+
(`delivered`, `failed`, `no_transport`, per transport) to
|
|
126
118
|
`<run-dir>/notify.jsonl`. Delivery is lossy: **exactly one attempt**, no retry,
|
|
127
119
|
no backoff. `FABERUN_NOTIFY_BIN` names an executable called with the event as
|
|
128
120
|
JSON on stdin (`os-macos` selects the bundled `osascript` adapter); it pushes to
|
|
129
121
|
a person, `canWake: false`. `FABERUN_NOTIFY_SESSION` wakes harness sessions,
|
|
130
122
|
`canWake: true`: `auto` is the Claude Code inbox and Codex thread the
|
|
131
123
|
environment names; `claude:<socket>` adds another session's inbox, so the
|
|
132
|
-
operator's own
|
|
133
|
-
(`echo $CLAUDE_CODE_MESSAGING_SOCKET` in that session gives the path).
|
|
134
|
-
window sets `auto
|
|
124
|
+
operator's own session hears a background run
|
|
125
|
+
(`echo $CLAUDE_CODE_MESSAGING_SOCKET` in that session gives the path). Only a seat
|
|
126
|
+
window sets `auto`, so a test suite never wakes a session.
|
|
135
127
|
**On an inbound `🐦 faberun` message**: repeat it to the operator verbatim,
|
|
136
|
-
then one line on what you do next --
|
|
137
|
-
|
|
128
|
+
then one line on what you do next -- it informs the person, not
|
|
129
|
+
you, and reaches them only through your reply; `👀`, act on the `do`
|
|
138
130
|
command it names. A
|
|
139
131
|
resume never re-sends a notification already recorded for the same node, attempt
|
|
140
132
|
and outcome. No transport is a default: `doctor`, `preflight` and the foreground
|
|
141
|
-
launch warn when both
|
|
133
|
+
launch warn when both are empty, and `--wake` names what will wake.
|
|
142
134
|
Campaign-level lines are queued in `.runs/inbox.jsonl`, the managed block's
|
|
143
135
|
append-only record — one object per line `{schemaVersion, eventId, at, type,
|
|
144
136
|
campaignId, runId, nodeId, status, errorCode, dedupeKey, summary}`, deduped on
|
|
145
|
-
`dedupeKey`
|
|
137
|
+
`dedupeKey` with one `O_APPEND` write per line.
|
|
146
138
|
`campaign watch --wake --detach` queues there and delivers through
|
|
147
139
|
`<campaign-dir>/notify.jsonl`; a durable `watch.lock` plus the inbox dedupe keep
|
|
148
140
|
two detached watchers from double-sending across a restart.
|
|
@@ -207,3 +199,4 @@ binary is absent, and only reattaching is lost.
|
|
|
207
199
|
`faberun skills install [name]` installs the shipped skills.
|
|
208
200
|
`faberun skills register [--harness a,b] [--copy] [--force]` links the faberun skill into each installed harness's skills directory.
|
|
209
201
|
`faberun campaign unpark <id> [--force]` clears a parked campaign once its run is no longer parked so `supervise campaign` can continue.
|
|
202
|
+
`faberun uninstall [--dry-run]` removes what faberun wrote outside a target repository and `$FABERUN_HOME`; an unpreserved ledger refuses unless `--force`.
|
package/src/campaign/brief.mjs
CHANGED
|
@@ -17,12 +17,13 @@ import { boundedText, compactCost, compactTokens, finite, readJsonTolerant } fro
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { readCampaign } from "./record.mjs";
|
|
19
19
|
import { readProjectionState } from "./projection.mjs";
|
|
20
|
+
import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
|
|
20
21
|
import { writeTextAtomic } from "../run/store.mjs";
|
|
21
22
|
|
|
22
23
|
/** @typedef {import("./index.mjs").Campaign} Campaign */
|
|
23
24
|
/** @typedef {import("./index.mjs").JournalEntry} JournalEntry */
|
|
24
25
|
/** @typedef {import("./index.mjs").Projection} Projection */
|
|
25
|
-
/** @typedef {{id: string, exists: boolean, summary: string, controller: string, phase: string|null, done: number, total: number, costUsd: number|null, inputTokens: number|null, outputTokens: number|null, attention: {id: string, status: string, note: string}[]}} BriefRun */
|
|
26
|
+
/** @typedef {{id: string, exists: boolean, summary: string, controller: string, phase: string|null, judgeIndependence?: string|null, done: number, total: number, costUsd: number|null, inputTokens: number|null, outputTokens: number|null, attention: {id: string, status: string, note: string}[]}} BriefRun */
|
|
26
27
|
/** @typedef {{campaign: Campaign, updatedAt: string, phase: string|null, checkpoints: {done: number, total: number}, runs: BriefRun[], openQuestions: JournalEntry[], transitions: JournalEntry[], budget: {costUsd: number|null, inputTokens: number|null, outputTokens: number|null}, notes: JournalEntry[], commands: string[]}} Brief */
|
|
27
28
|
/** @typedef {{limit: number, used: number, lines: string[]}} BriefBudget */
|
|
28
29
|
|
|
@@ -75,9 +76,11 @@ export function materializeBrief(campaignPath, brief) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
|
-
* Read the campaign's live projection
|
|
79
|
+
* Read the campaign's live projection, each linked run's `status.json` and the
|
|
80
|
+
* effective judge-independence mode recorded on that run's node snapshots, then
|
|
79
81
|
* materialize the brief. `heartbeat.json` does not exist in this tree -- the
|
|
80
|
-
* projection
|
|
82
|
+
* projection, the per-run status and the persisted node snapshots are the
|
|
83
|
+
* durable sources.
|
|
81
84
|
*
|
|
82
85
|
* @param {string} campaignPath
|
|
83
86
|
* @param {string} runsDir
|
|
@@ -95,16 +98,42 @@ export function renderBrief(campaignPath, runsDir) {
|
|
|
95
98
|
* @returns {BriefRun[]}
|
|
96
99
|
*/
|
|
97
100
|
function readRunBriefs(runsDir, runIds) {
|
|
98
|
-
return runIds.map((runId) =>
|
|
101
|
+
return runIds.map((runId) => {
|
|
102
|
+
const runDir = join(runsDir, runId);
|
|
103
|
+
return runBrief(runId, readJsonTolerant(join(runDir, "status.json")), recordedJudgeIndependence(runDir));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The effective judge-independence mode the launch recorded on this run's node
|
|
109
|
+
* snapshots (`routing.assignments.judgeIndependence`). The launch composes one
|
|
110
|
+
* mode per contract, so the first node that recorded one answers for the run;
|
|
111
|
+
* reading it here keeps the brief from re-deriving the mode from the contract,
|
|
112
|
+
* which cannot see a machine-config opt-in. A run with no persisted node or no
|
|
113
|
+
* recorded mode has no recorded opt-in (the cross-vendor default).
|
|
114
|
+
*
|
|
115
|
+
* @param {string} runDir
|
|
116
|
+
* @returns {string|null}
|
|
117
|
+
*/
|
|
118
|
+
export function recordedJudgeIndependence(runDir) {
|
|
119
|
+
for (const name of [...listNodeSnapshots(runDir)].sort()) {
|
|
120
|
+
const snapshot = readJsonTolerant(nodeSnapshotPath(runDir, name.slice(0, -".json".length)));
|
|
121
|
+
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) continue;
|
|
122
|
+
const routing = /** @type {Record<string, any>} */ (snapshot).routing;
|
|
123
|
+
const mode = routing && typeof routing === "object" ? routing.assignments?.judgeIndependence : undefined;
|
|
124
|
+
if (typeof mode === "string" && mode) return mode;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
/**
|
|
102
130
|
* @param {string} runId
|
|
103
131
|
* @param {unknown} status
|
|
132
|
+
* @param {string|null} [judgeIndependence] the mode the launch recorded on the run snapshot
|
|
104
133
|
* @returns {BriefRun}
|
|
105
134
|
*/
|
|
106
|
-
function runBrief(runId, status) {
|
|
107
|
-
if (!status || typeof status !== "object" || Array.isArray(status)) return missingRunBrief(runId);
|
|
135
|
+
function runBrief(runId, status, judgeIndependence = null) {
|
|
136
|
+
if (!status || typeof status !== "object" || Array.isArray(status)) return missingRunBrief(runId, judgeIndependence);
|
|
108
137
|
const record = /** @type {Record<string, any>} */ (status);
|
|
109
138
|
const nodes = Array.isArray(record.nodes) ? record.nodes : [];
|
|
110
139
|
const usage = record.usage && typeof record.usage === "object" ? /** @type {Record<string, unknown>} */ (record.usage) : {};
|
|
@@ -122,6 +151,7 @@ function runBrief(runId, status) {
|
|
|
122
151
|
summary: typeof record.summary === "string" ? record.summary : "",
|
|
123
152
|
controller: typeof record.controller?.state === "string" ? record.controller.state : "none",
|
|
124
153
|
phase,
|
|
154
|
+
judgeIndependence,
|
|
125
155
|
done: nodes.filter((node) => node.status === "done" || node.status === "no-op").length,
|
|
126
156
|
total: nodes.length,
|
|
127
157
|
costUsd: finite(usage.costUsd),
|
|
@@ -139,10 +169,11 @@ function runBrief(runId, status) {
|
|
|
139
169
|
|
|
140
170
|
/**
|
|
141
171
|
* @param {string} runId
|
|
172
|
+
* @param {string|null} [judgeIndependence]
|
|
142
173
|
* @returns {BriefRun}
|
|
143
174
|
*/
|
|
144
|
-
function missingRunBrief(runId) {
|
|
145
|
-
return { id: runId, exists: false, summary: "no status.json yet", controller: "none", phase: null, done: 0, total: 0, costUsd: null, inputTokens: null, outputTokens: null, attention: [] };
|
|
175
|
+
function missingRunBrief(runId, judgeIndependence = null) {
|
|
176
|
+
return { id: runId, exists: false, summary: "no status.json yet", controller: "none", phase: null, judgeIndependence, done: 0, total: 0, costUsd: null, inputTokens: null, outputTokens: null, attention: [] };
|
|
146
177
|
}
|
|
147
178
|
|
|
148
179
|
/**
|
|
@@ -367,7 +398,8 @@ function runLine(run, cap, idCap) {
|
|
|
367
398
|
const hidden = run.attention.length - ATTENTION_LINE_LIMIT;
|
|
368
399
|
if (hidden > 0) attention.push(`${hidden} more`);
|
|
369
400
|
const detail = attention.length ? ` · ${attention.join("; ")}` : "";
|
|
370
|
-
|
|
401
|
+
const independence = run.judgeIndependence ? ` · judge independence ${boundedText(run.judgeIndependence, cap)}` : "";
|
|
402
|
+
return `- ${id}: ${boundedText(run.summary, cap)} · checkpoints ${run.done}/${run.total} · controller ${boundedText(run.controller, cap)}${independence}${detail}`;
|
|
371
403
|
}
|
|
372
404
|
|
|
373
405
|
/**
|
|
@@ -51,7 +51,7 @@ import { projectCampaignDecisions } from "./projection.mjs";
|
|
|
51
51
|
/** @typedef {{risk: string, impact: string, mitigation: string}} BriefRisk */
|
|
52
52
|
/** @typedef {{intent: string|null, expectedOutcome: string|null, successCriteria: BriefSuccessCriterion[], humanFacts: string[], calculatedFacts: string[], gaps: string[]}} BriefOpening */
|
|
53
53
|
/** @typedef {{human: string[], delegated: string[], journal: ProjectedDecision[], risks: BriefRisk[], evals: string[], gaps: string[]}} BriefDecisions */
|
|
54
|
-
/** @typedef {{campaign: string, specBaseline: string|null, specDigest: string, specPath: string, targetGitHead: string|null, planPath: string, planDigest: string, contractDigest: string, journalCursor: number, usageSampleCutoff: string|null}} BriefIdentity */
|
|
54
|
+
/** @typedef {{campaign: string, specBaseline: string|null, specDigest: string, specPath: string, targetGitHead: string|null, planPath: string, planDigest: string, contractDigest: string, judgeIndependence?: string|null, journalCursor: number, usageSampleCutoff: string|null}} BriefIdentity */
|
|
55
55
|
/** @typedef {{identity: BriefIdentity, opening: BriefOpening, coverage: BriefCoverage, graph: BriefGraph, decisions: BriefDecisions, estimate: BriefEstimate, decisionState: BriefDecisionState}} BriefModel */
|
|
56
56
|
|
|
57
57
|
/**
|
|
@@ -71,6 +71,7 @@ import { projectCampaignDecisions } from "./projection.mjs";
|
|
|
71
71
|
* @property {string|null} [usageSampleCutoff]
|
|
72
72
|
* @property {Projection} [projection]
|
|
73
73
|
* @property {BriefEstimateInput} [estimate]
|
|
74
|
+
* @property {string|null} [effectiveJudgeIndependence] the mode the launch recorded on the run snapshot; `undefined` falls back to the contract's own declaration.
|
|
74
75
|
*/
|
|
75
76
|
|
|
76
77
|
const SAMPLE_FLOOR = 5;
|
|
@@ -152,7 +153,19 @@ export function buildBriefModel(options) {
|
|
|
152
153
|
const contractNodes = asArray(contract.nodes);
|
|
153
154
|
const declarations = normalizeDeclarations(plan.phases);
|
|
154
155
|
const coverage = buildCoverage(parsedSpec, declarations, contractNodes, specPath, planPath);
|
|
155
|
-
|
|
156
|
+
// R36: the effective judge-independence mode the launch recorded on the run
|
|
157
|
+
// snapshot wins over the contract's own declaration, which never sees the
|
|
158
|
+
// machine config's opt-in. With no recorded value the contract remains the
|
|
159
|
+
// only source, exactly as before.
|
|
160
|
+
const judgeIndependence = options.effectiveJudgeIndependence === "same-vendor"
|
|
161
|
+
? "same-vendor"
|
|
162
|
+
: options.effectiveJudgeIndependence !== undefined
|
|
163
|
+
? null
|
|
164
|
+
: contract.judgeIndependence === "same-vendor" ? "same-vendor" : null;
|
|
165
|
+
const graphContract = { ...contract };
|
|
166
|
+
if (judgeIndependence === "same-vendor") graphContract.judgeIndependence = "same-vendor";
|
|
167
|
+
else delete graphContract.judgeIndependence;
|
|
168
|
+
const graph = buildGraph(graphContract, contractNodes);
|
|
156
169
|
const journalDecisions = options.projection ? projectCampaignDecisions(options.projection) : [];
|
|
157
170
|
// R16: an operator step the frozen plan carries as an explicit stop
|
|
158
171
|
// (`plan.humanSteps`, `src/plan/human-step.mjs`) is a human decision the
|
|
@@ -170,6 +183,7 @@ export function buildBriefModel(options) {
|
|
|
170
183
|
contractDigest: expectedContractDigest,
|
|
171
184
|
specPath,
|
|
172
185
|
specBaseline: typeof parsedSpec.frontMatter?.baseline === "string" ? parsedSpec.frontMatter.baseline : null,
|
|
186
|
+
judgeIndependence,
|
|
173
187
|
journalCursor: options.journalCursor ?? 0,
|
|
174
188
|
usageSampleCutoff: options.usageSampleCutoff ?? null,
|
|
175
189
|
});
|
|
@@ -576,7 +590,7 @@ function buildOpening(parsedSpec, coverage, graph, estimate, decisions) {
|
|
|
576
590
|
}
|
|
577
591
|
|
|
578
592
|
/**
|
|
579
|
-
* @param {{campaign: string, plan: AnyRecord, planPath: string, planDigest: string, contractDigest: string, specPath: string, specBaseline: string|null, journalCursor: number, usageSampleCutoff: string|null}} input
|
|
593
|
+
* @param {{campaign: string, plan: AnyRecord, planPath: string, planDigest: string, contractDigest: string, specPath: string, specBaseline: string|null, judgeIndependence: string|null, journalCursor: number, usageSampleCutoff: string|null}} input
|
|
580
594
|
* @returns {BriefIdentity}
|
|
581
595
|
*/
|
|
582
596
|
function buildIdentity(input) {
|
|
@@ -590,6 +604,7 @@ function buildIdentity(input) {
|
|
|
590
604
|
planPath: input.planPath,
|
|
591
605
|
planDigest: input.planDigest,
|
|
592
606
|
contractDigest: input.contractDigest,
|
|
607
|
+
judgeIndependence: input.judgeIndependence,
|
|
593
608
|
journalCursor: input.journalCursor,
|
|
594
609
|
usageSampleCutoff: input.usageSampleCutoff,
|
|
595
610
|
};
|
package/src/campaign/index.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { readProjectionState } from "./projection.mjs";
|
|
|
18
18
|
import { handoffFromState, materializeHandoff } from "./handoff.mjs";
|
|
19
19
|
import { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
|
|
20
20
|
|
|
21
|
-
export { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
|
|
21
|
+
export { markCampaignLedgerPreserved, preserveCampaignLedger, reledgerCampaignLedger, unpreservedLedgerCampaigns } from "./ledger.mjs";
|
|
22
22
|
|
|
23
23
|
/** @typedef {Record<string, unknown>} JsonObject */
|
|
24
24
|
/** @typedef {{path: string, digest: string}} CampaignContract */
|
|
@@ -27,7 +27,7 @@ export { preserveCampaignLedger, reledgerCampaignLedger } from "./ledger.mjs";
|
|
|
27
27
|
/** @typedef {{runId: string, node: string, passed: boolean|null, verdict: string|null}} RequirementNodeEvidence */
|
|
28
28
|
/** @typedef {{requirementId: string, status: "covered"|"open", nodes: RequirementNodeEvidence[]}} RequirementClosure */
|
|
29
29
|
/** @typedef {{oldPath: string, newPath: string, runIds: string[], at: string}} ContractReplacement */
|
|
30
|
-
/** @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 */
|
|
30
|
+
/** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], replacements?: ContractReplacement[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, requirements?: RequirementClosure[], ledgerPreserved?: boolean, createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
|
|
31
31
|
/** @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 */
|
|
32
32
|
/** @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 */
|
|
33
33
|
/** @typedef {{cursor: number, byte: number, size: number, projection: Projection}} ProjectionRecord */
|
|
@@ -130,10 +130,10 @@ export function resolveCampaign(runsDir, campaignId) {
|
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* @param {string} campaignPath
|
|
133
|
-
* @param {{at?: string, eventId?: string}} options
|
|
133
|
+
* @param {{at?: string, eventId?: string, ledgerInRepo?: boolean}} options
|
|
134
134
|
* @returns {{path: string, campaign: Campaign, ledgerFiles: string[], ledgerSkipped: {runId: string, source: string}[], worktrees: {removed: number, archived: string[]}}}
|
|
135
135
|
*/
|
|
136
|
-
export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID() } = {}) {
|
|
136
|
+
export function closeCampaign(campaignPath, { at = new Date().toISOString(), eventId = randomUUID(), ledgerInRepo = true } = {}) {
|
|
137
137
|
requireTimestamp(at, "at");
|
|
138
138
|
const campaign = readCampaign(campaignPath);
|
|
139
139
|
if (campaign.status === "closed") throw new Error(`campaign already closed: ${campaign.id}`);
|
|
@@ -154,23 +154,36 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
|
|
|
154
154
|
appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
|
|
155
155
|
// Preserve after the closed record exists: the ledger is the recomputable
|
|
156
156
|
// closed record, including requirements used by the north-star projector.
|
|
157
|
-
|
|
157
|
+
//
|
|
158
|
+
// A plain close keeps the evidence beside the campaign in the operator home.
|
|
159
|
+
// The versioned copy under docs/ is opt-in with --ledger-in-repo, so closing
|
|
160
|
+
// a campaign against a repository the operator does not own leaves the tree
|
|
161
|
+
// untouched. The library default stays the versioned copy: existing callers
|
|
162
|
+
// and already-committed ledgers keep their location, and the CLI is what
|
|
163
|
+
// defaults to the home.
|
|
164
|
+
const repoRoot = campaignRepoRoot(campaignPath);
|
|
165
|
+
const ledger = preserveCampaignLedger(campaignPath, repoRoot, ledgerInRepo ? undefined : join(campaignPath, "ledger"));
|
|
166
|
+
// A versioned preservation marks the record inside `preserveCampaignLedger`;
|
|
167
|
+
// re-reading returns the record the caller can read, marker included.
|
|
168
|
+
const persisted = readCampaign(campaignPath);
|
|
158
169
|
// A closed campaign resumes nothing, so the attempt worktrees its runs left
|
|
159
170
|
// go now, each archived under a ref first (releaseRunWorktrees).
|
|
160
171
|
const runsDir = resolve(campaignPath, "..", "..");
|
|
161
172
|
const worktrees = { removed: 0, archived: /** @type {string[]} */ ([]) };
|
|
162
173
|
for (const runId of campaign.linkedRunIds) {
|
|
163
|
-
const released = releaseRunWorktrees(
|
|
174
|
+
const released = releaseRunWorktrees(repoRoot, join(runsDir, runId), runId);
|
|
164
175
|
worktrees.removed += released.removed;
|
|
165
176
|
worktrees.archived.push(...released.archived);
|
|
166
177
|
}
|
|
167
|
-
return { path: campaignPath, campaign:
|
|
178
|
+
return { path: campaignPath, campaign: persisted, ledgerFiles: ledger.written, ledgerSkipped: ledger.skipped, worktrees };
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
/**
|
|
171
182
|
* Complete the versioned evidence ledger of a closed campaign. Unlike close,
|
|
172
|
-
* this operation
|
|
173
|
-
* surviving run sources beneath the repository's ledger directory
|
|
183
|
+
* this operation does not otherwise update the campaign record or journal: it
|
|
184
|
+
* adds surviving run sources beneath the repository's ledger directory and
|
|
185
|
+
* marks the record's `ledgerPreserved`, the one state the durable copy makes
|
|
186
|
+
* true.
|
|
174
187
|
*
|
|
175
188
|
* @param {string} campaignPath
|
|
176
189
|
* @returns {import("./ledger.mjs").LedgerReledger & {ledgerDir: string}}
|
package/src/campaign/ledger.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync,
|
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
|
|
8
8
|
import { readMetricNodeSnapshots } from "./metrics-command.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { writeJsonAtomic } from "../run/store.mjs";
|
|
10
|
+
import { CAMPAIGN_FILE, JOURNAL_FILE, LEDGER_SOURCE_MANIFEST_FILE, campaignsDir } from "./layout.mjs";
|
|
10
11
|
import { readCampaign } from "./record.mjs";
|
|
11
12
|
|
|
12
13
|
/** @typedef {{runId: string, source: string}} LedgerSkip */
|
|
@@ -27,19 +28,74 @@ export const EVENT_PROJECTION_FIELDS = Object.freeze([
|
|
|
27
28
|
/** Fields consumed by `projectMetrics`; notification messages and transport details never cross the ledger boundary. */
|
|
28
29
|
export const NOTIFY_PROJECTION_FIELDS = Object.freeze(["at", "dedupeKey", "attempt", "status"]);
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Mark a campaign's record as having its ledger preserved at the durable,
|
|
33
|
+
* versioned location under its registered repository. A record that never
|
|
34
|
+
* carried the marker reads as not preserved (`readCampaign`), so removal
|
|
35
|
+
* refuses until this runs. Idempotent: an already marked record is returned
|
|
36
|
+
* untouched, which keeps a repeated reledger byte-stable.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} campaignPath
|
|
39
|
+
* @returns {import("./index.mjs").Campaign}
|
|
40
|
+
*/
|
|
41
|
+
export function markCampaignLedgerPreserved(campaignPath) {
|
|
42
|
+
const campaign = readCampaign(campaignPath);
|
|
43
|
+
if (campaign.ledgerPreserved) return campaign;
|
|
44
|
+
const marked = { ...campaign, ledgerPreserved: true };
|
|
45
|
+
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), marked);
|
|
46
|
+
return marked;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The ids of the campaigns under `runsDir` whose ledger is not preserved at
|
|
51
|
+
* its durable, versioned repository location, sorted. A record written before
|
|
52
|
+
* the marker existed and a record that cannot be read both report as
|
|
53
|
+
* unpreserved: a removal that keeps evidence must refuse on the default and
|
|
54
|
+
* cannot let an unreadable record prove its evidence safe.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} runsDir
|
|
57
|
+
* @returns {string[]}
|
|
58
|
+
*/
|
|
59
|
+
export function unpreservedLedgerCampaigns(runsDir) {
|
|
60
|
+
const root = campaignsDir(runsDir);
|
|
61
|
+
if (!existsSync(root)) return [];
|
|
62
|
+
/** @type {string[]} */
|
|
63
|
+
const unpreserved = [];
|
|
64
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
65
|
+
if (!entry.isDirectory()) continue;
|
|
66
|
+
const campaignPath = join(root, entry.name);
|
|
67
|
+
if (!existsSync(join(campaignPath, CAMPAIGN_FILE))) continue;
|
|
68
|
+
try {
|
|
69
|
+
if (readCampaign(campaignPath).ledgerPreserved !== true) unpreserved.push(entry.name);
|
|
70
|
+
} catch {
|
|
71
|
+
unpreserved.push(entry.name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return unpreserved.sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
/**
|
|
31
78
|
* Copy a campaign's projector sources into its versioned ledger. Missing run
|
|
32
79
|
* sources are reported so a pruned run cannot make the close fail or look
|
|
33
80
|
* complete by accident. Repeated calls overwrite the same paths.
|
|
34
81
|
*
|
|
82
|
+
* `ledgerDir` is the exact destination. It defaults to the versioned ledger
|
|
83
|
+
* under `repoRoot/docs/campaigns/<id>/ledger`; `closeCampaign` passes the
|
|
84
|
+
* campaign directory in the operator home instead when `--ledger-in-repo` was
|
|
85
|
+
* not given, so the target repository is not dirtied by a plain close. A
|
|
86
|
+
* versioned write marks the campaign record's `ledgerPreserved`; a home write
|
|
87
|
+
* does not, because it does not survive an uninstall.
|
|
88
|
+
*
|
|
35
89
|
* @param {string} campaignPath
|
|
36
90
|
* @param {string} repoRoot
|
|
91
|
+
* @param {string|undefined} [ledgerDir]
|
|
37
92
|
* @returns {LedgerPreservation}
|
|
38
93
|
*/
|
|
39
|
-
export function preserveCampaignLedger(campaignPath, repoRoot) {
|
|
94
|
+
export function preserveCampaignLedger(campaignPath, repoRoot, ledgerDir = undefined) {
|
|
40
95
|
const campaign = readCampaign(campaignPath);
|
|
41
96
|
const runsDir = resolve(campaignPath, "..", "..");
|
|
42
|
-
const
|
|
97
|
+
const versioned = ledgerDir === undefined;
|
|
98
|
+
ledgerDir ??= join(repoRoot, "docs", "campaigns", campaign.id, "ledger");
|
|
43
99
|
mkdirSync(ledgerDir, { recursive: true });
|
|
44
100
|
/** @type {string[]} */
|
|
45
101
|
const written = [];
|
|
@@ -96,6 +152,13 @@ export function preserveCampaignLedger(campaignPath, repoRoot) {
|
|
|
96
152
|
const manifest = join(ledgerDir, LEDGER_SOURCE_MANIFEST_FILE);
|
|
97
153
|
writeFileSync(manifest, `${JSON.stringify({ schemaVersion: 1, absent: [...absent].sort(), preserved: [...preserved].sort() })}\n`);
|
|
98
154
|
written.push(manifest);
|
|
155
|
+
// The ledger exists, so a versioned preservation now marks the record. The
|
|
156
|
+
// already-copied campaign.json is refreshed afterwards so the durable copy
|
|
157
|
+
// carries the marker too; `written` keeps its original position and length.
|
|
158
|
+
if (versioned) {
|
|
159
|
+
markCampaignLedgerPreserved(campaignPath);
|
|
160
|
+
copyFileSync(join(campaignPath, CAMPAIGN_FILE), join(ledgerDir, CAMPAIGN_FILE));
|
|
161
|
+
}
|
|
99
162
|
return { written, skipped };
|
|
100
163
|
}
|
|
101
164
|
|
|
@@ -103,7 +166,9 @@ export function preserveCampaignLedger(campaignPath, repoRoot) {
|
|
|
103
166
|
* Complete an already closed ledger from the run files still in the operator
|
|
104
167
|
* home. A destination is replaced only when the source strictly extends its
|
|
105
168
|
* 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.
|
|
169
|
+
* truncated after close. Missing run sources are reported as gone. The write
|
|
170
|
+
* is always versioned, so it marks the campaign record's `ledgerPreserved`
|
|
171
|
+
* and refreshes the ledger's copy of the record.
|
|
107
172
|
*
|
|
108
173
|
* @param {string} campaignPath
|
|
109
174
|
* @param {string} repoRoot
|
|
@@ -164,6 +229,9 @@ export function reledgerCampaignLedger(campaignPath, repoRoot) {
|
|
|
164
229
|
if (copyIfStrictExtension(source, destination)) copied.push({ runId: campaign.id, source: `proposals/${relative}` });
|
|
165
230
|
}
|
|
166
231
|
}
|
|
232
|
+
// The ledger is durable now, so mark the campaign record before refreshing
|
|
233
|
+
// the ledger's own copy of it when that copy is not already a closed record.
|
|
234
|
+
markCampaignLedgerPreserved(campaignPath);
|
|
167
235
|
const ledgerCampaign = join(ledgerDir, CAMPAIGN_FILE);
|
|
168
236
|
if (existsSync(ledgerCampaign)) {
|
|
169
237
|
const existing = readJsonObject(ledgerCampaign);
|