crewhaus 0.1.8 → 0.2.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.
Files changed (136) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +172 -0
  25. package/dist/dataset-mine.js +403 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11601 -964
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/scaffold-evals.d.ts +138 -0
  107. package/dist/scaffold-evals.js +410 -0
  108. package/dist/scope-audit-drift.d.ts +139 -0
  109. package/dist/scope-audit-drift.js +260 -0
  110. package/dist/security-corpus.d.ts +237 -0
  111. package/dist/security-corpus.js +516 -0
  112. package/dist/security-digest.d.ts +173 -0
  113. package/dist/security-digest.js +650 -0
  114. package/dist/sessions-index.d.ts +27 -0
  115. package/dist/sessions-index.js +51 -0
  116. package/dist/slo-doctor.d.ts +67 -0
  117. package/dist/slo-doctor.js +119 -0
  118. package/dist/slo-sink.d.ts +96 -0
  119. package/dist/slo-sink.js +107 -0
  120. package/dist/spec-changelog.d.ts +102 -0
  121. package/dist/spec-changelog.js +237 -0
  122. package/dist/state-backup.d.ts +223 -0
  123. package/dist/state-backup.js +648 -0
  124. package/dist/tools-cli.d.ts +170 -0
  125. package/dist/tools-cli.js +298 -0
  126. package/dist/triage.d.ts +202 -0
  127. package/dist/triage.js +403 -0
  128. package/dist/upgrade.d.ts +57 -0
  129. package/dist/upgrade.js +113 -0
  130. package/dist/version.d.ts +6 -0
  131. package/dist/version.js +27 -0
  132. package/dist/voice-eval.d.ts +138 -0
  133. package/dist/voice-eval.js +309 -0
  134. package/dist/watch.d.ts +58 -0
  135. package/dist/watch.js +97 -0
  136. package/package.json +89 -65
