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.
- package/README.md +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +172 -0
- package/dist/dataset-mine.js +403 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +9 -2
- package/dist/feedback.js +17 -7
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11601 -964
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +173 -0
- package/dist/security-digest.js +650 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +298 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- 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
|
+
}
|