@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,131 @@
|
|
|
1
|
+
import { type Redactor, type SharedFragment, type SharedMemory } from "@crewhaus/harness-lifecycle";
|
|
2
|
+
import { type Loaded } from "./result";
|
|
3
|
+
export declare const NO_PII_PASS_REASON: string;
|
|
4
|
+
/**
|
|
5
|
+
* The redactor this build can supply: the package's own credential masking
|
|
6
|
+
* and its strict post-mask rescan, with an identity PII pass.
|
|
7
|
+
*
|
|
8
|
+
* `IDENTITY_REDACTOR` from the package is deliberately NOT used — it never
|
|
9
|
+
* flags anything, so a memory holding an API key would be pushed verbatim.
|
|
10
|
+
*/
|
|
11
|
+
export declare function makeRedactor(): Redactor;
|
|
12
|
+
/**
|
|
13
|
+
* The shared store's on-disk layout, as `applyPush` / `readSharedFragments`
|
|
14
|
+
* write and read it. Mirrored once here so the audit, the containment check
|
|
15
|
+
* and the post-push verification all name the same file rather than each
|
|
16
|
+
* spelling the rule out again.
|
|
17
|
+
*/
|
|
18
|
+
export declare const SHARED_MEMORIES_RELPATH = "memories.jsonl";
|
|
19
|
+
export declare const SHARED_MANIFEST_RELPATH = "manifest.jsonl";
|
|
20
|
+
export declare function sharedFragmentRelPath(frag: {
|
|
21
|
+
readonly kind: "grader" | "prompt";
|
|
22
|
+
readonly contentHash: string;
|
|
23
|
+
}): string;
|
|
24
|
+
export type Rejected = {
|
|
25
|
+
readonly at: string;
|
|
26
|
+
readonly reason: string;
|
|
27
|
+
};
|
|
28
|
+
export type SharedMemoryAudit = {
|
|
29
|
+
readonly memories: ReadonlyArray<SharedMemory>;
|
|
30
|
+
readonly rejected: ReadonlyArray<Rejected>;
|
|
31
|
+
readonly linesSeen: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Read the shared `memories.jsonl` through the package's own validator, and
|
|
35
|
+
* keep the rejects instead of dropping them.
|
|
36
|
+
*
|
|
37
|
+
* A missing file is an empty store and says so; an unreadable one is a
|
|
38
|
+
* failure and says THAT — a shared store that cannot be read is not a shared
|
|
39
|
+
* store with nothing in it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function auditSharedMemories(sharedDirAbs: string): Loaded<SharedMemoryAudit>;
|
|
42
|
+
export type SharedFragmentAudit = {
|
|
43
|
+
readonly fragments: ReadonlyArray<SharedFragment>;
|
|
44
|
+
readonly rejected: ReadonlyArray<Rejected>;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Fragments of one kind, with the files the package's validation refused.
|
|
48
|
+
*
|
|
49
|
+
* The valid set comes from `readSharedFragments`, so the accept/reject rule
|
|
50
|
+
* stays in one place; what is derived here is only WHICH candidate files did
|
|
51
|
+
* not survive it. Re-deriving the rule to explain each rejection would be a
|
|
52
|
+
* second copy of it, and the second copy is the one that goes stale.
|
|
53
|
+
*/
|
|
54
|
+
export declare function auditSharedFragments(sharedDirAbs: string, kind: "grader" | "prompt"): Loaded<SharedFragmentAudit>;
|
|
55
|
+
/**
|
|
56
|
+
* The tag `applyPull` appends to every memory it lands
|
|
57
|
+
* (`shared:<source harness>`).
|
|
58
|
+
*
|
|
59
|
+
* It matters because the content hash covers text AND tags: a memory pulled
|
|
60
|
+
* from the shared store lands with one more tag than the record it came from,
|
|
61
|
+
* so hashing what is on disk does NOT reproduce the shared record's hash. The
|
|
62
|
+
* CLI computes the harness's hashes without accounting for it
|
|
63
|
+
* (`apps/cli/src/index.ts`, the `knowledge pull` branch), which is why a
|
|
64
|
+
* second pull re-imports everything and `shared.jsonl` grows on every run.
|
|
65
|
+
*
|
|
66
|
+
* `harnessMemoryHashes` below closes that by hashing a landed memory BOTH
|
|
67
|
+
* ways. The hashing rule itself is still the package's `memoryContentHash`;
|
|
68
|
+
* what is mirrored here is only the tag `applyPull` writes, and
|
|
69
|
+
* `index.test.ts` pins it — a second pull must bring in nothing.
|
|
70
|
+
*/
|
|
71
|
+
export declare const SHARED_TAG_PREFIX = "shared:";
|
|
72
|
+
/**
|
|
73
|
+
* Every content hash the harness already holds, counting a pulled memory
|
|
74
|
+
* under its shared hash as well as its local one.
|
|
75
|
+
*
|
|
76
|
+
* The undo is EXACTLY `applyPull`'s do: it appends ONE provenance tag to the
|
|
77
|
+
* end of the record's tags, so exactly one trailing tag is taken back off.
|
|
78
|
+
* Stripping every `shared:`-prefixed tag instead — which is what this did —
|
|
79
|
+
* is a different function, and it is wrong for the record that already
|
|
80
|
+
* carried provenance of its own (one harness pulled a memory and pushed it
|
|
81
|
+
* on, so the shared record's own tags include a `shared:` tag). For that
|
|
82
|
+
* record neither form reproduced the shared hash, so the FIRST pull reported
|
|
83
|
+
* `verified: false` with the memory listed as missing although it had landed
|
|
84
|
+
* perfectly, and every later pull re-imported it — the unbounded growth this
|
|
85
|
+
* mirror exists to prevent, reintroduced by the mirror.
|
|
86
|
+
*/
|
|
87
|
+
export declare function harnessMemoryHashes(memories: ReadonlyArray<{
|
|
88
|
+
readonly text: string;
|
|
89
|
+
readonly tags: ReadonlyArray<string>;
|
|
90
|
+
}>): Set<string>;
|
|
91
|
+
/**
|
|
92
|
+
* Where `applyPull` lands each artifact inside a harness.
|
|
93
|
+
*
|
|
94
|
+
* Mirrored, not re-derived: the package chooses these paths and does not
|
|
95
|
+
* export them, and a tool that cannot name them can neither contain the
|
|
96
|
+
* writes (a symlink at one of them sends the file out of the workspace) nor
|
|
97
|
+
* check that they arrived. `index.test.ts` pins every one of them against a
|
|
98
|
+
* real pull, so a rename upstream fails a test here rather than silently
|
|
99
|
+
* turning the checks into no-ops.
|
|
100
|
+
*/
|
|
101
|
+
export declare const PULLED_MEMORIES_RELPATH = ".crewhaus/memories/shared.jsonl";
|
|
102
|
+
export declare const PULLED_GRADER_PREFIX = "graders.shared-";
|
|
103
|
+
export declare const PULLED_GRADER_SUFFIX = ".yaml";
|
|
104
|
+
export declare function pulledFragmentRelPath(frag: {
|
|
105
|
+
readonly kind: "grader" | "prompt";
|
|
106
|
+
readonly contentHash: string;
|
|
107
|
+
}): string;
|
|
108
|
+
/**
|
|
109
|
+
* Content hashes of the grader fragments a previous pull already landed.
|
|
110
|
+
*
|
|
111
|
+
* `readHarnessGraders` looks only at `graders.yaml` / `.crewhaus/graders.yaml`
|
|
112
|
+
* — by design, those are the harness's OWN grader files — so a fragment
|
|
113
|
+
* `applyPull` wrote to `graders.shared-<hash>.yaml` is invisible to it, and
|
|
114
|
+
* every later pull counted that fragment as new and reported pulling it
|
|
115
|
+
* again. Matched by the filename shape and hashed with the package's own
|
|
116
|
+
* `fragmentContentHash`, so the accept rule stays the package's; only the
|
|
117
|
+
* landing name is mirrored.
|
|
118
|
+
*/
|
|
119
|
+
export declare function landedSharedGraderHashes(harnessDirAbs: string): Loaded<Set<string>>;
|
|
120
|
+
export type HarnessMemoryAudit = {
|
|
121
|
+
readonly dirPresent: boolean;
|
|
122
|
+
readonly files: number;
|
|
123
|
+
readonly linesSeen: number;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* How many lines the harness's memory files hold, so a store whose lines are
|
|
127
|
+
* mostly unparseable is not reported as a store with few memories. The parsed
|
|
128
|
+
* count comes from the package's `readHarnessMemories`; the difference
|
|
129
|
+
* between the two is the interesting number.
|
|
130
|
+
*/
|
|
131
|
+
export declare function auditHarnessMemoryFiles(harnessDirAbs: string): Loaded<HarnessMemoryAudit>;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `KnowledgeSync`'s half: accounting for what a shared store contains that
|
|
3
|
+
* could not be used, and wiring the redactor this build can honestly offer.
|
|
4
|
+
*
|
|
5
|
+
* The rules all belong to `@crewhaus/harness-lifecycle`: what a valid shared
|
|
6
|
+
* memory is (`validateSharedMemory`, including the re-hash that defeats
|
|
7
|
+
* dedupe-poisoning), what a credential looks like (`maskKnowledgeSecrets` /
|
|
8
|
+
* `looksLikeSecret`), what to push (`planPush`) and what to pull
|
|
9
|
+
* (`planPull`). None of it is restated here.
|
|
10
|
+
*
|
|
11
|
+
* Two things are added.
|
|
12
|
+
*
|
|
13
|
+
* FIRST, SKIPS ARE COUNTED. The package's readers drop an invalid shared
|
|
14
|
+
* record with a warning to stderr and return the rest, which is right for a
|
|
15
|
+
* CLI and wrong for a tool: a caller handed `{"memories": 12}` has no way to
|
|
16
|
+
* know that 40 more were rejected as forged. A shared store is untrusted
|
|
17
|
+
* input — the count of what it offered and could not be used is part of the
|
|
18
|
+
* answer, not noise.
|
|
19
|
+
*
|
|
20
|
+
* SECOND, THE REDACTION GAP IS NAMED. Production wires
|
|
21
|
+
* `buildKnowledgeRedactor` around `@crewhaus/pii-redactor`. That package is
|
|
22
|
+
* not a dependency here, so this build runs the credential half — masking,
|
|
23
|
+
* and the strict post-mask rescan that DROPS anything still secret-shaped —
|
|
24
|
+
* with an identity PII pass. Names, emails and phone numbers are therefore
|
|
25
|
+
* NOT redacted, and a push says so and requires the caller to acknowledge it
|
|
26
|
+
* rather than quietly shipping a weaker guarantee under the same name.
|
|
27
|
+
*/
|
|
28
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { buildKnowledgeRedactor, fragmentContentHash, memoryContentHash, readSharedFragments, validateSharedMemory, } from "@crewhaus/harness-lifecycle";
|
|
31
|
+
import { fail } from "./result";
|
|
32
|
+
export const NO_PII_PASS_REASON = "@crewhaus/pii-redactor is not a dependency of @crewhaus/tool-lifecycle, so the PII pass of the " +
|
|
33
|
+
"knowledge redactor is an identity function here: credential-shaped tokens ARE masked and any " +
|
|
34
|
+
"artifact still secret-shaped afterwards is dropped, but names, emails, addresses and phone " +
|
|
35
|
+
"numbers are NOT removed. Pass allowWithoutPiiRedaction to push on those terms.";
|
|
36
|
+
/**
|
|
37
|
+
* The redactor this build can supply: the package's own credential masking
|
|
38
|
+
* and its strict post-mask rescan, with an identity PII pass.
|
|
39
|
+
*
|
|
40
|
+
* `IDENTITY_REDACTOR` from the package is deliberately NOT used — it never
|
|
41
|
+
* flags anything, so a memory holding an API key would be pushed verbatim.
|
|
42
|
+
*/
|
|
43
|
+
export function makeRedactor() {
|
|
44
|
+
return buildKnowledgeRedactor(async (text) => text);
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// shared store, with the rejects counted
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
/**
|
|
50
|
+
* The shared store's on-disk layout, as `applyPush` / `readSharedFragments`
|
|
51
|
+
* write and read it. Mirrored once here so the audit, the containment check
|
|
52
|
+
* and the post-push verification all name the same file rather than each
|
|
53
|
+
* spelling the rule out again.
|
|
54
|
+
*/
|
|
55
|
+
export const SHARED_MEMORIES_RELPATH = "memories.jsonl";
|
|
56
|
+
export const SHARED_MANIFEST_RELPATH = "manifest.jsonl";
|
|
57
|
+
const SHARED_FRAGMENT_DIR = { grader: "graders", prompt: "prompts" };
|
|
58
|
+
const SHARED_FRAGMENT_EXT = { grader: ".yaml", prompt: ".md" };
|
|
59
|
+
export function sharedFragmentRelPath(frag) {
|
|
60
|
+
return `${SHARED_FRAGMENT_DIR[frag.kind]}/${frag.contentHash}${SHARED_FRAGMENT_EXT[frag.kind]}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read the shared `memories.jsonl` through the package's own validator, and
|
|
64
|
+
* keep the rejects instead of dropping them.
|
|
65
|
+
*
|
|
66
|
+
* A missing file is an empty store and says so; an unreadable one is a
|
|
67
|
+
* failure and says THAT — a shared store that cannot be read is not a shared
|
|
68
|
+
* store with nothing in it.
|
|
69
|
+
*/
|
|
70
|
+
export function auditSharedMemories(sharedDirAbs) {
|
|
71
|
+
const file = path.join(sharedDirAbs, SHARED_MEMORIES_RELPATH);
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = readFileSync(file, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const code = err.code;
|
|
78
|
+
if (code === "ENOENT")
|
|
79
|
+
return { ok: true, value: { memories: [], rejected: [], linesSeen: 0 } };
|
|
80
|
+
return fail("unreadable", `the shared memories file could not be read (${code ?? "unknown error"})`);
|
|
81
|
+
}
|
|
82
|
+
const memories = [];
|
|
83
|
+
const rejected = [];
|
|
84
|
+
let linesSeen = 0;
|
|
85
|
+
let lineNo = 0;
|
|
86
|
+
for (const line of raw.split("\n")) {
|
|
87
|
+
lineNo += 1;
|
|
88
|
+
if (line.trim() === "")
|
|
89
|
+
continue;
|
|
90
|
+
linesSeen += 1;
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(line);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
rejected.push({ at: `memories.jsonl:${lineNo}`, reason: "not valid JSON" });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const verdict = validateSharedMemory(parsed);
|
|
100
|
+
if (verdict.ok)
|
|
101
|
+
memories.push(verdict.memory);
|
|
102
|
+
else
|
|
103
|
+
rejected.push({ at: `memories.jsonl:${lineNo}`, reason: verdict.reason });
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, value: { memories, rejected, linesSeen } };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Fragments of one kind, with the files the package's validation refused.
|
|
109
|
+
*
|
|
110
|
+
* The valid set comes from `readSharedFragments`, so the accept/reject rule
|
|
111
|
+
* stays in one place; what is derived here is only WHICH candidate files did
|
|
112
|
+
* not survive it. Re-deriving the rule to explain each rejection would be a
|
|
113
|
+
* second copy of it, and the second copy is the one that goes stale.
|
|
114
|
+
*/
|
|
115
|
+
export function auditSharedFragments(sharedDirAbs, kind) {
|
|
116
|
+
const dir = path.join(sharedDirAbs, SHARED_FRAGMENT_DIR[kind]);
|
|
117
|
+
const ext = SHARED_FRAGMENT_EXT[kind];
|
|
118
|
+
let names;
|
|
119
|
+
try {
|
|
120
|
+
names = readdirSync(dir).sort();
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const code = err.code;
|
|
124
|
+
if (code === "ENOENT")
|
|
125
|
+
return { ok: true, value: { fragments: [], rejected: [] } };
|
|
126
|
+
return fail("unreadable", `the shared ${kind} directory could not be listed (${code ?? "unknown error"})`);
|
|
127
|
+
}
|
|
128
|
+
let fragments;
|
|
129
|
+
try {
|
|
130
|
+
fragments = readSharedFragments(sharedDirAbs, kind);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
return fail("unreadable", `the shared ${kind} fragments could not be read: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
const accepted = new Set(fragments.map((f) => f.filename));
|
|
136
|
+
const rejected = names
|
|
137
|
+
.filter((n) => n.endsWith(ext) && !accepted.has(n))
|
|
138
|
+
.map((n) => ({
|
|
139
|
+
at: `${SHARED_FRAGMENT_DIR[kind]}/${n}`,
|
|
140
|
+
reason: "refused by @crewhaus/harness-lifecycle's fragment validation — the filename's claimed content hash does not match the body's, or the body is over the size cap",
|
|
141
|
+
}));
|
|
142
|
+
return { ok: true, value: { fragments, rejected } };
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// harness side
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
/**
|
|
148
|
+
* The tag `applyPull` appends to every memory it lands
|
|
149
|
+
* (`shared:<source harness>`).
|
|
150
|
+
*
|
|
151
|
+
* It matters because the content hash covers text AND tags: a memory pulled
|
|
152
|
+
* from the shared store lands with one more tag than the record it came from,
|
|
153
|
+
* so hashing what is on disk does NOT reproduce the shared record's hash. The
|
|
154
|
+
* CLI computes the harness's hashes without accounting for it
|
|
155
|
+
* (`apps/cli/src/index.ts`, the `knowledge pull` branch), which is why a
|
|
156
|
+
* second pull re-imports everything and `shared.jsonl` grows on every run.
|
|
157
|
+
*
|
|
158
|
+
* `harnessMemoryHashes` below closes that by hashing a landed memory BOTH
|
|
159
|
+
* ways. The hashing rule itself is still the package's `memoryContentHash`;
|
|
160
|
+
* what is mirrored here is only the tag `applyPull` writes, and
|
|
161
|
+
* `index.test.ts` pins it — a second pull must bring in nothing.
|
|
162
|
+
*/
|
|
163
|
+
export const SHARED_TAG_PREFIX = "shared:";
|
|
164
|
+
/**
|
|
165
|
+
* Every content hash the harness already holds, counting a pulled memory
|
|
166
|
+
* under its shared hash as well as its local one.
|
|
167
|
+
*
|
|
168
|
+
* The undo is EXACTLY `applyPull`'s do: it appends ONE provenance tag to the
|
|
169
|
+
* end of the record's tags, so exactly one trailing tag is taken back off.
|
|
170
|
+
* Stripping every `shared:`-prefixed tag instead — which is what this did —
|
|
171
|
+
* is a different function, and it is wrong for the record that already
|
|
172
|
+
* carried provenance of its own (one harness pulled a memory and pushed it
|
|
173
|
+
* on, so the shared record's own tags include a `shared:` tag). For that
|
|
174
|
+
* record neither form reproduced the shared hash, so the FIRST pull reported
|
|
175
|
+
* `verified: false` with the memory listed as missing although it had landed
|
|
176
|
+
* perfectly, and every later pull re-imported it — the unbounded growth this
|
|
177
|
+
* mirror exists to prevent, reintroduced by the mirror.
|
|
178
|
+
*/
|
|
179
|
+
export function harnessMemoryHashes(memories) {
|
|
180
|
+
const hashes = new Set();
|
|
181
|
+
for (const memory of memories) {
|
|
182
|
+
hashes.add(memoryContentHash(memory.text, memory.tags));
|
|
183
|
+
const last = memory.tags[memory.tags.length - 1];
|
|
184
|
+
if (last?.startsWith(SHARED_TAG_PREFIX) === true) {
|
|
185
|
+
hashes.add(memoryContentHash(memory.text, memory.tags.slice(0, -1)));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return hashes;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Where `applyPull` lands each artifact inside a harness.
|
|
192
|
+
*
|
|
193
|
+
* Mirrored, not re-derived: the package chooses these paths and does not
|
|
194
|
+
* export them, and a tool that cannot name them can neither contain the
|
|
195
|
+
* writes (a symlink at one of them sends the file out of the workspace) nor
|
|
196
|
+
* check that they arrived. `index.test.ts` pins every one of them against a
|
|
197
|
+
* real pull, so a rename upstream fails a test here rather than silently
|
|
198
|
+
* turning the checks into no-ops.
|
|
199
|
+
*/
|
|
200
|
+
export const PULLED_MEMORIES_RELPATH = ".crewhaus/memories/shared.jsonl";
|
|
201
|
+
const PULLED_PROMPT_RELDIR = ".crewhaus/prompts";
|
|
202
|
+
export const PULLED_GRADER_PREFIX = "graders.shared-";
|
|
203
|
+
export const PULLED_GRADER_SUFFIX = ".yaml";
|
|
204
|
+
export function pulledFragmentRelPath(frag) {
|
|
205
|
+
const short = frag.contentHash.slice(0, 12);
|
|
206
|
+
return frag.kind === "prompt"
|
|
207
|
+
? `${PULLED_PROMPT_RELDIR}/shared-${short}.md`
|
|
208
|
+
: `${PULLED_GRADER_PREFIX}${short}${PULLED_GRADER_SUFFIX}`;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Content hashes of the grader fragments a previous pull already landed.
|
|
212
|
+
*
|
|
213
|
+
* `readHarnessGraders` looks only at `graders.yaml` / `.crewhaus/graders.yaml`
|
|
214
|
+
* — by design, those are the harness's OWN grader files — so a fragment
|
|
215
|
+
* `applyPull` wrote to `graders.shared-<hash>.yaml` is invisible to it, and
|
|
216
|
+
* every later pull counted that fragment as new and reported pulling it
|
|
217
|
+
* again. Matched by the filename shape and hashed with the package's own
|
|
218
|
+
* `fragmentContentHash`, so the accept rule stays the package's; only the
|
|
219
|
+
* landing name is mirrored.
|
|
220
|
+
*/
|
|
221
|
+
export function landedSharedGraderHashes(harnessDirAbs) {
|
|
222
|
+
let names;
|
|
223
|
+
try {
|
|
224
|
+
names = readdirSync(harnessDirAbs).sort();
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
const code = err.code;
|
|
228
|
+
return fail("unreadable", `the harness directory could not be listed (${code ?? "unknown error"})`);
|
|
229
|
+
}
|
|
230
|
+
const hashes = new Set();
|
|
231
|
+
for (const name of names) {
|
|
232
|
+
if (!name.startsWith(PULLED_GRADER_PREFIX) || !name.endsWith(PULLED_GRADER_SUFFIX))
|
|
233
|
+
continue;
|
|
234
|
+
let contents;
|
|
235
|
+
try {
|
|
236
|
+
contents = readFileSync(path.join(harnessDirAbs, name), "utf8");
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
// An unreadable landed fragment is not an absent one: reporting it as
|
|
240
|
+
// absent would make the next pull re-import it and call it new.
|
|
241
|
+
return fail("unreadable", `the pulled grader fragment ${name} could not be read (${err.code ?? "unknown error"})`);
|
|
242
|
+
}
|
|
243
|
+
hashes.add(fragmentContentHash(contents));
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, value: hashes };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* How many lines the harness's memory files hold, so a store whose lines are
|
|
249
|
+
* mostly unparseable is not reported as a store with few memories. The parsed
|
|
250
|
+
* count comes from the package's `readHarnessMemories`; the difference
|
|
251
|
+
* between the two is the interesting number.
|
|
252
|
+
*/
|
|
253
|
+
export function auditHarnessMemoryFiles(harnessDirAbs) {
|
|
254
|
+
const dir = path.join(harnessDirAbs, ".crewhaus", "memories");
|
|
255
|
+
let names;
|
|
256
|
+
try {
|
|
257
|
+
names = readdirSync(dir).sort();
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
const code = err.code;
|
|
261
|
+
if (code === "ENOENT")
|
|
262
|
+
return { ok: true, value: { dirPresent: false, files: 0, linesSeen: 0 } };
|
|
263
|
+
return fail("unreadable", `the harness memories directory could not be listed (${code ?? "unknown error"})`);
|
|
264
|
+
}
|
|
265
|
+
let files = 0;
|
|
266
|
+
let linesSeen = 0;
|
|
267
|
+
for (const name of names) {
|
|
268
|
+
if (!name.endsWith(".jsonl"))
|
|
269
|
+
continue;
|
|
270
|
+
files += 1;
|
|
271
|
+
let raw;
|
|
272
|
+
try {
|
|
273
|
+
raw = readFileSync(path.join(dir, name), "utf8");
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
return fail("unreadable", `the harness memory file ${name} could not be read (${err.code ?? "unknown error"})`);
|
|
277
|
+
}
|
|
278
|
+
for (const line of raw.split("\n"))
|
|
279
|
+
if (line.trim() !== "")
|
|
280
|
+
linesSeen += 1;
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, value: { dirPresent: true, files, linesSeen } };
|
|
283
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type Loaded } from "./result";
|
|
2
|
+
/**
|
|
3
|
+
* The two directories under `.crewhaus` that ARE the harness's record store:
|
|
4
|
+
* the pair `runRetentionExport` carries, and the same pair
|
|
5
|
+
* `openHarnessRecordStore` enumerates and a sweep deletes from. Mirrored here
|
|
6
|
+
* because `@crewhaus/harness-lifecycle` keeps the relpaths private.
|
|
7
|
+
*/
|
|
8
|
+
export declare const COVERED_STORE_DIRS: readonly ["sessions", "audit"];
|
|
9
|
+
export declare const RECEIPT_FILENAME = "store-migration.json";
|
|
10
|
+
/**
|
|
11
|
+
* The file `runRetentionExport` writes into its output directory on every
|
|
12
|
+
* REAL run, on top of the copies themselves.
|
|
13
|
+
*
|
|
14
|
+
* Mirrored here (the package does not export the name) because it is a
|
|
15
|
+
* destination this tool writes but does not copy: leaving it out of the
|
|
16
|
+
* written set made the conflict check miss it, so a destination that already
|
|
17
|
+
* held a `manifest.json` — a previous `crewhaus retention export`, say — had
|
|
18
|
+
* it overwritten without `overwrite` being asked for and without the preview
|
|
19
|
+
* ever naming it. `index.test.ts` pins the coupling: a real migration must
|
|
20
|
+
* produce this file at this name.
|
|
21
|
+
*/
|
|
22
|
+
export declare const EXPORT_MANIFEST_FILENAME = "manifest.json";
|
|
23
|
+
export declare const RECORD_SHAPE_MIGRATION_UNAVAILABLE: string;
|
|
24
|
+
export type StoreVersion = {
|
|
25
|
+
readonly state: "known";
|
|
26
|
+
readonly memoriesSchemaVersion: number;
|
|
27
|
+
}
|
|
28
|
+
/** No `.crewhaus/meta.json`: nothing has stamped a version here yet. */
|
|
29
|
+
| {
|
|
30
|
+
readonly state: "unstamped";
|
|
31
|
+
} | {
|
|
32
|
+
readonly state: "unknown";
|
|
33
|
+
readonly reason: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Read `<harnessDir>/.crewhaus/meta.json`, the file `crewhaus migrate
|
|
37
|
+
* memories` stamps (`{ memories: { schemaVersion, migratedAt } }`).
|
|
38
|
+
*
|
|
39
|
+
* An unreadable or malformed meta file is `unknown`, never version 1: a
|
|
40
|
+
* migration that guesses the version it is migrating FROM is a migration that
|
|
41
|
+
* rewrites records twice.
|
|
42
|
+
*/
|
|
43
|
+
export declare function readStoreVersion(harnessDirAbs: string): StoreVersion;
|
|
44
|
+
export type Uncovered = {
|
|
45
|
+
readonly entry: string;
|
|
46
|
+
readonly reason: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Everything directly under `.crewhaus` that the export path does not copy.
|
|
50
|
+
*
|
|
51
|
+
* Returned so the result can name it. A caller who migrates a store and is
|
|
52
|
+
* told only "42 sessions, 3 audit days" will believe the memories came too.
|
|
53
|
+
*/
|
|
54
|
+
export declare function uncoveredStateEntries(harnessDirAbs: string): Loaded<Uncovered[]>;
|
|
55
|
+
export type MigrationReceiptFile = {
|
|
56
|
+
readonly rel: string;
|
|
57
|
+
readonly bytes: number;
|
|
58
|
+
readonly sha256: string;
|
|
59
|
+
};
|
|
60
|
+
export type MigrationReceipt = {
|
|
61
|
+
readonly writtenAt: string;
|
|
62
|
+
readonly sourceRoot: string;
|
|
63
|
+
readonly destinationRoot: string;
|
|
64
|
+
readonly storeVersion: StoreVersion;
|
|
65
|
+
readonly since?: string;
|
|
66
|
+
readonly sessions: ReadonlyArray<string>;
|
|
67
|
+
readonly auditDays: ReadonlyArray<string>;
|
|
68
|
+
readonly chainTailCopied: boolean;
|
|
69
|
+
readonly files: ReadonlyArray<MigrationReceiptFile>;
|
|
70
|
+
readonly verified: boolean;
|
|
71
|
+
};
|
|
72
|
+
export type DestinationState = {
|
|
73
|
+
readonly exists: boolean;
|
|
74
|
+
/** Files already present that a migration would write over. */
|
|
75
|
+
readonly conflicts: ReadonlyArray<string>;
|
|
76
|
+
readonly receipt?: MigrationReceipt;
|
|
77
|
+
/** Set when a receipt is present but could not be trusted. */
|
|
78
|
+
readonly receiptProblem?: string;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* What is already at the destination, and whether a previous migration put it
|
|
82
|
+
* there. A receipt naming a DIFFERENT source is the case that matters: two
|
|
83
|
+
* harnesses' stores merged into one directory cannot be told apart
|
|
84
|
+
* afterwards, and the audit chains cannot both be valid.
|
|
85
|
+
*/
|
|
86
|
+
export declare function inspectDestination(destAbs: string, plannedRelPaths: ReadonlyArray<string>): Loaded<DestinationState>;
|
|
87
|
+
/** The destination-relative paths a migration of this selection would write. */
|
|
88
|
+
export declare function plannedPaths(opts: {
|
|
89
|
+
readonly sessions: ReadonlyArray<string>;
|
|
90
|
+
readonly auditDays: ReadonlyArray<string>;
|
|
91
|
+
readonly chainTail: boolean;
|
|
92
|
+
/** Session ids that have a sibling event log at the source. */
|
|
93
|
+
readonly withEventLog: ReadonlySet<string>;
|
|
94
|
+
}): string[];
|
|
95
|
+
/**
|
|
96
|
+
* Every destination-relative path a REAL migration writes: the copies, plus
|
|
97
|
+
* the export's own `manifest.json` and this tool's receipt.
|
|
98
|
+
*
|
|
99
|
+
* Distinct from {@link plannedPaths} on purpose. `plannedPaths` is the set
|
|
100
|
+
* that is copied from a source and can therefore be hashed on both sides;
|
|
101
|
+
* this is the set that is WRITTEN, which is what the conflict check and the
|
|
102
|
+
* containment check have to be asked about. Reporting the first as the second
|
|
103
|
+
* is how a destination file nobody copied gets destroyed quietly.
|
|
104
|
+
*/
|
|
105
|
+
export declare function writtenPaths(plannedRelPaths: ReadonlyArray<string>): string[];
|
|
106
|
+
export declare function writeReceipt(destAbs: string, receipt: MigrationReceipt): Loaded<string>;
|