@crewhaus/tool-lifecycle 0.7.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 +274 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +1233 -0
- package/dist/lib/knowledge.d.ts +131 -0
- package/dist/lib/knowledge.js +283 -0
- package/dist/lib/migrate.d.ts +106 -0
- package/dist/lib/migrate.js +264 -0
- package/dist/lib/result.d.ts +72 -0
- package/dist/lib/result.js +139 -0
- package/dist/lib/retention.d.ts +87 -0
- package/dist/lib/retention.js +96 -0
- package/dist/lib/retire.d.ts +92 -0
- package/dist/lib/retire.js +269 -0
- package/dist/lib/tree.d.ts +74 -0
- package/dist/lib/tree.js +284 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +46 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `StoreMigrate`'s half: what a harness's store version IS, what the
|
|
3
|
+
* migration path actually carries, and what it leaves behind.
|
|
4
|
+
*
|
|
5
|
+
* The copy itself is `@crewhaus/harness-lifecycle`'s `runRetentionExport` —
|
|
6
|
+
* the same enumerate-and-copy code the retention export verb uses, which
|
|
7
|
+
* copies session and audit files VERBATIM (an audit day file has to keep its
|
|
8
|
+
* exact bytes or the hash chain over it stops verifying). Nothing about
|
|
9
|
+
* selection or copying is re-implemented here.
|
|
10
|
+
*
|
|
11
|
+
* What is here is the part that makes a migration honest rather than a hope:
|
|
12
|
+
*
|
|
13
|
+
* - THE STORE VERSION. Read, reported, and never quietly changed. This
|
|
14
|
+
* build can move a store; it cannot rewrite records into a new shape,
|
|
15
|
+
* because the memory-entry 1→2 transform lives in `apps/cli`
|
|
16
|
+
* (`migrateMemories`) and reaching it needs packages this one does not
|
|
17
|
+
* depend on. A version-changing migration is REFUSED by name instead of
|
|
18
|
+
* being silently performed as a copy, which would leave a caller
|
|
19
|
+
* believing their store was upgraded.
|
|
20
|
+
* - WHAT IS NOT CARRIED. The export path covers `.crewhaus/sessions` and
|
|
21
|
+
* `.crewhaus/audit`. Memories, prompts, graders, the registry and the
|
|
22
|
+
* policy files are NOT part of it. A migration that reports success while
|
|
23
|
+
* leaving them behind is exactly the half-migration that puts a store in
|
|
24
|
+
* neither shape, so everything under `.crewhaus` that the copy does not
|
|
25
|
+
* carry is enumerated and returned.
|
|
26
|
+
* - THE RECEIPT. Every file that was copied, with its sha256, written to
|
|
27
|
+
* the destination. A re-run reads it back, so "already migrated" and
|
|
28
|
+
* "migrated just now" are distinguishable, and an interrupted run can be
|
|
29
|
+
* resumed by running it again.
|
|
30
|
+
*/
|
|
31
|
+
import { lstatSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
32
|
+
import * as path from "node:path";
|
|
33
|
+
import { fail, renderPath } from "./result";
|
|
34
|
+
/**
|
|
35
|
+
* The two directories under `.crewhaus` that ARE the harness's record store:
|
|
36
|
+
* the pair `runRetentionExport` carries, and the same pair
|
|
37
|
+
* `openHarnessRecordStore` enumerates and a sweep deletes from. Mirrored here
|
|
38
|
+
* because `@crewhaus/harness-lifecycle` keeps the relpaths private.
|
|
39
|
+
*/
|
|
40
|
+
export const COVERED_STORE_DIRS = ["sessions", "audit"];
|
|
41
|
+
export const RECEIPT_FILENAME = "store-migration.json";
|
|
42
|
+
/**
|
|
43
|
+
* The file `runRetentionExport` writes into its output directory on every
|
|
44
|
+
* REAL run, on top of the copies themselves.
|
|
45
|
+
*
|
|
46
|
+
* Mirrored here (the package does not export the name) because it is a
|
|
47
|
+
* destination this tool writes but does not copy: leaving it out of the
|
|
48
|
+
* written set made the conflict check miss it, so a destination that already
|
|
49
|
+
* held a `manifest.json` — a previous `crewhaus retention export`, say — had
|
|
50
|
+
* it overwritten without `overwrite` being asked for and without the preview
|
|
51
|
+
* ever naming it. `index.test.ts` pins the coupling: a real migration must
|
|
52
|
+
* produce this file at this name.
|
|
53
|
+
*/
|
|
54
|
+
export const EXPORT_MANIFEST_FILENAME = "manifest.json";
|
|
55
|
+
export const RECORD_SHAPE_MIGRATION_UNAVAILABLE = "a version-changing migration rewrites every record, and the record transforms live in apps/cli " +
|
|
56
|
+
"(migrateMemories, over @crewhaus/memory-store + @crewhaus/migration-engine) which " +
|
|
57
|
+
"@crewhaus/tool-lifecycle does not depend on. This tool moves a store between roots at the " +
|
|
58
|
+
"version it already has; it will not claim to have upgraded one.";
|
|
59
|
+
/**
|
|
60
|
+
* Read `<harnessDir>/.crewhaus/meta.json`, the file `crewhaus migrate
|
|
61
|
+
* memories` stamps (`{ memories: { schemaVersion, migratedAt } }`).
|
|
62
|
+
*
|
|
63
|
+
* An unreadable or malformed meta file is `unknown`, never version 1: a
|
|
64
|
+
* migration that guesses the version it is migrating FROM is a migration that
|
|
65
|
+
* rewrites records twice.
|
|
66
|
+
*/
|
|
67
|
+
export function readStoreVersion(harnessDirAbs) {
|
|
68
|
+
const metaPath = path.join(harnessDirAbs, ".crewhaus", "meta.json");
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = readFileSync(metaPath, "utf8");
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
const code = err.code;
|
|
75
|
+
if (code === "ENOENT")
|
|
76
|
+
return { state: "unstamped" };
|
|
77
|
+
return {
|
|
78
|
+
state: "unknown",
|
|
79
|
+
reason: `.crewhaus/meta.json could not be read (${code ?? "unknown error"})`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
state: "unknown",
|
|
89
|
+
reason: `.crewhaus/meta.json is not valid JSON (${err.message})`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
93
|
+
return { state: "unknown", reason: ".crewhaus/meta.json is not a JSON object" };
|
|
94
|
+
}
|
|
95
|
+
const memories = parsed["memories"];
|
|
96
|
+
if (memories === undefined)
|
|
97
|
+
return { state: "unstamped" };
|
|
98
|
+
if (typeof memories !== "object" || memories === null || Array.isArray(memories)) {
|
|
99
|
+
return {
|
|
100
|
+
state: "unknown",
|
|
101
|
+
reason: '.crewhaus/meta.json has a "memories" field that is not an object',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const version = memories["schemaVersion"];
|
|
105
|
+
if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
|
|
106
|
+
return {
|
|
107
|
+
state: "unknown",
|
|
108
|
+
reason: `.crewhaus/meta.json has memories.schemaVersion ${JSON.stringify(version)}, which is not a positive integer`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { state: "known", memoriesSchemaVersion: version };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Everything directly under `.crewhaus` that the export path does not copy.
|
|
115
|
+
*
|
|
116
|
+
* Returned so the result can name it. A caller who migrates a store and is
|
|
117
|
+
* told only "42 sessions, 3 audit days" will believe the memories came too.
|
|
118
|
+
*/
|
|
119
|
+
export function uncoveredStateEntries(harnessDirAbs) {
|
|
120
|
+
const stateDir = path.join(harnessDirAbs, ".crewhaus");
|
|
121
|
+
let names;
|
|
122
|
+
try {
|
|
123
|
+
names = readdirSync(stateDir).sort();
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
const code = err.code;
|
|
127
|
+
if (code === "ENOENT")
|
|
128
|
+
return { ok: true, value: [] };
|
|
129
|
+
return fail("unreadable", `.crewhaus could not be listed (${code ?? "unknown error"})`);
|
|
130
|
+
}
|
|
131
|
+
const covered = new Set(COVERED_STORE_DIRS);
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
value: names
|
|
135
|
+
.filter((n) => !covered.has(n))
|
|
136
|
+
.map((n) => ({
|
|
137
|
+
entry: `.crewhaus/${n}`,
|
|
138
|
+
reason: "not part of the store export path (@crewhaus/harness-lifecycle carries .crewhaus/sessions and .crewhaus/audit only)",
|
|
139
|
+
})),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* What is already at the destination, and whether a previous migration put it
|
|
144
|
+
* there. A receipt naming a DIFFERENT source is the case that matters: two
|
|
145
|
+
* harnesses' stores merged into one directory cannot be told apart
|
|
146
|
+
* afterwards, and the audit chains cannot both be valid.
|
|
147
|
+
*/
|
|
148
|
+
export function inspectDestination(destAbs, plannedRelPaths) {
|
|
149
|
+
let exists = true;
|
|
150
|
+
try {
|
|
151
|
+
if (!statSync(destAbs).isDirectory()) {
|
|
152
|
+
return fail("not-a-directory", `"${renderPath(destAbs)}" exists and is not a directory`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
const code = err.code;
|
|
157
|
+
if (code === "ENOENT")
|
|
158
|
+
exists = false;
|
|
159
|
+
else
|
|
160
|
+
return fail("unreadable", `the destination could not be read (${code ?? "unknown error"})`);
|
|
161
|
+
}
|
|
162
|
+
const conflicts = [];
|
|
163
|
+
if (exists) {
|
|
164
|
+
for (const rel of plannedRelPaths) {
|
|
165
|
+
try {
|
|
166
|
+
// lstat, not stat: the question is whether the NAME is taken, not
|
|
167
|
+
// whether it leads anywhere. `stat` follows a symlink and reports a
|
|
168
|
+
// DANGLING one as absent — so a link left at a destination path read
|
|
169
|
+
// as "nothing here", `overwrite` was never asked for, and the copy
|
|
170
|
+
// went through the link to whatever it pointed at.
|
|
171
|
+
lstatSync(path.join(destAbs, ...rel.split("/")));
|
|
172
|
+
conflicts.push(rel);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
// ENOENT is the normal case: nothing is there. Anything else means
|
|
176
|
+
// the entry could not be identified, which is not the same as absent
|
|
177
|
+
// and still counts as "something is in the way".
|
|
178
|
+
if (err.code !== "ENOENT")
|
|
179
|
+
conflicts.push(rel);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const receiptPath = path.join(destAbs, RECEIPT_FILENAME);
|
|
184
|
+
let receiptRaw;
|
|
185
|
+
try {
|
|
186
|
+
receiptRaw = readFileSync(receiptPath, "utf8");
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
const code = err.code;
|
|
190
|
+
if (code !== "ENOENT") {
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
value: {
|
|
194
|
+
exists,
|
|
195
|
+
conflicts,
|
|
196
|
+
receiptProblem: `a ${RECEIPT_FILENAME} is present but could not be read (${code ?? "unknown error"})`,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (receiptRaw === undefined)
|
|
202
|
+
return { ok: true, value: { exists, conflicts } };
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(receiptRaw);
|
|
205
|
+
if (typeof parsed?.sourceRoot !== "string") {
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
value: {
|
|
209
|
+
exists,
|
|
210
|
+
conflicts,
|
|
211
|
+
receiptProblem: `${RECEIPT_FILENAME} does not name a source root`,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, value: { exists, conflicts, receipt: parsed } };
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
value: {
|
|
221
|
+
exists,
|
|
222
|
+
conflicts,
|
|
223
|
+
receiptProblem: `${RECEIPT_FILENAME} is not valid JSON (${err.message})`,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** The destination-relative paths a migration of this selection would write. */
|
|
229
|
+
export function plannedPaths(opts) {
|
|
230
|
+
const rels = [];
|
|
231
|
+
for (const id of opts.sessions) {
|
|
232
|
+
rels.push(`sessions/${id}.json`);
|
|
233
|
+
if (opts.withEventLog.has(id))
|
|
234
|
+
rels.push(`sessions/${id}.jsonl`);
|
|
235
|
+
}
|
|
236
|
+
for (const day of opts.auditDays)
|
|
237
|
+
rels.push(`audit/${day}.jsonl`);
|
|
238
|
+
if (opts.chainTail)
|
|
239
|
+
rels.push("audit/_chain-tail.json");
|
|
240
|
+
return rels;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Every destination-relative path a REAL migration writes: the copies, plus
|
|
244
|
+
* the export's own `manifest.json` and this tool's receipt.
|
|
245
|
+
*
|
|
246
|
+
* Distinct from {@link plannedPaths} on purpose. `plannedPaths` is the set
|
|
247
|
+
* that is copied from a source and can therefore be hashed on both sides;
|
|
248
|
+
* this is the set that is WRITTEN, which is what the conflict check and the
|
|
249
|
+
* containment check have to be asked about. Reporting the first as the second
|
|
250
|
+
* is how a destination file nobody copied gets destroyed quietly.
|
|
251
|
+
*/
|
|
252
|
+
export function writtenPaths(plannedRelPaths) {
|
|
253
|
+
return [...plannedRelPaths, EXPORT_MANIFEST_FILENAME, RECEIPT_FILENAME];
|
|
254
|
+
}
|
|
255
|
+
export function writeReceipt(destAbs, receipt) {
|
|
256
|
+
const p = path.join(destAbs, RECEIPT_FILENAME);
|
|
257
|
+
try {
|
|
258
|
+
writeFileSync(p, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
259
|
+
return { ok: true, value: p };
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
return fail("unreadable", `the migration receipt could not be written: ${err.message}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type SafePath } from "../paths";
|
|
2
|
+
/** Compact JSON — the reader is a model, and every byte is context. */
|
|
3
|
+
export declare const json: (value: unknown) => string;
|
|
4
|
+
/** A caller-supplied path, safe to put in a message: bounded and printable. */
|
|
5
|
+
export declare function renderPath(given: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Why something could not be loaded.
|
|
8
|
+
*
|
|
9
|
+
* `missing` is the only code a caller may carry on past ("there is no store
|
|
10
|
+
* here"); every other one is a refusal that must be returned. It is a field
|
|
11
|
+
* rather than something sniffed out of the message so that rewording a
|
|
12
|
+
* message can never silently turn a refusal into a carry-on.
|
|
13
|
+
*/
|
|
14
|
+
export type FailCode = "refused" | "missing" | "not-a-directory" | "bad-input" | "unreadable" | "too-large";
|
|
15
|
+
export type Failure = {
|
|
16
|
+
readonly ok: false;
|
|
17
|
+
readonly reason: string;
|
|
18
|
+
readonly code: FailCode;
|
|
19
|
+
};
|
|
20
|
+
export type Loaded<T> = {
|
|
21
|
+
readonly ok: true;
|
|
22
|
+
readonly value: T;
|
|
23
|
+
} | Failure;
|
|
24
|
+
export declare function fail(code: FailCode, reason: string): Failure;
|
|
25
|
+
/** The JSON a tool returns when it will not proceed. */
|
|
26
|
+
export declare function refusal(tool: string, code: FailCode, reason: string, extra?: {}): string;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a caller-supplied path inside the workspace.
|
|
29
|
+
*
|
|
30
|
+
* The NUL guard is here rather than in `paths.ts` (which is a verbatim copy
|
|
31
|
+
* of `@crewhaus/tool-pkg`'s, deliberately): a NUL truncates a path at the
|
|
32
|
+
* syscall boundary, so `a\u0000/../../etc` can resolve one way in the
|
|
33
|
+
* containment check and another way in the `open` that follows. Refused at
|
|
34
|
+
* the gate instead of surfacing later as "unreadable".
|
|
35
|
+
*/
|
|
36
|
+
export declare function contain(toolName: string, rel: string): Loaded<SafePath>;
|
|
37
|
+
/**
|
|
38
|
+
* Contain every path an operation will WRITE, not just the directory it was
|
|
39
|
+
* handed.
|
|
40
|
+
*
|
|
41
|
+
* `contain` resolves the ROOT through its symlinks and proves the root sits
|
|
42
|
+
* inside the workspace. It says nothing about the names BELOW it — and the
|
|
43
|
+
* writes these tools perform all go to those names: a store export copies to
|
|
44
|
+
* `<dest>/sessions/<id>.json`, a knowledge push appends to
|
|
45
|
+
* `<shared>/memories.jsonl`, a pull drops files into
|
|
46
|
+
* `<harness>/.crewhaus/prompts`. A symlink sitting at one of those names
|
|
47
|
+
* sends the write to the link's target instead, and a DANGLING one is the
|
|
48
|
+
* worst case: `stat` reports it absent, so it does not even look like a
|
|
49
|
+
* conflict, while `open(…, "w")` through it CREATES the target. Validating
|
|
50
|
+
* the directory and then writing through a link underneath it is exactly the
|
|
51
|
+
* "validate one spelling, act on another" shape this repository keeps paying
|
|
52
|
+
* for.
|
|
53
|
+
*
|
|
54
|
+
* Each write path therefore goes through the SAME `resolveSafe` the root went
|
|
55
|
+
* through — symlink-aware, dangling links followed by hand — rather than a
|
|
56
|
+
* second symlink walker written here. Returns the refusal, or `undefined`
|
|
57
|
+
* when every write path stays inside the workspace.
|
|
58
|
+
*/
|
|
59
|
+
export declare function containWritePaths(toolName: string, root: SafePath, rels: ReadonlyArray<string>): Failure | undefined;
|
|
60
|
+
/** Resolve a directory that must already exist. */
|
|
61
|
+
export declare function containExistingDir(toolName: string, rel: string): Loaded<SafePath>;
|
|
62
|
+
/** True when `candidate` is `parent` or lives underneath it (both absolute). */
|
|
63
|
+
export declare function isInside(parent: string, candidate: string): boolean;
|
|
64
|
+
/** True when either absolute path contains the other, or they are equal. */
|
|
65
|
+
export declare function overlaps(a: string, b: string): boolean;
|
|
66
|
+
/** Epoch ms → ISO, or `undefined` when there is nothing to render. */
|
|
67
|
+
export declare function iso(ms: number | undefined): string | undefined;
|
|
68
|
+
/** A bounded slice of a list, plus the full count, for a result field. */
|
|
69
|
+
export declare function sample<T>(items: ReadonlyArray<T>, limit?: number): {
|
|
70
|
+
shown: T[];
|
|
71
|
+
total: number;
|
|
72
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared answer shapes for this package: how a caller-supplied path is
|
|
3
|
+
* echoed back, how a directory argument is contained, and how a read that
|
|
4
|
+
* COULD NOT BE PERFORMED is distinguished from one that found nothing.
|
|
5
|
+
*
|
|
6
|
+
* That last distinction is the whole point of the `Loaded` type. Four
|
|
7
|
+
* irreversible operations sit behind these tools; every one of them decides
|
|
8
|
+
* what to destroy from something it read first. A read that failed and got
|
|
9
|
+
* reported as an empty result is how a retention sweep deletes a store it
|
|
10
|
+
* could not enumerate, so an unreadable directory has its own code and its
|
|
11
|
+
* own reason here, and never collapses into `[]`.
|
|
12
|
+
*/
|
|
13
|
+
import { statSync } from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { ToolPermissionError, resolveSafe } from "../paths";
|
|
16
|
+
/** Compact JSON — the reader is a model, and every byte is context. */
|
|
17
|
+
export const json = (value) => JSON.stringify(value);
|
|
18
|
+
/**
|
|
19
|
+
* How much of a caller-supplied path is echoed back in a result.
|
|
20
|
+
*
|
|
21
|
+
* The message goes into a model's context, so a caller cannot be allowed to
|
|
22
|
+
* spend that context with a megabyte of path, and cannot be allowed to
|
|
23
|
+
* smuggle control characters (a NUL, an ANSI escape, a newline that forges a
|
|
24
|
+
* second line of output) through a string a human or a model then reads.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_ECHOED_PATH_CHARS = 200;
|
|
27
|
+
/** A caller-supplied path, safe to put in a message: bounded and printable. */
|
|
28
|
+
export function renderPath(given) {
|
|
29
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: neutralising control characters is the point
|
|
30
|
+
const printable = given.replace(/[\u0000-\u001f\u007f]/g, "\uFFFD");
|
|
31
|
+
return printable.length > MAX_ECHOED_PATH_CHARS
|
|
32
|
+
? `${printable.slice(0, MAX_ECHOED_PATH_CHARS)}…`
|
|
33
|
+
: printable;
|
|
34
|
+
}
|
|
35
|
+
export function fail(code, reason) {
|
|
36
|
+
return { ok: false, reason, code };
|
|
37
|
+
}
|
|
38
|
+
/** The JSON a tool returns when it will not proceed. */
|
|
39
|
+
export function refusal(tool, code, reason, extra = {}) {
|
|
40
|
+
return json({ tool, status: "refused", code, reason, ...extra });
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a caller-supplied path inside the workspace.
|
|
44
|
+
*
|
|
45
|
+
* The NUL guard is here rather than in `paths.ts` (which is a verbatim copy
|
|
46
|
+
* of `@crewhaus/tool-pkg`'s, deliberately): a NUL truncates a path at the
|
|
47
|
+
* syscall boundary, so `a\u0000/../../etc` can resolve one way in the
|
|
48
|
+
* containment check and another way in the `open` that follows. Refused at
|
|
49
|
+
* the gate instead of surfacing later as "unreadable".
|
|
50
|
+
*/
|
|
51
|
+
export function contain(toolName, rel) {
|
|
52
|
+
if (rel.includes("\u0000")) {
|
|
53
|
+
return fail("refused", `"${renderPath(rel)}" contains a NUL byte`);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return { ok: true, value: resolveSafe(toolName, rel) };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err instanceof ToolPermissionError) {
|
|
60
|
+
// The error's own message carries the path unrendered; rebuild it so a
|
|
61
|
+
// control character in the caller's input cannot reach the output.
|
|
62
|
+
return fail("refused", `tool "${toolName}" rejected path "${renderPath(rel)}": resolved location escapes the workspace root`);
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Contain every path an operation will WRITE, not just the directory it was
|
|
69
|
+
* handed.
|
|
70
|
+
*
|
|
71
|
+
* `contain` resolves the ROOT through its symlinks and proves the root sits
|
|
72
|
+
* inside the workspace. It says nothing about the names BELOW it — and the
|
|
73
|
+
* writes these tools perform all go to those names: a store export copies to
|
|
74
|
+
* `<dest>/sessions/<id>.json`, a knowledge push appends to
|
|
75
|
+
* `<shared>/memories.jsonl`, a pull drops files into
|
|
76
|
+
* `<harness>/.crewhaus/prompts`. A symlink sitting at one of those names
|
|
77
|
+
* sends the write to the link's target instead, and a DANGLING one is the
|
|
78
|
+
* worst case: `stat` reports it absent, so it does not even look like a
|
|
79
|
+
* conflict, while `open(…, "w")` through it CREATES the target. Validating
|
|
80
|
+
* the directory and then writing through a link underneath it is exactly the
|
|
81
|
+
* "validate one spelling, act on another" shape this repository keeps paying
|
|
82
|
+
* for.
|
|
83
|
+
*
|
|
84
|
+
* Each write path therefore goes through the SAME `resolveSafe` the root went
|
|
85
|
+
* through — symlink-aware, dangling links followed by hand — rather than a
|
|
86
|
+
* second symlink walker written here. Returns the refusal, or `undefined`
|
|
87
|
+
* when every write path stays inside the workspace.
|
|
88
|
+
*/
|
|
89
|
+
export function containWritePaths(toolName, root, rels) {
|
|
90
|
+
const shownRoot = root.rel === "" ? "." : root.rel;
|
|
91
|
+
for (const rel of rels) {
|
|
92
|
+
const under = root.rel === "" ? rel : `${root.rel}/${rel}`;
|
|
93
|
+
if (!contain(toolName, under).ok) {
|
|
94
|
+
return fail("refused", `"${renderPath(rel)}" under "${renderPath(shownRoot)}" resolves outside the workspace root — a symlink on that path leads out, so the write would land on the link's target rather than inside "${renderPath(shownRoot)}". Remove the link and run again; there is no flag for writing through it.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/** Resolve a directory that must already exist. */
|
|
100
|
+
export function containExistingDir(toolName, rel) {
|
|
101
|
+
const safe = contain(toolName, rel);
|
|
102
|
+
if (!safe.ok)
|
|
103
|
+
return safe;
|
|
104
|
+
const shown = renderPath(rel);
|
|
105
|
+
let stat;
|
|
106
|
+
try {
|
|
107
|
+
stat = statSync(safe.value.real);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const code = err.code;
|
|
111
|
+
// ENOENT is "there is nothing here"; EACCES/ELOOP/anything else is "this
|
|
112
|
+
// could not be read", which is a different answer and must not be
|
|
113
|
+
// reported as absence.
|
|
114
|
+
return code === "ENOENT"
|
|
115
|
+
? fail("missing", `"${shown}" does not exist`)
|
|
116
|
+
: fail("unreadable", `"${shown}" could not be read (${code ?? "unknown error"})`);
|
|
117
|
+
}
|
|
118
|
+
if (!stat.isDirectory())
|
|
119
|
+
return fail("not-a-directory", `"${shown}" is not a directory`);
|
|
120
|
+
return safe;
|
|
121
|
+
}
|
|
122
|
+
/** True when `candidate` is `parent` or lives underneath it (both absolute). */
|
|
123
|
+
export function isInside(parent, candidate) {
|
|
124
|
+
const p = path.resolve(parent);
|
|
125
|
+
const c = path.resolve(candidate);
|
|
126
|
+
return c === p || c.startsWith(`${p}${path.sep}`);
|
|
127
|
+
}
|
|
128
|
+
/** True when either absolute path contains the other, or they are equal. */
|
|
129
|
+
export function overlaps(a, b) {
|
|
130
|
+
return isInside(a, b) || isInside(b, a);
|
|
131
|
+
}
|
|
132
|
+
/** Epoch ms → ISO, or `undefined` when there is nothing to render. */
|
|
133
|
+
export function iso(ms) {
|
|
134
|
+
return ms === undefined ? undefined : new Date(ms).toISOString();
|
|
135
|
+
}
|
|
136
|
+
/** A bounded slice of a list, plus the full count, for a result field. */
|
|
137
|
+
export function sample(items, limit = 50) {
|
|
138
|
+
return { shown: items.slice(0, limit), total: items.length };
|
|
139
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a retention selection well enough to refuse it.
|
|
3
|
+
*
|
|
4
|
+
* `@crewhaus/harness-lifecycle` decides WHAT a sweep or purge deletes: the
|
|
5
|
+
* age rule, the pins, the audit windows, the audit-chain exclusion. None of
|
|
6
|
+
* that is re-decided here. What this module adds is the question the package
|
|
7
|
+
* does not answer — is this selection plausibly what the operator meant? —
|
|
8
|
+
* and the two shapes of "no" that matter:
|
|
9
|
+
*
|
|
10
|
+
* 1. THE RULE SELECTED EVERYTHING. A window that expires every session in
|
|
11
|
+
* the store is either a deliberate wipe or a misconfiguration, and the
|
|
12
|
+
* two are indistinguishable from inside the rule. It has to be said out
|
|
13
|
+
* loud before the delete, not discovered after it.
|
|
14
|
+
* 2. A TIMESTAMP IS NOT A DATE. A session whose mtime is at or near epoch
|
|
15
|
+
* zero is a broken clock, a failed copy or a restored archive — not data
|
|
16
|
+
* from 1970. Age rules read it as infinitely old and delete it first.
|
|
17
|
+
* There is deliberately no flag to wave this through: the fix is to
|
|
18
|
+
* repair or pin the record, not to confirm a number nobody believes.
|
|
19
|
+
*
|
|
20
|
+
* The selection itself comes from running the package's OWN dry run, so the
|
|
21
|
+
* set being judged here is the set the real call will act on, produced by the
|
|
22
|
+
* same code — not a preview re-derived beside it.
|
|
23
|
+
*/
|
|
24
|
+
import type { RetentionEnforcementReport } from "@crewhaus/harness-lifecycle";
|
|
25
|
+
/**
|
|
26
|
+
* Timestamps at or below this are treated as broken rather than old:
|
|
27
|
+
* 1971-01-01T00:00:00Z. A real harness session at or before that is not a
|
|
28
|
+
* thing; a zeroed or truncated mtime lands here every time.
|
|
29
|
+
*
|
|
30
|
+
* The comparison is inclusive because every sentence describing this rule —
|
|
31
|
+
* the tool's description, the refusal it prints — says "at or before", and a
|
|
32
|
+
* boundary that the prose and the code disagree about is a boundary nobody
|
|
33
|
+
* can reason about. Exactly `EPOCH_SANITY_MS` is a fabricated number, not
|
|
34
|
+
* data from the first instant of 1971.
|
|
35
|
+
*/
|
|
36
|
+
export declare const EPOCH_SANITY_MS: number;
|
|
37
|
+
/** The shape of an inventory entry this module needs, structurally. */
|
|
38
|
+
export type InventorySession = {
|
|
39
|
+
readonly sessionId: string;
|
|
40
|
+
readonly record: {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly createdAt: number;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
export type SelectedRecord = {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly createdAtMs: number;
|
|
48
|
+
readonly paths: ReadonlyArray<string>;
|
|
49
|
+
};
|
|
50
|
+
export type Selection = {
|
|
51
|
+
readonly records: ReadonlyArray<SelectedRecord>;
|
|
52
|
+
readonly count: number;
|
|
53
|
+
readonly oldestMs?: number;
|
|
54
|
+
readonly newestMs?: number;
|
|
55
|
+
/** Selected records whose timestamp is at or below {@link EPOCH_SANITY_MS}. */
|
|
56
|
+
readonly epochZero: ReadonlyArray<SelectedRecord>;
|
|
57
|
+
/**
|
|
58
|
+
* Selected ids that were not in the inventory snapshot, so their age could
|
|
59
|
+
* not be read. Not an empty set of timestamps — an unknown one.
|
|
60
|
+
*/
|
|
61
|
+
readonly unknownTimestamp: ReadonlyArray<string>;
|
|
62
|
+
readonly sessionsInStore: number;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Join the package's deletion list to the inventory snapshot, so the caller
|
|
66
|
+
* can be told not just HOW MANY records would go but how old the oldest and
|
|
67
|
+
* newest of them are — the two numbers that make a misconfigured window
|
|
68
|
+
* visible before it runs.
|
|
69
|
+
*/
|
|
70
|
+
export declare function summarizeSelection(report: RetentionEnforcementReport, sessions: ReadonlyArray<InventorySession>): Selection;
|
|
71
|
+
export type BreadthOptions = {
|
|
72
|
+
readonly allowDeleteAll: boolean;
|
|
73
|
+
readonly maxDeletions?: number;
|
|
74
|
+
/** The parsed `before` cutoff, epoch ms, for a purge. */
|
|
75
|
+
readonly cutoffMs?: number;
|
|
76
|
+
readonly nowMs: number;
|
|
77
|
+
};
|
|
78
|
+
export type BreadthRefusal = {
|
|
79
|
+
readonly code: "refused";
|
|
80
|
+
readonly reason: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* The refusals that keep a plausible-looking rule from taking the whole
|
|
84
|
+
* store. Order matters: the broken-timestamp refusal comes first because it
|
|
85
|
+
* explains WHY a rule that looks narrow selected everything.
|
|
86
|
+
*/
|
|
87
|
+
export declare function breadthRefusal(selection: Selection, opts: BreadthOptions): BreadthRefusal | undefined;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timestamps at or below this are treated as broken rather than old:
|
|
3
|
+
* 1971-01-01T00:00:00Z. A real harness session at or before that is not a
|
|
4
|
+
* thing; a zeroed or truncated mtime lands here every time.
|
|
5
|
+
*
|
|
6
|
+
* The comparison is inclusive because every sentence describing this rule —
|
|
7
|
+
* the tool's description, the refusal it prints — says "at or before", and a
|
|
8
|
+
* boundary that the prose and the code disagree about is a boundary nobody
|
|
9
|
+
* can reason about. Exactly `EPOCH_SANITY_MS` is a fabricated number, not
|
|
10
|
+
* data from the first instant of 1971.
|
|
11
|
+
*/
|
|
12
|
+
export const EPOCH_SANITY_MS = Date.UTC(1971, 0, 1);
|
|
13
|
+
/**
|
|
14
|
+
* Join the package's deletion list to the inventory snapshot, so the caller
|
|
15
|
+
* can be told not just HOW MANY records would go but how old the oldest and
|
|
16
|
+
* newest of them are — the two numbers that make a misconfigured window
|
|
17
|
+
* visible before it runs.
|
|
18
|
+
*/
|
|
19
|
+
export function summarizeSelection(report, sessions) {
|
|
20
|
+
const byId = new Map();
|
|
21
|
+
for (const s of sessions)
|
|
22
|
+
byId.set(s.record.id, s);
|
|
23
|
+
const records = [];
|
|
24
|
+
const unknownTimestamp = [];
|
|
25
|
+
for (const deletion of report.deleted) {
|
|
26
|
+
const entry = byId.get(deletion.id);
|
|
27
|
+
if (entry === undefined) {
|
|
28
|
+
// The enforcement pass named a record the snapshot does not have. That
|
|
29
|
+
// is a real divergence (a racing writer, a store that changed shape),
|
|
30
|
+
// and it is reported rather than silently treated as age-zero.
|
|
31
|
+
unknownTimestamp.push(deletion.id);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
records.push({ id: deletion.id, createdAtMs: entry.record.createdAt, paths: deletion.paths });
|
|
35
|
+
}
|
|
36
|
+
const times = records.map((r) => r.createdAtMs);
|
|
37
|
+
return {
|
|
38
|
+
records,
|
|
39
|
+
count: report.deleted.length,
|
|
40
|
+
...(times.length > 0 ? { oldestMs: Math.min(...times), newestMs: Math.max(...times) } : {}),
|
|
41
|
+
epochZero: records.filter((r) => r.createdAtMs <= EPOCH_SANITY_MS),
|
|
42
|
+
unknownTimestamp,
|
|
43
|
+
sessionsInStore: sessions.length,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The refusals that keep a plausible-looking rule from taking the whole
|
|
48
|
+
* store. Order matters: the broken-timestamp refusal comes first because it
|
|
49
|
+
* explains WHY a rule that looks narrow selected everything.
|
|
50
|
+
*/
|
|
51
|
+
export function breadthRefusal(selection, opts) {
|
|
52
|
+
if (selection.epochZero.length > 0) {
|
|
53
|
+
const shown = selection.epochZero
|
|
54
|
+
.slice(0, 5)
|
|
55
|
+
.map((r) => `${r.id} (${new Date(r.createdAtMs).toISOString()})`)
|
|
56
|
+
.join(", ");
|
|
57
|
+
return {
|
|
58
|
+
code: "refused",
|
|
59
|
+
reason: `refusing to delete on an age rule while ${selection.epochZero.length} selected record(s) carry a timestamp at or before ${new Date(EPOCH_SANITY_MS).toISOString()}: ${shown}. A timestamp that low is a broken clock or a failed copy, not data that old, and every age rule reads it as infinitely old. Repair the timestamps, or pin those sessions in .crewhaus/retention.json, and run again. There is no flag to override this.`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (selection.unknownTimestamp.length > 0) {
|
|
63
|
+
return {
|
|
64
|
+
code: "refused",
|
|
65
|
+
reason: `refusing: ${selection.unknownTimestamp.length} selected record(s) were not in the inventory snapshot, so their age could not be read (${selection.unknownTimestamp.slice(0, 5).join(", ")}). Something is writing to the store during the sweep; re-run when it is quiet.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (opts.cutoffMs !== undefined &&
|
|
69
|
+
opts.cutoffMs > opts.nowMs &&
|
|
70
|
+
selection.count > 0 &&
|
|
71
|
+
!opts.allowDeleteAll) {
|
|
72
|
+
return {
|
|
73
|
+
code: "refused",
|
|
74
|
+
reason: `refusing: the purge cutoff ${new Date(opts.cutoffMs).toISOString()} is in the future, so "older than the cutoff" means every record in the store that its retention window allows. Pass a past cutoff, or set allowDeleteAll to confirm.`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (opts.maxDeletions !== undefined && selection.count > opts.maxDeletions) {
|
|
78
|
+
return {
|
|
79
|
+
code: "refused",
|
|
80
|
+
reason: `refusing: the selection is ${selection.count} record(s), over the maxDeletions limit of ${opts.maxDeletions} this call set.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!opts.allowDeleteAll &&
|
|
84
|
+
selection.count > 0 &&
|
|
85
|
+
selection.sessionsInStore > 0 &&
|
|
86
|
+
selection.count === selection.sessionsInStore) {
|
|
87
|
+
const window = selection.oldestMs !== undefined && selection.newestMs !== undefined
|
|
88
|
+
? ` (oldest ${new Date(selection.oldestMs).toISOString()}, newest ${new Date(selection.newestMs).toISOString()})`
|
|
89
|
+
: "";
|
|
90
|
+
return {
|
|
91
|
+
code: "refused",
|
|
92
|
+
reason: `refusing: the age rule selects EVERY session in the store — ${selection.count} of ${selection.sessionsInStore}${window}. That is either a deliberate wipe or a misconfigured window, and this tool will not guess which. Set allowDeleteAll to confirm it is the former.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|