faberun 0.17.0 → 0.17.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.17.0",
3
+ "version": "0.17.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": {
@@ -49,9 +49,14 @@ export function campaignIdOf(campaignPath) {
49
49
  }
50
50
  }
51
51
  /**
52
+ * The campaign record's schema, in one home. Exported so the repair verb in
53
+ * `run/migrate.mjs` can prove a record's only defect is the absent `status`
54
+ * field by validating the record with that default applied, instead of the
55
+ * schema being restated beside the repair.
56
+ *
52
57
  * @param {unknown} campaign
53
58
  */
54
- function validateCampaign(campaign) {
59
+ export function validateCampaign(campaign) {
55
60
  if (!campaign || typeof campaign !== "object" || Array.isArray(campaign)) {
56
61
  throw new TypeError("campaign.json must be an object");
57
62
  }
@@ -24,6 +24,16 @@
24
24
  * redone, by the next run: it verifies the published copy still holds every
25
25
  * byte the original holds and removes the original.
26
26
  *
27
+ * A published copy that predates a path the original later gained can never
28
+ * pass that verification, and the branch that sees one runs no copy for the
29
+ * path to reach: measured 2026-09-21, `.runs/control/second-opinions` was
30
+ * written into this repository's original tree after the home side had
31
+ * become authoritative, and every migrate run refused on it identically.
32
+ * Repairing the copy in place would publish state nobody verified, so the
33
+ * refusal is made to carry the resolution instead — it names what to carry
34
+ * into the published copy by hand, and the run after that action completes
35
+ * the move.
36
+ *
27
37
  * Idempotent by the same shape. A second run after a completed migration
28
38
  * finds no legacy root and reports nothing to move — the normal case for an
29
39
  * operator rerunning the command to be sure. A re-run after an interrupted
@@ -34,10 +44,18 @@
34
44
  * composed here from `projectsDir` plus names `run/paths.mjs` keeps private;
35
45
  * widening that module's surface for one caller is worse than spelling the
36
46
  * two literals here, next to this comment.
47
+ *
48
+ * `repairCampaignRecords` belongs here for the same reason the move does:
49
+ * both bring state an older faberun wrote to the shape the current code
50
+ * reads. Read keeps refusing a record it cannot trust, so the repair is a
51
+ * verb rather than a silent default on read, and it names every record it
52
+ * repairs and the field it filled — a repair that happens unreported is
53
+ * corruption by another name.
37
54
  */
38
- import { cpSync, existsSync, lstatSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync } from "node:fs";
55
+ import { cpSync, existsSync, lstatSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
39
56
  import { join } from "node:path";
40
- import { campaignsDir } from "../campaign/layout.mjs";
57
+ import { CAMPAIGN_FILE, campaignsDir } from "../campaign/layout.mjs";
58
+ import { validateCampaign } from "../campaign/record.mjs";
41
59
  import { readRemotes } from "../cli/project.mjs";
42
60
  import { faberunHome } from "../host/home.mjs";
43
61
  import { projectsDir, registerProject } from "../host/projects.mjs";
@@ -101,7 +119,7 @@ export function migrateRunState(cwd, options = {}) {
101
119
  // became authoritative; this branch never writes to the copy, so nothing
102
120
  // of theirs is at risk.
103
121
  if (existsSync(target)) {
104
- verifyCopy(legacy, target);
122
+ verifyCopy(legacy, target, "published");
105
123
  const runs = countRunDirs(legacy);
106
124
  const campaigns = countCampaigns(legacy);
107
125
  refuseLiveLeases(leasesOf(legacy));
@@ -117,7 +135,7 @@ export function migrateRunState(cwd, options = {}) {
117
135
  // moment the removal ran; verbatim keeps the literal target, so relative
118
136
  // links (the worktrees' dependency symlinks) survive the move.
119
137
  cpSync(legacy, staging, { recursive: true, verbatimSymlinks: true });
120
- verifyCopy(legacy, staging);
138
+ verifyCopy(legacy, staging, "staging");
121
139
  verifyNothingExtra(legacy, staging);
122
140
  const runs = countRunDirs(legacy);
123
141
  const campaigns = countCampaigns(legacy);
@@ -136,6 +154,65 @@ export function migrateRunState(cwd, options = {}) {
136
154
  return { moved: true, legacy, target, runs, campaigns };
137
155
  }
138
156
 
157
+ /**
158
+ * Repair every campaign record under `runsDir` whose only defect is the
159
+ * absent `status` field: a record written before the field existed carries
160
+ * id, goal and linkedRunIds but no status, and discovery would report it
161
+ * corrupt forever. The repair fills the default current writes apply, writes
162
+ * the record back, and names every record it repaired and the field it
163
+ * filled in the returned list.
164
+ *
165
+ * The default is `closed` because an active campaign is one the product is
166
+ * currently driving, and a record written before the field existed has not
167
+ * been driven since.
168
+ *
169
+ * The predicate is the validator itself, run on the record with the default
170
+ * applied, so the repair cannot widen: a record that still fails validation
171
+ * with a valid status — malformed JSON, a missing id, a goal that is not
172
+ * text, a linkedRunIds that is not an array, a status present but wrong — is
173
+ * left exactly as discovery reports it. A record already carrying status is
174
+ * never rewritten, so a second run repairs nothing and writes nothing.
175
+ *
176
+ * @param {string} runsDir
177
+ * @returns {{id: string, field: string}[]} one entry per repaired record
178
+ */
179
+ export function repairCampaignRecords(runsDir) {
180
+ const campaigns = campaignsDir(runsDir);
181
+ if (!existsSync(campaigns)) return [];
182
+ /** @type {{id: string, field: string}[]} */
183
+ const repaired = [];
184
+ for (const entry of readdirSync(campaigns, { withFileTypes: true })) {
185
+ if (!entry.isDirectory()) continue;
186
+ const file = join(campaigns, entry.name, CAMPAIGN_FILE);
187
+ if (!existsSync(file)) continue;
188
+ let parsed;
189
+ try {
190
+ parsed = JSON.parse(readFileSync(file, "utf8"));
191
+ } catch {
192
+ // Unreadable or unparseable: there is no absent field to fill, and
193
+ // discovery keeps reporting the record corrupt.
194
+ continue;
195
+ }
196
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
197
+ const record = /** @type {Record<string, unknown>} */ (parsed);
198
+ // A status present but wrong is corruption, not age: only the absent
199
+ // field is repairable.
200
+ if (record.status !== undefined) continue;
201
+ record.status = "closed";
202
+ try {
203
+ validateCampaign(record);
204
+ } catch {
205
+ // The record fails the schema for a reason other than the absent
206
+ // status; the fill above dies with this in-memory object and the file
207
+ // is never written, so discovery's corrupt report stays true.
208
+ continue;
209
+ }
210
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
211
+ repaired.push({ id: String(record.id), field: "status" });
212
+ }
213
+ return repaired;
214
+ }
215
+
139
216
  /**
140
217
  * Every controller lock under `tree` whose holder is still alive. The lock
141
218
  * file's name is spelled here because `run/lock.mjs` keeps the constant
@@ -184,22 +261,28 @@ function refuseLiveLeases(leases) {
184
261
  * of what must be true, and byte-for-byte equality is the strongest check
185
262
  * that proves it without trusting the copy step's own bookkeeping.
186
263
  *
264
+ * `role` is which copy this is, and it only chooses what a refusal tells the
265
+ * operator to do about it: a staging copy is discarded and recopied by the
266
+ * next run, while a published copy is the one every reader answers while
267
+ * both trees exist, so only the operator can carry the difference into it.
268
+ *
187
269
  * @param {string} source
188
270
  * @param {string} copy
271
+ * @param {"staging"|"published"} role
189
272
  * @returns {void}
190
273
  */
191
- function verifyCopy(source, copy) {
274
+ function verifyCopy(source, copy, role) {
192
275
  for (const entry of readdirSync(source, { withFileTypes: true })) {
193
276
  const from = join(source, entry.name);
194
277
  const to = join(copy, entry.name);
195
278
  if (entry.isDirectory()) {
196
279
  // lstat, not stat: a copied symlink to a directory must not pass as
197
280
  // the directory it points at.
198
- if (!lstatSync(to, { throwIfNoEntry: false })?.isDirectory()) throw verifyFailure(from, to);
199
- verifyCopy(from, to);
281
+ if (!lstatSync(to, { throwIfNoEntry: false })?.isDirectory()) throw verifyFailure(from, to, role);
282
+ verifyCopy(from, to, role);
200
283
  } else if (entry.isSymbolicLink()) {
201
- if (readLinkOrUndefined(to) !== readlinkSync(from)) throw verifyFailure(from, to);
202
- } else if (readOrUndefined(to)?.equals(readFileSync(from)) !== true) throw verifyFailure(from, to);
284
+ if (readLinkOrUndefined(to) !== readlinkSync(from)) throw verifyFailure(from, to, role);
285
+ } else if (readOrUndefined(to)?.equals(readFileSync(from)) !== true) throw verifyFailure(from, to, role);
203
286
  }
204
287
  }
205
288
 
@@ -233,9 +316,23 @@ function verifyNothingExtra(source, copy) {
233
316
  }
234
317
  }
235
318
 
236
- /** @param {string} from @param {string} to @returns {Error} */
237
- function verifyFailure(from, to) {
238
- return new Error(`migration copy does not verify: ${from} is missing or different at ${to}; nothing was published or removed`);
319
+ /**
320
+ * The refusal is the only way out of a copy that does not verify, so it
321
+ * names the action that lets a later run finish, not just the mismatch. For
322
+ * a staging copy that action is nothing: the next run discards the staging
323
+ * and copies anew. For a published copy there is no next-run help — the
324
+ * branch runs no copy, and migrating must not repair a copy behind the
325
+ * operator's back — so the refusal carries the whole resolution: what to
326
+ * carry into the published copy, by hand, and that the run after that
327
+ * completes the move.
328
+ *
329
+ * @param {string} from @param {string} to @param {"staging"|"published"} role @returns {Error}
330
+ */
331
+ function verifyFailure(from, to, role) {
332
+ const action = role === "published"
333
+ ? `${to} is the copy every reader answers while both trees exist: move what ${from} holds that it lacks into it and settle any differing bytes by hand, then run migrate again`
334
+ : "the staging copy is discarded and recopied by the next run, so run migrate again";
335
+ return new Error(`migration copy does not verify: ${from} is missing or different at ${to}; nothing was published or removed — ${action}`);
239
336
  }
240
337
 
241
338
  /** @param {string} path @returns {Error} */