@@ -0,0 +1,648 @@
1
+ /**
2
+ * Item 69 — `crewhaus state backup|restore`: snapshot and transport the
3
+ * cwd-local `.crewhaus/` state directory.
4
+ *
5
+ * A harness's entire accumulated state — sessions, feedback, memories,
6
+ * datasets, spec registry, optimize runs, durable-state sqlite — lives in
7
+ * `.crewhaus/` next to wherever the harness runs. That locality is a feature
8
+ * (the standalone-harness convention) until the box dies, or until feedback
9
+ * captured on a DEPLOYED bot needs to travel back to the dev machine before
10
+ * `crewhaus distill` can learn from it. `backup` packs the dir into a
11
+ * gzipped tarball with a manifest; `restore` unpacks it — full replace
12
+ * (`--force`, existing state moved aside, never deleted) or additive merges
13
+ * (`--merge feedback|all`).
14
+ *
15
+ * Like `feedback.ts` / `justification-gate.ts`, this module has NO
16
+ * import-time side effects (the entry file `index.ts` runs a top-level argv
17
+ * switch and so cannot be imported by a test without executing the CLI).
18
+ * The exported functions do touch the filesystem and spawn the system `tar`
19
+ * binary: Bun (1.3.x) ships gzip (`Bun.gzipSync`) but no tar API and the
20
+ * repo takes no archive dependency, so the ubiquitous system `tar` (bsdtar
21
+ * on macOS/Windows, GNU tar on Linux) does the packing/unpacking via
22
+ * `Bun.spawn` argv arrays (no shell). Backup NEVER modifies a source file:
23
+ * every archived file is staged as an exact byte copy — which is what makes
24
+ * the round-trip byte-identical, so a hash-chained `.crewhaus/audit` dir
25
+ * still passes `@crewhaus/audit-log`'s `verify()` after backup → restore.
26
+ *
27
+ * SQLITE SAFETY: a live `@crewhaus/durable-state` writer (WAL journaling)
28
+ * can tear a naive byte copy — the main db file alone is missing whatever
29
+ * sits in `-wal`. Any file carrying the SQLite magic header is therefore
30
+ * snapshotted through `bun:sqlite`: open read-only, `db.serialize()` (the
31
+ * sqlite serialize API — a consistent point-in-time image with WAL frames
32
+ * folded in), and the snapshot bytes are archived in place of the raw file;
33
+ * its `-wal`/`-shm`/`-journal` sidecars are dropped as folded. When a
34
+ * snapshot fails (corrupt file, exclusive lock) the raw bytes are copied
35
+ * instead and the manifest records `sqliteConsistent: false` so the
36
+ * operator knows the copy may be torn.
37
+ *
38
+ * Deliberately OUT OF SCOPE: S3/R2 upload (sync the tarball with your own
39
+ * tooling — `aws s3 cp`, rclone, …) and a scheduled-cron template (a
40
+ * `templates/` convention is landing on another branch).
41
+ */
42
+ import { Database } from "bun:sqlite";
43
+ import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
44
+ import { tmpdir } from "node:os";
45
+ import { dirname, join, resolve } from "node:path";
46
+ import { extractFeedbackRecords, mergeFeedback } from "./feedback";
47
+ /** Manifest file name at the archive root (NOT restored into `.crewhaus`). */
48
+ export const MANIFEST_FILENAME = "backup-manifest.json";
49
+ /** First 16 bytes of every SQLite main database file: "SQLite format 3\\0"
50
+ * (spelled as code points so the source carries no literal NUL). */
51
+ const SQLITE_MAGIC_BYTES = new Uint8Array([
52
+ 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00,
53
+ ]);
54
+ /** Sidecar suffixes a live sqlite writer leaves next to the main db file. */
55
+ const SQLITE_SIDECAR_SUFFIXES = ["-wal", "-shm", "-journal"];
56
+ /** Thrown for every operational failure (missing dir, tar failure, bad
57
+ * archive). The CLI entry file catches it and routes the message through
58
+ * `die()`; tests assert on `.message` without the process exiting. The
59
+ * `name` stays a plain string so `StateRestoreRefusedError` can narrow it. */
60
+ export class StateBackupError extends Error {
61
+ name = "StateBackupError";
62
+ }
63
+ /** Thrown when `restore` (without --force/--merge) would overwrite an
64
+ * existing non-empty `.crewhaus`. Carries a preformatted message listing
65
+ * what is there and how to proceed. */
66
+ export class StateRestoreRefusedError extends StateBackupError {
67
+ stateDir;
68
+ name = "StateRestoreRefusedError";
69
+ constructor(stateDir, entries) {
70
+ const listing = entries
71
+ .map((e) => (e.kind === "dir" ? `${e.name}/ (${e.files} file(s))` : e.name))
72
+ .join(", ");
73
+ super([
74
+ `refusing to overwrite existing non-empty ${stateDir}`,
75
+ ` contains: ${listing}`,
76
+ " rerun with --force to replace it (the current dir is moved aside to " +
77
+ ".crewhaus.bak-<ts>, never deleted), or --merge feedback|all to fold " +
78
+ "the archive into it",
79
+ ].join("\n"));
80
+ this.stateDir = stateDir;
81
+ }
82
+ }
83
+ /** Pure manifest assembly from the archived file list + backup metadata. */
84
+ export function buildManifest(files, meta) {
85
+ const subdirs = {};
86
+ let totalFiles = 0;
87
+ let totalBytes = 0;
88
+ for (const f of files) {
89
+ const slash = f.relPath.indexOf("/");
90
+ const top = slash === -1 ? "." : f.relPath.slice(0, slash);
91
+ const stat = subdirs[top] ?? { files: 0, bytes: 0 };
92
+ stat.files += 1;
93
+ stat.bytes += f.bytes;
94
+ subdirs[top] = stat;
95
+ totalFiles += 1;
96
+ totalBytes += f.bytes;
97
+ }
98
+ return {
99
+ schemaVersion: 1,
100
+ createdAt: meta.createdAt,
101
+ sourceDir: meta.sourceDir,
102
+ crewhausVersion: meta.crewhausVersion,
103
+ totals: { files: totalFiles, bytes: totalBytes },
104
+ subdirs,
105
+ sqliteConsistent: meta.sqliteConsistent,
106
+ sqlite: meta.sqlite,
107
+ };
108
+ }
109
+ /** Narrow a parsed JSON value to a BackupManifest (tolerant structural check —
110
+ * a hand-made or future-versioned manifest should degrade to a warning, not
111
+ * a crash). */
112
+ export function isBackupManifest(value) {
113
+ if (typeof value !== "object" || value === null)
114
+ return false;
115
+ const v = value;
116
+ return (v["schemaVersion"] === 1 &&
117
+ typeof v["createdAt"] === "string" &&
118
+ typeof v["sourceDir"] === "string" &&
119
+ typeof v["crewhausVersion"] === "string" &&
120
+ typeof v["totals"] === "object" &&
121
+ v["totals"] !== null &&
122
+ typeof v["subdirs"] === "object" &&
123
+ v["subdirs"] !== null);
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Pure helpers — names, exclude globs, sqlite detection, merge planning
127
+ // ---------------------------------------------------------------------------
128
+ /** Collapse a harness name / dir basename to a filename-safe label. */
129
+ export function sanitizeBackupLabel(label) {
130
+ const cleaned = label.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
131
+ return cleaned === "" ? "state" : cleaned;
132
+ }
133
+ /** Default tarball name: `crewhaus-state-<label>-<ISO date>.tar.gz`. */
134
+ export function defaultBackupFileName(label, now) {
135
+ return `crewhaus-state-${sanitizeBackupLabel(label)}-${now.toISOString().slice(0, 10)}.tar.gz`;
136
+ }
137
+ /** Split a comma-separated `--exclude` value into trimmed non-empty globs. */
138
+ export function parseExcludeGlobs(raw) {
139
+ if (raw === undefined)
140
+ return [];
141
+ return raw
142
+ .split(",")
143
+ .map((g) => g.trim())
144
+ .filter((g) => g !== "");
145
+ }
146
+ /**
147
+ * Whether a state-dir-relative path (always "/"-separated) matches any
148
+ * exclude glob. A glob is matched against the full relative path; a bare
149
+ * pattern (no "/") additionally matches any single path segment, mirroring
150
+ * tar's basename `--exclude` ergonomics — so `sessions` drops the whole
151
+ * subdir and `*.sqlite` drops sqlite files anywhere in the tree.
152
+ */
153
+ export function isExcluded(relPath, globs) {
154
+ for (const g of globs) {
155
+ const glob = new Bun.Glob(g);
156
+ if (glob.match(relPath))
157
+ return true;
158
+ if (!g.includes("/") && relPath.split("/").some((segment) => glob.match(segment))) {
159
+ return true;
160
+ }
161
+ }
162
+ return false;
163
+ }
164
+ /** Whether `bytes` starts with the 16-byte SQLite main-db magic header. */
165
+ export function isSqliteHeader(bytes) {
166
+ if (bytes.length < SQLITE_MAGIC_BYTES.length)
167
+ return false;
168
+ for (let i = 0; i < SQLITE_MAGIC_BYTES.length; i++) {
169
+ if (bytes[i] !== SQLITE_MAGIC_BYTES[i])
170
+ return false;
171
+ }
172
+ return true;
173
+ }
174
+ /** For a `-wal`/`-shm`/`-journal` sidecar path, the main db path it belongs
175
+ * to; undefined for everything else. */
176
+ export function sqliteSidecarBase(relPath) {
177
+ for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
178
+ if (relPath.endsWith(suffix))
179
+ return relPath.slice(0, -suffix.length);
180
+ }
181
+ return undefined;
182
+ }
183
+ /** Plan a `--merge all` additive copy: only files that don't exist locally
184
+ * are copied; existing local files always win. Pure via the injected
185
+ * existence predicate. */
186
+ export function planAdditiveMerge(archiveFiles, existsLocally) {
187
+ const copy = [];
188
+ const skip = [];
189
+ for (const f of archiveFiles) {
190
+ (existsLocally(f) ? skip : copy).push(f);
191
+ }
192
+ return { copy, skip };
193
+ }
194
+ function feedbackKey(r) {
195
+ return `${r.sessionId}#${r.turnNumber}`;
196
+ }
197
+ /** Key-order-insensitive record identity, so a re-folded record that carries
198
+ * exactly the local information counts as unchanged. */
199
+ function canonicalRecord(r) {
200
+ const rec = r;
201
+ const sorted = {};
202
+ for (const k of Object.keys(rec).sort())
203
+ sorted[k] = rec[k];
204
+ return JSON.stringify(sorted);
205
+ }
206
+ /**
207
+ * Plan a `--merge feedback` fold using the exact dedupe semantics `crewhaus
208
+ * distill` applies: `mergeFeedback` collapses records to one per
209
+ * (sessionId, turnNumber), newest field wins, and a later comment-only
210
+ * record never erases an earlier rating. Local and archived records are
211
+ * each folded first, then each archived fold is folded AGAINST the local
212
+ * fold for its key — imported only when the archive actually contributes
213
+ * something (new key, or a fold that differs from the local one). Running
214
+ * the same merge twice is therefore a no-op.
215
+ */
216
+ export function planFeedbackMerge(local, archived) {
217
+ const localByKey = new Map();
218
+ for (const r of mergeFeedback(local))
219
+ localByKey.set(feedbackKey(r), r);
220
+ const toImport = [];
221
+ let added = 0;
222
+ let updated = 0;
223
+ let unchanged = 0;
224
+ for (const a of mergeFeedback(archived)) {
225
+ const l = localByKey.get(feedbackKey(a));
226
+ if (l === undefined) {
227
+ toImport.push(a);
228
+ added += 1;
229
+ continue;
230
+ }
231
+ const folded = mergeFeedback([l, a])[0];
232
+ if (canonicalRecord(folded) === canonicalRecord(l)) {
233
+ unchanged += 1;
234
+ }
235
+ else {
236
+ toImport.push(folded);
237
+ updated += 1;
238
+ }
239
+ }
240
+ return { toImport, added, updated, unchanged };
241
+ }
242
+ function walkFiles(root) {
243
+ const files = [];
244
+ const dirs = [];
245
+ const symlinks = [];
246
+ const visit = (rel) => {
247
+ const abs = rel === "" ? root : join(root, rel);
248
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
249
+ const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
250
+ if (entry.isSymbolicLink()) {
251
+ symlinks.push(childRel);
252
+ }
253
+ else if (entry.isDirectory()) {
254
+ dirs.push(childRel);
255
+ visit(childRel);
256
+ }
257
+ else if (entry.isFile()) {
258
+ files.push(childRel);
259
+ }
260
+ }
261
+ };
262
+ visit("");
263
+ files.sort();
264
+ dirs.sort();
265
+ symlinks.sort();
266
+ return { files, dirs, symlinks };
267
+ }
268
+ /** Run the system `tar` binary (argv array — no shell). Throws
269
+ * StateBackupError with tar's stderr on a non-zero exit. */
270
+ async function runTar(args) {
271
+ let proc;
272
+ try {
273
+ proc = Bun.spawn(["tar", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
274
+ }
275
+ catch (err) {
276
+ const detail = err.message;
277
+ throw new StateBackupError(`could not spawn the system \`tar\` binary (${detail}) — crewhaus state backup/restore needs tar on PATH`);
278
+ }
279
+ const [stdout, stderr, exitCode] = await Promise.all([
280
+ new Response(proc.stdout).text(),
281
+ new Response(proc.stderr).text(),
282
+ proc.exited,
283
+ ]);
284
+ if (exitCode !== 0) {
285
+ throw new StateBackupError(`tar ${args[0]} failed (exit ${exitCode}): ${stderr.trim()}`);
286
+ }
287
+ return stdout;
288
+ }
289
+ /** Read the first 16 bytes of a file and test the SQLite magic. */
290
+ function fileHasSqliteHeader(absPath) {
291
+ const buf = new Uint8Array(SQLITE_MAGIC_BYTES.length);
292
+ let fd;
293
+ try {
294
+ fd = openSync(absPath, "r");
295
+ }
296
+ catch {
297
+ return false;
298
+ }
299
+ try {
300
+ const n = readSync(fd, buf, 0, buf.length, 0);
301
+ return n === buf.length && isSqliteHeader(buf);
302
+ }
303
+ finally {
304
+ closeSync(fd);
305
+ }
306
+ }
307
+ /**
308
+ * Archive `stateDir` to a gzipped tarball. Every regular file is staged as
309
+ * an exact byte copy (sqlite files as a consistent read-only snapshot — see
310
+ * the file header), a manifest is written at the archive root, and the
311
+ * system `tar` packs the staging dir. Source files are never modified.
312
+ */
313
+ export async function createStateBackup(opts) {
314
+ const stateDir = resolve(opts.stateDir);
315
+ if (!existsSync(stateDir) || !statSync(stateDir).isDirectory()) {
316
+ throw new StateBackupError(`no state directory at ${stateDir} — nothing to back up`);
317
+ }
318
+ const globs = opts.excludeGlobs ?? [];
319
+ const warnings = [];
320
+ const { files, dirs, symlinks } = walkFiles(stateDir);
321
+ for (const s of symlinks) {
322
+ warnings.push(`skipped symlink ${s} (backups archive regular files only)`);
323
+ }
324
+ const excluded = [];
325
+ const kept = [];
326
+ for (const f of files) {
327
+ (isExcluded(f, globs) ? excluded : kept).push(f);
328
+ }
329
+ const outFile = resolve(opts.outFile);
330
+ mkdirSync(dirname(outFile), { recursive: true });
331
+ const staging = mkdtempSync(join(tmpdir(), "crewhaus-state-backup-"));
332
+ try {
333
+ // Preserve the directory shape (including empty dirs) so a restore
334
+ // reproduces the layout, not just the files.
335
+ for (const d of dirs) {
336
+ if (!isExcluded(d, globs))
337
+ mkdirSync(join(staging, d), { recursive: true });
338
+ }
339
+ // SQLITE SAFETY — snapshot every magic-headed file via a read-only
340
+ // serialize; fall back to a raw byte copy (recorded in the manifest)
341
+ // when the snapshot fails.
342
+ const snapshotted = [];
343
+ const copiedRaw = [];
344
+ const foldedSidecars = new Set();
345
+ let sqliteConsistent = true;
346
+ const keptSet = new Set(kept);
347
+ for (const rel of kept) {
348
+ if (!fileHasSqliteHeader(join(stateDir, rel)))
349
+ continue;
350
+ try {
351
+ const db = new Database(join(stateDir, rel), { readonly: true });
352
+ let snapshot;
353
+ try {
354
+ snapshot = db.serialize();
355
+ }
356
+ finally {
357
+ db.close();
358
+ }
359
+ mkdirSync(dirname(join(staging, rel)), { recursive: true });
360
+ writeFileSync(join(staging, rel), snapshot);
361
+ // Adversarial-review F4: `serialize()` preserves the source's
362
+ // journal mode, so a WAL-mode source yields a WAL-mode snapshot —
363
+ // which a READ-ONLY consumer cannot open (SQLite needs to create
364
+ // `-wal`/`-shm` on the first open of a WAL db, and we archive no
365
+ // sidecars). Flip the STAGED copy to a rollback journal before it
366
+ // is packed; the live source db is untouched. A failure here falls
367
+ // through to the raw-copy path below like any other snapshot error.
368
+ const staged = new Database(join(staging, rel));
369
+ try {
370
+ staged.run("PRAGMA journal_mode=DELETE");
371
+ }
372
+ finally {
373
+ staged.close();
374
+ }
375
+ // The mode flip itself can leave a stray `-shm` beside the staged
376
+ // file (observed on bun 1.3 / macOS even after close) — scrub any
377
+ // sidecars so they don't get packed.
378
+ for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
379
+ rmSync(join(staging, `${rel}${suffix}`), { force: true });
380
+ }
381
+ snapshotted.push(rel);
382
+ // The snapshot folds WAL frames in; archiving the sidecars alongside
383
+ // it would restore a db whose WAL disagrees with its main file.
384
+ for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
385
+ const sidecar = `${rel}${suffix}`;
386
+ if (keptSet.has(sidecar))
387
+ foldedSidecars.add(sidecar);
388
+ }
389
+ }
390
+ catch (err) {
391
+ sqliteConsistent = false;
392
+ copiedRaw.push(rel);
393
+ const detail = err.message;
394
+ warnings.push(`could not snapshot sqlite file ${rel} (${detail}) — copied raw bytes instead; the copy may be inconsistent if a writer was active (manifest records sqliteConsistent: false)`);
395
+ }
396
+ }
397
+ const snapshottedSet = new Set(snapshotted);
398
+ const archived = [];
399
+ for (const rel of kept) {
400
+ if (foldedSidecars.has(rel))
401
+ continue;
402
+ const dst = join(staging, rel);
403
+ if (!snapshottedSet.has(rel)) {
404
+ mkdirSync(dirname(dst), { recursive: true });
405
+ copyFileSync(join(stateDir, rel), dst);
406
+ }
407
+ archived.push({ relPath: rel, bytes: statSync(dst).size });
408
+ }
409
+ const manifest = buildManifest(archived, {
410
+ createdAt: (opts.now?.() ?? new Date()).toISOString(),
411
+ sourceDir: stateDir,
412
+ crewhausVersion: opts.crewhausVersion,
413
+ sqliteConsistent,
414
+ sqlite: {
415
+ snapshotted,
416
+ copiedRaw,
417
+ foldedSidecars: [...foldedSidecars].sort(),
418
+ },
419
+ });
420
+ writeFileSync(join(staging, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`);
421
+ await runTar(["-czf", outFile, "-C", staging, "."]);
422
+ return { manifest, outFile, excluded, warnings };
423
+ }
424
+ finally {
425
+ rmSync(staging, { recursive: true, force: true });
426
+ }
427
+ }
428
+ /** What (if anything) currently occupies the target `.crewhaus` dir. */
429
+ export function inspectStateDir(stateDir) {
430
+ if (!existsSync(stateDir))
431
+ return { state: "absent" };
432
+ const top = readdirSync(stateDir, { withFileTypes: true });
433
+ if (top.length === 0)
434
+ return { state: "empty" };
435
+ const entries = top
436
+ .map((e) => {
437
+ if (!e.isDirectory())
438
+ return { name: e.name, kind: "file", files: 1 };
439
+ return { name: e.name, kind: "dir", files: walkFiles(join(stateDir, e.name)).files.length };
440
+ })
441
+ .sort((a, b) => a.name.localeCompare(b.name));
442
+ return { state: "occupied", entries };
443
+ }
444
+ function assertArchiveExists(archiveFile) {
445
+ if (!existsSync(archiveFile)) {
446
+ throw new StateBackupError(`archive not found at ${archiveFile}`);
447
+ }
448
+ }
449
+ /** Extract `archiveFile` into a fresh temp dir under `parentDir` and return
450
+ * the temp path (caller cleans up). */
451
+ async function extractToTemp(archiveFile, parentDir) {
452
+ mkdirSync(parentDir, { recursive: true });
453
+ const temp = mkdtempSync(join(parentDir, ".crewhaus.extract-"));
454
+ try {
455
+ await runTar(["-xzf", archiveFile, "-C", temp]);
456
+ }
457
+ catch (err) {
458
+ rmSync(temp, { recursive: true, force: true });
459
+ throw err;
460
+ }
461
+ return temp;
462
+ }
463
+ function readExtractedManifest(extractedDir, warnings) {
464
+ const p = join(extractedDir, MANIFEST_FILENAME);
465
+ if (!existsSync(p)) {
466
+ warnings.push(`archive carries no ${MANIFEST_FILENAME} — not created by \`crewhaus state backup\`?`);
467
+ return undefined;
468
+ }
469
+ try {
470
+ const parsed = JSON.parse(readFileSync(p, "utf-8"));
471
+ if (isBackupManifest(parsed))
472
+ return parsed;
473
+ warnings.push(`archive's ${MANIFEST_FILENAME} has an unrecognized shape — ignored`);
474
+ }
475
+ catch {
476
+ warnings.push(`archive's ${MANIFEST_FILENAME} is not valid JSON — ignored`);
477
+ }
478
+ return undefined;
479
+ }
480
+ /** Collision-safe `.crewhaus.bak-<ts>` sibling path. */
481
+ function bakPath(stateDir) {
482
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
483
+ let candidate = `${stateDir}.bak-${stamp}`;
484
+ let n = 2;
485
+ while (existsSync(candidate)) {
486
+ candidate = `${stateDir}.bak-${stamp}-${n}`;
487
+ n += 1;
488
+ }
489
+ return candidate;
490
+ }
491
+ /**
492
+ * Full restore of a backup into `<intoDir>/.crewhaus`. Refuses (throws
493
+ * `StateRestoreRefusedError`) when the target is non-empty and `force` is
494
+ * not set. Extraction happens in a sibling temp dir which is renamed into
495
+ * place, so a half-extracted archive can never corrupt existing state, and
496
+ * restored file bytes are exactly the archived bytes (tar preserves them;
497
+ * nothing is rewritten).
498
+ */
499
+ export async function restoreStateArchive(opts) {
500
+ const archiveFile = resolve(opts.archiveFile);
501
+ assertArchiveExists(archiveFile);
502
+ const stateDir = join(resolve(opts.intoDir), ".crewhaus");
503
+ const inspection = inspectStateDir(stateDir);
504
+ if (inspection.state === "occupied" && !opts.force) {
505
+ throw new StateRestoreRefusedError(stateDir, inspection.entries);
506
+ }
507
+ const warnings = [];
508
+ // Sibling temp dir (same filesystem as the target) so the final move is a
509
+ // plain rename.
510
+ const temp = await extractToTemp(archiveFile, dirname(stateDir));
511
+ try {
512
+ const manifest = readExtractedManifest(temp, warnings);
513
+ // The manifest describes the backup; it is not state. Drop our extracted
514
+ // copy so the restored `.crewhaus` contains exactly the archived state
515
+ // (provenance stays inside the tarball).
516
+ rmSync(join(temp, MANIFEST_FILENAME), { force: true });
517
+ let movedAsideTo;
518
+ if (inspection.state === "empty") {
519
+ rmdirSync(stateDir); // empty — nothing to preserve
520
+ }
521
+ else if (inspection.state === "occupied") {
522
+ movedAsideTo = bakPath(stateDir);
523
+ renameSync(stateDir, movedAsideTo);
524
+ }
525
+ renameSync(temp, stateDir);
526
+ return {
527
+ stateDir,
528
+ ...(movedAsideTo !== undefined ? { movedAsideTo } : {}),
529
+ ...(manifest !== undefined ? { manifest } : {}),
530
+ filesRestored: walkFiles(stateDir).files.length,
531
+ warnings,
532
+ };
533
+ }
534
+ finally {
535
+ // No-op after the successful rename; cleans up on any earlier failure.
536
+ rmSync(temp, { recursive: true, force: true });
537
+ }
538
+ }
539
+ // ---------------------------------------------------------------------------
540
+ // --merge feedback | --merge all
541
+ // ---------------------------------------------------------------------------
542
+ /** Parse a JSONL blob into objects, skipping blank/malformed lines (mirrors
543
+ * the entry file's tolerant reader — one corrupt line must not abort a
544
+ * merge). */
545
+ function parseJsonlObjects(text) {
546
+ const out = [];
547
+ for (const line of text.split("\n")) {
548
+ if (line.trim() === "")
549
+ continue;
550
+ try {
551
+ out.push(JSON.parse(line));
552
+ }
553
+ catch {
554
+ // tolerated
555
+ }
556
+ }
557
+ return out;
558
+ }
559
+ /**
560
+ * Every FeedbackRecord a state dir carries, read exactly the way `crewhaus
561
+ * distill` reads them: `user_feedback` event-log lines in
562
+ * `sessions/*.jsonl` PLUS bare records in `feedback/*.jsonl` (the web-UI
563
+ * host sink). `extractFeedbackRecords` accepts both encodings and validates
564
+ * each record.
565
+ */
566
+ function collectFeedbackRecords(stateDir) {
567
+ const objects = [];
568
+ for (const sub of ["sessions", "feedback"]) {
569
+ const dir = join(stateDir, sub);
570
+ if (!existsSync(dir))
571
+ continue;
572
+ for (const f of readdirSync(dir)) {
573
+ if (!f.endsWith(".jsonl"))
574
+ continue;
575
+ objects.push(...parseJsonlObjects(readFileSync(join(dir, f), "utf-8")));
576
+ }
577
+ }
578
+ return extractFeedbackRecords(objects);
579
+ }
580
+ /**
581
+ * `--merge feedback`: fold ONLY the archive's feedback records into the
582
+ * local store. Imported folds land as bare records in a new
583
+ * `feedback/restored-<ts>.jsonl` — the exact sink `crewhaus distill`
584
+ * already reads — so local session transcripts are never rewritten and the
585
+ * next distill sees local + imported records merged by `mergeFeedback`.
586
+ */
587
+ export async function mergeFeedbackFromArchive(opts) {
588
+ const archiveFile = resolve(opts.archiveFile);
589
+ assertArchiveExists(archiveFile);
590
+ const stateDir = join(resolve(opts.intoDir), ".crewhaus");
591
+ const temp = await extractToTemp(archiveFile, tmpdir());
592
+ try {
593
+ const archived = collectFeedbackRecords(temp);
594
+ if (archived.length === 0) {
595
+ throw new StateBackupError("archive carries no feedback records — nothing to merge " +
596
+ "(looked at sessions/*.jsonl user_feedback events and feedback/*.jsonl)");
597
+ }
598
+ const plan = planFeedbackMerge(collectFeedbackRecords(stateDir), archived);
599
+ let wroteFile;
600
+ if (plan.toImport.length > 0) {
601
+ const feedbackDir = join(stateDir, "feedback");
602
+ mkdirSync(feedbackDir, { recursive: true });
603
+ const stamp = (opts.now?.() ?? new Date()).toISOString().replace(/[:.]/g, "-");
604
+ wroteFile = join(feedbackDir, `restored-${stamp}.jsonl`);
605
+ let n = 2;
606
+ while (existsSync(wroteFile)) {
607
+ wroteFile = join(feedbackDir, `restored-${stamp}-${n}.jsonl`);
608
+ n += 1;
609
+ }
610
+ writeFileSync(wroteFile, `${plan.toImport.map((r) => JSON.stringify(r)).join("\n")}\n`, {
611
+ mode: 0o600,
612
+ });
613
+ }
614
+ return {
615
+ ...plan,
616
+ archivedRecords: archived.length,
617
+ ...(wroteFile !== undefined ? { wroteFile } : {}),
618
+ };
619
+ }
620
+ finally {
621
+ rmSync(temp, { recursive: true, force: true });
622
+ }
623
+ }
624
+ /**
625
+ * `--merge all`: per-subdir additive copy. Only archive files that do NOT
626
+ * exist locally are written; an existing local file is never overwritten
627
+ * (the skips are reported). The archive manifest is metadata, not state,
628
+ * and is never copied.
629
+ */
630
+ export async function mergeAllFromArchive(opts) {
631
+ const archiveFile = resolve(opts.archiveFile);
632
+ assertArchiveExists(archiveFile);
633
+ const stateDir = join(resolve(opts.intoDir), ".crewhaus");
634
+ const temp = await extractToTemp(archiveFile, tmpdir());
635
+ try {
636
+ const archiveFiles = walkFiles(temp).files.filter((f) => f !== MANIFEST_FILENAME);
637
+ const plan = planAdditiveMerge(archiveFiles, (rel) => existsSync(join(stateDir, rel)));
638
+ for (const rel of plan.copy) {
639
+ const dst = join(stateDir, rel);
640
+ mkdirSync(dirname(dst), { recursive: true });
641
+ copyFileSync(join(temp, rel), dst);
642
+ }
643
+ return { stateDir, copied: plan.copy, skipped: plan.skip };
644
+ }
645
+ finally {
646
+ rmSync(temp, { recursive: true, force: true });
647
+ }
648
+ }