@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/tool-lifecycle — the back half of a harness's life, as
|
|
3
|
+
* deterministic tools: retirement, store migration, retention enforcement and
|
|
4
|
+
* knowledge sync.
|
|
5
|
+
*
|
|
6
|
+
* These four are the operations that are irreversible when they are wrong. A
|
|
7
|
+
* retirement moves a harness's durable state out and removes the live copy. A
|
|
8
|
+
* retention sweep deletes sessions on an age rule. A store migration writes
|
|
9
|
+
* one store's contents into another root. A knowledge push copies a harness's
|
|
10
|
+
* memories into a store other harnesses read. None of them can be undone by
|
|
11
|
+
* running them again with a better flag.
|
|
12
|
+
*
|
|
13
|
+
* Five properties hold across the package.
|
|
14
|
+
*
|
|
15
|
+
* 1. THE REAL LIBRARY. Every rule these tools enforce belongs to
|
|
16
|
+
* `@crewhaus/harness-lifecycle` — the retirement plan and its
|
|
17
|
+
* active-pin refusal, the retention age rule and its pins, windows and
|
|
18
|
+
* audit-chain exclusion, the shared-knowledge validation and the
|
|
19
|
+
* credential redaction. This package contributes the schema, the
|
|
20
|
+
* containment, the refusals and the result shape. It does not contain a
|
|
21
|
+
* second copy of any of those rules, which is the whole reason they were
|
|
22
|
+
* lifted out of `apps/cli` in the first place.
|
|
23
|
+
* 2. DRY RUN IS THE DEFAULT, AND IT IS THE SAME CODE. `dryRun` defaults to
|
|
24
|
+
* TRUE on all four tools: an irreversible operation should take an
|
|
25
|
+
* explicit `dryRun: false` to happen. The preview is produced by calling
|
|
26
|
+
* the same library function with its own dry-run flag, so the selection
|
|
27
|
+
* being previewed is the selection the real call acts on, not a parallel
|
|
28
|
+
* preview beside it. Where the two runs can still disagree — the library
|
|
29
|
+
* re-enumerates, and a store can change between the calls — the
|
|
30
|
+
* difference is computed and REPORTED rather than hidden.
|
|
31
|
+
* 3. A REFUSAL WHEN THE SELECTOR IS TOO WIDE. An age rule that selects
|
|
32
|
+
* every session in the store, a purge cutoff in the future, a record
|
|
33
|
+
* whose timestamp is epoch zero, a migration into a directory that
|
|
34
|
+
* already holds someone else's store, a retirement into an archive that
|
|
35
|
+
* already holds one: each is refused by name, with what it would have
|
|
36
|
+
* taken.
|
|
37
|
+
* 4. COULD NOT DETERMINE IS NOT NO. A directory that could not be listed is
|
|
38
|
+
* never reported as empty, an unreadable registry manifest is never
|
|
39
|
+
* reported as "no pins", an unverifiable archive is never reported as
|
|
40
|
+
* verified, and a step this build cannot perform reports that it did not
|
|
41
|
+
* perform it rather than returning success.
|
|
42
|
+
* 5. WHAT THIS BUILD CANNOT DO, IT SAYS. Three things the CLI does around
|
|
43
|
+
* these operations need packages this one does not depend on: verifying
|
|
44
|
+
* the audit chain, collecting a compliance-evidence bundle, and
|
|
45
|
+
* rewriting records into a new store version. Each is named where it
|
|
46
|
+
* would have happened, with the tool or command that does it, and the
|
|
47
|
+
* operations that depend on it are refused rather than approximated.
|
|
48
|
+
*/
|
|
49
|
+
import { existsSync, lstatSync, readdirSync, statSync } from "node:fs";
|
|
50
|
+
import * as path from "node:path";
|
|
51
|
+
import { AUDIT_CHAIN_EXCLUSION_REASON, InvalidRetentionDateError, KNOWLEDGE_MARKER_RELPATH, RETENTION_CONFIG_RELPATH, RETIREMENT_LOG_FILENAME, RetentionConfigError, RetireError, SHARED_DIR_DEFAULT, applyPull, applyPush, buildRetirementPlan, formatPlan, fragmentContentHash, harnessOptedIn, openHarnessRecordStore, parseRetentionDate, planPull, planPush, readHarnessGraders, readHarnessMemories, readHarnessPrompts, runRetentionExport, runRetentionPurge, runRetentionSweep, runRetirement, } from "@crewhaus/harness-lifecycle";
|
|
52
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
53
|
+
import { z } from "zod";
|
|
54
|
+
import { NO_PII_PASS_REASON, PULLED_MEMORIES_RELPATH, SHARED_MANIFEST_RELPATH, SHARED_MEMORIES_RELPATH, auditHarnessMemoryFiles, auditSharedFragments, auditSharedMemories, harnessMemoryHashes, landedSharedGraderHashes, makeRedactor, pulledFragmentRelPath, sharedFragmentRelPath, } from "./lib/knowledge";
|
|
55
|
+
import { COVERED_STORE_DIRS, EXPORT_MANIFEST_FILENAME, RECEIPT_FILENAME, RECORD_SHAPE_MIGRATION_UNAVAILABLE, inspectDestination, plannedPaths, readStoreVersion, uncoveredStateEntries, writeReceipt, writtenPaths, } from "./lib/migrate";
|
|
56
|
+
import { contain, containExistingDir, containWritePaths, fail, iso, json, overlaps, refusal, renderPath, sample, } from "./lib/result";
|
|
57
|
+
import { breadthRefusal, summarizeSelection } from "./lib/retention";
|
|
58
|
+
import { ARCHIVED_STATE_DIRNAME, AUDIT_VERIFY_UNAVAILABLE, COMPLIANCE_EVIDENCE_UNAVAILABLE, DEFAULT_REGISTRY_RELDIR, STATE_MANIFEST_FILENAME, STATE_RELDIR, buildRetireSteps, readRegistryPins, recordSteps, specNameIsSafe, } from "./lib/retire";
|
|
59
|
+
import { hashFile, verifyAgainst, walkTree } from "./lib/tree";
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// shared plumbing
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* Budgets for fingerprinting a harness's `.crewhaus` before it is archived.
|
|
65
|
+
*
|
|
66
|
+
* Hashing is the slow part of a retirement, and it runs on the preview too
|
|
67
|
+
* (the preview has to be able to say "this will refuse, one of these files
|
|
68
|
+
* cannot be read"). A tree past these budgets is refused rather than hashed,
|
|
69
|
+
* and the caller can raise them for the call that really means it.
|
|
70
|
+
*/
|
|
71
|
+
const DEFAULT_MAX_STATE_FILES = 50_000;
|
|
72
|
+
const DEFAULT_MAX_STATE_BYTES = 512 * 1024 * 1024;
|
|
73
|
+
/** Default ceiling on artifacts moved by one knowledge sync. */
|
|
74
|
+
const DEFAULT_MAX_ARTIFACTS = 500;
|
|
75
|
+
/** Longest harness label accepted as push provenance. */
|
|
76
|
+
const MAX_HARNESS_LABEL = 100;
|
|
77
|
+
const dryRunField = z
|
|
78
|
+
.boolean()
|
|
79
|
+
.optional()
|
|
80
|
+
.describe("report what WOULD happen and change nothing. Defaults to TRUE: this tool takes an explicit dryRun:false to act.");
|
|
81
|
+
/** Directory entries, distinguishing absent from unlistable. */
|
|
82
|
+
function listDir(abs) {
|
|
83
|
+
try {
|
|
84
|
+
return { ok: true, value: readdirSync(abs).sort() };
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
const code = err.code;
|
|
88
|
+
return code === "ENOENT"
|
|
89
|
+
? { ok: true, value: [] }
|
|
90
|
+
: {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: "unreadable",
|
|
93
|
+
reason: `"${renderPath(abs)}" could not be listed (${code ?? "unknown error"})`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function isDirectory(abs) {
|
|
98
|
+
try {
|
|
99
|
+
return statSync(abs).isDirectory();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* What the state directory's NAME actually is, keeping the three answers
|
|
107
|
+
* apart.
|
|
108
|
+
*
|
|
109
|
+
* `absent` is the only one a retirement may carry on past as "there is
|
|
110
|
+
* nothing to archive". A `.crewhaus` that is a regular file, a fifo, or a
|
|
111
|
+
* name that cannot be stat'd at all is NOT an absent one — and it used to be
|
|
112
|
+
* reported as one, because a boolean `isDirectory()` that swallows its errors
|
|
113
|
+
* answers "no" to "could you tell?" and "no" to "is there anything here?"
|
|
114
|
+
* with the same word. The retirement then moved the thing it had just
|
|
115
|
+
* reported did not exist.
|
|
116
|
+
*/
|
|
117
|
+
function probeStateDir(abs) {
|
|
118
|
+
let stat;
|
|
119
|
+
try {
|
|
120
|
+
// lstat: the NAME itself, not what it leads to.
|
|
121
|
+
stat = lstatSync(abs);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
const code = err.code;
|
|
125
|
+
return code === "ENOENT"
|
|
126
|
+
? { ok: true, value: "absent" }
|
|
127
|
+
: fail("unreadable", `${STATE_RELDIR} could not be read (${code ?? "unknown error"})`);
|
|
128
|
+
}
|
|
129
|
+
if (stat.isSymbolicLink()) {
|
|
130
|
+
// `renameSync` moves the LINK: the data would stay exactly where it is —
|
|
131
|
+
// possibly outside the workspace this tool is contained to — while the
|
|
132
|
+
// harness loses its only pointer to it, and a verification that followed
|
|
133
|
+
// the link into the archive would happily pass. The harness would read as
|
|
134
|
+
// retired and nothing would have been archived.
|
|
135
|
+
return fail("refused", `${STATE_RELDIR} is a symlink — archiving it would move the link and leave the data where it is. Retire the directory the link points at, or replace the link with the real directory.`);
|
|
136
|
+
}
|
|
137
|
+
if (!stat.isDirectory()) {
|
|
138
|
+
return fail("not-a-directory", `${STATE_RELDIR} exists but is not a directory — refusing to retire a harness whose state directory cannot be fingerprinted. An entry that could not be identified is not an absent one, and the archive step would move it anyway.`);
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, value: "directory" };
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// HarnessRetire
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
export const harnessRetire = buildTool({
|
|
146
|
+
name: "HarnessRetire",
|
|
147
|
+
description: "Decommission a harness: fingerprint its durable state, archive it, and remove the live copy, with a retirement log written into the archive recording every step and its outcome. The orchestration, the ordering and the refusal on an active deployment pin are @crewhaus/harness-lifecycle's own. It REFUSES when the spec still has an environment pinned to a registered version, when the registry manifest cannot be read (an unreadable manifest is never treated as 'no pins'), when the archive directory already holds an archived state (the library replaces it, which would destroy the earlier harness's archive), and when the archive directory overlaps the state directory it is archiving. Two steps the CLI performs are NOT performed here and are reported as not performed: verifying the audit chain (run the AuditVerify tool first) and collecting a compliance-evidence bundle. Because of that a real run needs acceptUnverified:true. dryRun defaults to true and touches nothing; the preview fingerprints the state so it can tell you in advance about files it will not be able to read.",
|
|
148
|
+
inputSchema: z.object({
|
|
149
|
+
spec: z
|
|
150
|
+
.string()
|
|
151
|
+
.min(1)
|
|
152
|
+
.describe("the registered spec name, used to find the registry entry and to label the archive"),
|
|
153
|
+
archiveDir: z
|
|
154
|
+
.string()
|
|
155
|
+
.min(1)
|
|
156
|
+
.describe("where to archive the harness's state, inside the workspace and outside its state directory"),
|
|
157
|
+
dir: z.string().optional().describe("the harness directory (default: the working directory)"),
|
|
158
|
+
registryDir: z
|
|
159
|
+
.string()
|
|
160
|
+
.optional()
|
|
161
|
+
.describe(`the file-backed spec registry root (default: <dir>/${DEFAULT_REGISTRY_RELDIR})`),
|
|
162
|
+
dryRun: dryRunField,
|
|
163
|
+
acceptUnverified: z
|
|
164
|
+
.boolean()
|
|
165
|
+
.optional()
|
|
166
|
+
.describe("proceed even though this build verified neither the audit chain nor the compliance evidence. Required for a real run."),
|
|
167
|
+
overwriteArchive: z
|
|
168
|
+
.boolean()
|
|
169
|
+
.optional()
|
|
170
|
+
.describe("allow an archive directory that is not empty (its archived state may be replaced)"),
|
|
171
|
+
maxStateFiles: z
|
|
172
|
+
.number()
|
|
173
|
+
.int()
|
|
174
|
+
.positive()
|
|
175
|
+
.optional()
|
|
176
|
+
.describe(`refuse a state tree with more files than this (default ${DEFAULT_MAX_STATE_FILES})`),
|
|
177
|
+
maxStateBytes: z
|
|
178
|
+
.number()
|
|
179
|
+
.int()
|
|
180
|
+
.positive()
|
|
181
|
+
.optional()
|
|
182
|
+
.describe(`refuse a state tree bigger than this (default ${DEFAULT_MAX_STATE_BYTES})`),
|
|
183
|
+
}),
|
|
184
|
+
destructive: true,
|
|
185
|
+
requireJustification: true,
|
|
186
|
+
execute: async (input) => {
|
|
187
|
+
const tool = "HarnessRetire";
|
|
188
|
+
const dryRun = input.dryRun ?? true;
|
|
189
|
+
if (!specNameIsSafe(input.spec)) {
|
|
190
|
+
return refusal(tool, "bad-input", `"${renderPath(input.spec)}" is not a spec name — it looks like a path`);
|
|
191
|
+
}
|
|
192
|
+
const dirRel = input.dir ?? ".";
|
|
193
|
+
const harness = containExistingDir(tool, dirRel);
|
|
194
|
+
if (!harness.ok)
|
|
195
|
+
return refusal(tool, harness.code, harness.reason);
|
|
196
|
+
const archive = contain(tool, input.archiveDir);
|
|
197
|
+
if (!archive.ok)
|
|
198
|
+
return refusal(tool, archive.code, archive.reason);
|
|
199
|
+
const registry = contain(tool, input.registryDir ?? path.join(dirRel, DEFAULT_REGISTRY_RELDIR));
|
|
200
|
+
if (!registry.ok)
|
|
201
|
+
return refusal(tool, registry.code, registry.reason);
|
|
202
|
+
const stateDir = path.join(harness.value.real, STATE_RELDIR);
|
|
203
|
+
if (overlaps(archive.value.real, stateDir)) {
|
|
204
|
+
return refusal(tool, "refused", `the archive directory and the state directory ${STATE_RELDIR} overlap — archiving a directory into itself would destroy it`);
|
|
205
|
+
}
|
|
206
|
+
// The registry fact FIRST: the plan's refusal is built on it, and an
|
|
207
|
+
// unreadable manifest must stop the call rather than read as "no pins".
|
|
208
|
+
const pinState = readRegistryPins(registry.value.real, input.spec);
|
|
209
|
+
if (pinState.state === "unknown") {
|
|
210
|
+
return refusal(tool, "unreadable", `${pinState.reason} — refusing to retire a harness whose deployment pins cannot be read. An unreadable manifest is not an unpinned one.`);
|
|
211
|
+
}
|
|
212
|
+
const pins = pinState.state === "known" ? pinState.pins : {};
|
|
213
|
+
let plan;
|
|
214
|
+
try {
|
|
215
|
+
plan = buildRetirementPlan({
|
|
216
|
+
specName: input.spec,
|
|
217
|
+
harnessDir: harness.value.real,
|
|
218
|
+
archiveDir: archive.value.real,
|
|
219
|
+
pins,
|
|
220
|
+
// Never forced: with @crewhaus/spec-registry out of reach this tool
|
|
221
|
+
// cannot tombstone the pins it would be overriding, so "retire anyway"
|
|
222
|
+
// would leave environments pointing at state that no longer exists.
|
|
223
|
+
force: false,
|
|
224
|
+
pushKnowledge: false,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (err instanceof RetireError) {
|
|
229
|
+
return refusal(tool, "refused", err.message, { pins });
|
|
230
|
+
}
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
233
|
+
const archiveEntries = listDir(archive.value.real);
|
|
234
|
+
if (!archiveEntries.ok)
|
|
235
|
+
return refusal(tool, archiveEntries.code, archiveEntries.reason);
|
|
236
|
+
if (archiveEntries.value.length > 0 && input.overwriteArchive !== true) {
|
|
237
|
+
return refusal(tool, "refused", `the archive directory is not empty (${archiveEntries.value.length} entry(ies)) and @crewhaus/harness-lifecycle REPLACES an existing ${ARCHIVED_STATE_DIRNAME} directory inside it — retiring into this directory could destroy an earlier harness's archive. Pick an empty directory, or pass overwriteArchive.`, { entries: sample(archiveEntries.value, 20) });
|
|
238
|
+
}
|
|
239
|
+
// Contain what the retirement writes into the archive. `contain` proved
|
|
240
|
+
// the archive directory is inside the workspace; with overwriteArchive a
|
|
241
|
+
// caller can point it at a directory that already holds entries, and a
|
|
242
|
+
// symlink named like one of these would send the retirement log or the
|
|
243
|
+
// fingerprint manifest out of the workspace — the two files that are the
|
|
244
|
+
// whole evidence of the decommission.
|
|
245
|
+
const archiveEscapes = containWritePaths(tool, archive.value, [
|
|
246
|
+
STATE_MANIFEST_FILENAME,
|
|
247
|
+
RETIREMENT_LOG_FILENAME,
|
|
248
|
+
ARCHIVED_STATE_DIRNAME,
|
|
249
|
+
]);
|
|
250
|
+
if (archiveEscapes !== undefined) {
|
|
251
|
+
return refusal(tool, archiveEscapes.code, archiveEscapes.reason);
|
|
252
|
+
}
|
|
253
|
+
// What `.crewhaus` IS, before anything is concluded from its absence.
|
|
254
|
+
const stateKind = probeStateDir(stateDir);
|
|
255
|
+
if (!stateKind.ok)
|
|
256
|
+
return refusal(tool, stateKind.code, stateKind.reason);
|
|
257
|
+
// Fingerprint the live state. Done identically on the preview and the real
|
|
258
|
+
// run: it is the selection, and it is also what makes the archive
|
|
259
|
+
// checkable afterwards.
|
|
260
|
+
let stateTree;
|
|
261
|
+
if (stateKind.value === "directory") {
|
|
262
|
+
const walked = walkTree(stateDir, {
|
|
263
|
+
maxFiles: input.maxStateFiles ?? DEFAULT_MAX_STATE_FILES,
|
|
264
|
+
maxBytes: input.maxStateBytes ?? DEFAULT_MAX_STATE_BYTES,
|
|
265
|
+
hash: true,
|
|
266
|
+
});
|
|
267
|
+
if (!walked.ok)
|
|
268
|
+
return refusal(tool, walked.code, walked.reason);
|
|
269
|
+
stateTree = walked.value;
|
|
270
|
+
}
|
|
271
|
+
const stateReport = stateTree === undefined
|
|
272
|
+
? { present: false }
|
|
273
|
+
: {
|
|
274
|
+
present: true,
|
|
275
|
+
files: stateTree.fileCount,
|
|
276
|
+
bytes: stateTree.totalBytes,
|
|
277
|
+
oldestMtime: iso(stateTree.oldestMtimeMs),
|
|
278
|
+
newestMtime: iso(stateTree.newestMtimeMs),
|
|
279
|
+
fingerprintComplete: stateTree.complete,
|
|
280
|
+
...(stateTree.unreadable.length > 0
|
|
281
|
+
? { unreadable: sample(stateTree.unreadable, 10) }
|
|
282
|
+
: {}),
|
|
283
|
+
};
|
|
284
|
+
const notPerformed = [
|
|
285
|
+
{ step: "auditVerify", reason: AUDIT_VERIFY_UNAVAILABLE },
|
|
286
|
+
{ step: "complianceEvidence", reason: COMPLIANCE_EVIDENCE_UNAVAILABLE },
|
|
287
|
+
];
|
|
288
|
+
const registryReport = {
|
|
289
|
+
state: pinState.state,
|
|
290
|
+
...(pinState.state === "known"
|
|
291
|
+
? { pins, versions: pinState.versions.length, versionNames: sample(pinState.versions, 20) }
|
|
292
|
+
: {}),
|
|
293
|
+
};
|
|
294
|
+
if (dryRun) {
|
|
295
|
+
return json({
|
|
296
|
+
tool,
|
|
297
|
+
status: "preview",
|
|
298
|
+
dryRun: true,
|
|
299
|
+
spec: input.spec,
|
|
300
|
+
harnessDir: harness.value.rel === "" ? "." : harness.value.rel,
|
|
301
|
+
archiveDir: archive.value.rel,
|
|
302
|
+
plan: formatPlan(plan),
|
|
303
|
+
steps: plan.steps,
|
|
304
|
+
registry: registryReport,
|
|
305
|
+
state: stateReport,
|
|
306
|
+
notPerformed,
|
|
307
|
+
wouldNeed: input.acceptUnverified === true ? [] : ["acceptUnverified"],
|
|
308
|
+
nothingWasTouched: true,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (input.acceptUnverified !== true) {
|
|
312
|
+
return refusal(tool, "refused", `a real retirement needs acceptUnverified:true, because this build performed neither of two checks the CLI performs. ${AUDIT_VERIFY_UNAVAILABLE} ${COMPLIANCE_EVIDENCE_UNAVAILABLE}`, { notPerformed });
|
|
313
|
+
}
|
|
314
|
+
const { steps, recorded } = recordSteps(buildRetireSteps({
|
|
315
|
+
stateTree,
|
|
316
|
+
harnessDir: harness.value.real,
|
|
317
|
+
pinState,
|
|
318
|
+
now: () => new Date(),
|
|
319
|
+
}));
|
|
320
|
+
try {
|
|
321
|
+
const result = await runRetirement({
|
|
322
|
+
plan,
|
|
323
|
+
steps,
|
|
324
|
+
dryRun: false,
|
|
325
|
+
// The audit/compliance gate is being overridden KNOWINGLY: the two
|
|
326
|
+
// steps reported that they did not run, which is not the same as
|
|
327
|
+
// reporting tamper. `acceptUnverified` above is the caller saying so.
|
|
328
|
+
forceUnverified: true,
|
|
329
|
+
});
|
|
330
|
+
const archivedStateDir = path.join(archive.value.real, ARCHIVED_STATE_DIRNAME);
|
|
331
|
+
let verification;
|
|
332
|
+
if (!result.removedState) {
|
|
333
|
+
verification = {
|
|
334
|
+
performed: false,
|
|
335
|
+
reason: "no live state was moved, so there is nothing to verify",
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
else if (stateTree === undefined) {
|
|
339
|
+
verification = { performed: false, reason: "no fingerprint was taken before the move" };
|
|
340
|
+
}
|
|
341
|
+
else if (!isDirectory(archivedStateDir)) {
|
|
342
|
+
// The library moved the state somewhere this tool did not expect. Say
|
|
343
|
+
// that; do not report an unverified archive as a verified one.
|
|
344
|
+
verification = {
|
|
345
|
+
performed: false,
|
|
346
|
+
reason: `the archived state was not found at <archiveDir>/${ARCHIVED_STATE_DIRNAME}, so the archive could not be checked against the fingerprint`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const verified = verifyAgainst(archivedStateDir, stateTree.entries, stateTree.complete);
|
|
351
|
+
verification = {
|
|
352
|
+
performed: true,
|
|
353
|
+
ok: verified.ok,
|
|
354
|
+
checked: verified.checked,
|
|
355
|
+
verified: verified.verified,
|
|
356
|
+
...(verified.mismatched.length > 0
|
|
357
|
+
? { mismatched: sample(verified.mismatched, 10) }
|
|
358
|
+
: {}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return json({
|
|
362
|
+
tool,
|
|
363
|
+
status: "applied",
|
|
364
|
+
dryRun: false,
|
|
365
|
+
spec: input.spec,
|
|
366
|
+
harnessDir: harness.value.rel === "" ? "." : harness.value.rel,
|
|
367
|
+
archiveDir: archive.value.rel,
|
|
368
|
+
retiredAt: result.log.retiredAt,
|
|
369
|
+
removedState: result.removedState,
|
|
370
|
+
...(result.logPath !== undefined ? { retirementLog: result.logPath } : {}),
|
|
371
|
+
outcomes: result.log.outcomes,
|
|
372
|
+
notPerformed,
|
|
373
|
+
state: stateReport,
|
|
374
|
+
verification,
|
|
375
|
+
followUps: [
|
|
376
|
+
pinState.state === "absent"
|
|
377
|
+
? "no registry entry existed for this spec"
|
|
378
|
+
: "the registry entry was NOT tombstoned by this tool — delete the registered versions with the CLI",
|
|
379
|
+
"knowledge was not pushed — run the KnowledgeSync tool before retiring if the harness's lessons should outlive it",
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
// The library aborts BEFORE the destructive move on any failed step, so
|
|
385
|
+
// a failure here means the live state is still in place.
|
|
386
|
+
const stillThere = isDirectory(stateDir);
|
|
387
|
+
// THIS run's outcomes, recorded as the steps returned them. Reading them
|
|
388
|
+
// back out of the archive's retirement.json instead would be reading a
|
|
389
|
+
// file an earlier run may have written — the outcomes of somebody else's
|
|
390
|
+
// retirement, reported as this one's.
|
|
391
|
+
return json({
|
|
392
|
+
tool,
|
|
393
|
+
status: err instanceof RetireError ? "refused" : "failed",
|
|
394
|
+
dryRun: false,
|
|
395
|
+
spec: input.spec,
|
|
396
|
+
reason: err.message,
|
|
397
|
+
liveStateIntact: stillThere,
|
|
398
|
+
outcomes: recorded,
|
|
399
|
+
notPerformed,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// StoreMigrate
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
export const storeMigrate = buildTool({
|
|
408
|
+
name: "StoreMigrate",
|
|
409
|
+
description: "Migrate a harness's durable store to another store root: sessions and audit day files copied VERBATIM by @crewhaus/harness-lifecycle's own export path, every copied file then re-hashed at the destination, and a store-migration.json receipt written recording each file with its sha256. The source is not modified (beyond the audit evidence record the export appends), so the rollback is the source itself and re-running is safe: a second run reports what was already there. It REFUSES a destination that overlaps a live store, a destination already holding a receipt from a DIFFERENT source, and an overwrite of existing files unless overwrite is set. It also refuses a version-CHANGING migration outright rather than quietly copying records unchanged: the record transforms live in apps/cli, not here. What the export path does not carry — memories, prompts, graders, the registry, the policy files — is listed under notMigrated instead of being left silent. dryRun defaults to true.",
|
|
410
|
+
inputSchema: z.object({
|
|
411
|
+
to: z.string().min(1).describe("the destination store root, inside the workspace"),
|
|
412
|
+
dir: z.string().optional().describe("the harness directory (default: the working directory)"),
|
|
413
|
+
since: z
|
|
414
|
+
.string()
|
|
415
|
+
.optional()
|
|
416
|
+
.describe("only records at or after this ISO date/datetime (audit is included by whole day)"),
|
|
417
|
+
targetVersion: z
|
|
418
|
+
.number()
|
|
419
|
+
.int()
|
|
420
|
+
.positive()
|
|
421
|
+
.optional()
|
|
422
|
+
.describe("the store schema version to migrate TO; a version different from the source's is refused"),
|
|
423
|
+
dryRun: dryRunField,
|
|
424
|
+
overwrite: z
|
|
425
|
+
.boolean()
|
|
426
|
+
.optional()
|
|
427
|
+
.describe("allow writing over files that already exist at the destination"),
|
|
428
|
+
}),
|
|
429
|
+
destructive: true,
|
|
430
|
+
requireJustification: true,
|
|
431
|
+
execute: async (input) => {
|
|
432
|
+
const tool = "StoreMigrate";
|
|
433
|
+
const dryRun = input.dryRun ?? true;
|
|
434
|
+
const harness = containExistingDir(tool, input.dir ?? ".");
|
|
435
|
+
if (!harness.ok)
|
|
436
|
+
return refusal(tool, harness.code, harness.reason);
|
|
437
|
+
const dest = contain(tool, input.to);
|
|
438
|
+
if (!dest.ok)
|
|
439
|
+
return refusal(tool, dest.code, dest.reason);
|
|
440
|
+
// Parse first, then act on the PARSED value: the library gets the epoch
|
|
441
|
+
// ms, and the result echoes back the timestamp that was actually used,
|
|
442
|
+
// never the caller's string as though it were the cutoff.
|
|
443
|
+
let sinceMs;
|
|
444
|
+
if (input.since !== undefined) {
|
|
445
|
+
try {
|
|
446
|
+
sinceMs = parseRetentionDate("since", input.since);
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
if (err instanceof InvalidRetentionDateError) {
|
|
450
|
+
return refusal(tool, "bad-input", err.message);
|
|
451
|
+
}
|
|
452
|
+
throw err;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const version = readStoreVersion(harness.value.real);
|
|
456
|
+
if (version.state === "unknown") {
|
|
457
|
+
return refusal(tool, "unreadable", `${version.reason} — refusing to migrate a store whose version cannot be read`);
|
|
458
|
+
}
|
|
459
|
+
if (input.targetVersion !== undefined) {
|
|
460
|
+
const sourceVersion = version.state === "known" ? version.memoriesSchemaVersion : undefined;
|
|
461
|
+
if (sourceVersion === undefined) {
|
|
462
|
+
return refusal(tool, "refused", `targetVersion ${input.targetVersion} was requested but the source store carries no version stamp (.crewhaus/meta.json), so there is no FROM version to migrate from. ${RECORD_SHAPE_MIGRATION_UNAVAILABLE}`);
|
|
463
|
+
}
|
|
464
|
+
if (sourceVersion !== input.targetVersion) {
|
|
465
|
+
return refusal(tool, "refused", `the source store is at version ${sourceVersion} and targetVersion ${input.targetVersion} was requested. ${RECORD_SHAPE_MIGRATION_UNAVAILABLE}`, { sourceVersion, targetVersion: input.targetVersion });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// The inventory tells us which sessions have a sibling event log, which is
|
|
469
|
+
// what the destination paths depend on.
|
|
470
|
+
let inventory;
|
|
471
|
+
try {
|
|
472
|
+
inventory = await openHarnessRecordStore({ rootDir: harness.value.real, dryRun: true });
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
return refusal(tool, "unreadable", `the harness store could not be enumerated: ${err.message}`);
|
|
476
|
+
}
|
|
477
|
+
const withEventLog = new Set(inventory.sessions.filter((s) => s.eventLogPath !== undefined).map((s) => s.sessionId));
|
|
478
|
+
const sourcePathFor = new Map();
|
|
479
|
+
for (const s of inventory.sessions) {
|
|
480
|
+
sourcePathFor.set(`sessions/${s.sessionId}.json`, s.jsonPath);
|
|
481
|
+
if (s.eventLogPath !== undefined) {
|
|
482
|
+
sourcePathFor.set(`sessions/${s.sessionId}.jsonl`, s.eventLogPath);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
for (const a of inventory.auditDays)
|
|
486
|
+
sourcePathFor.set(`audit/${a.day}.jsonl`, a.path);
|
|
487
|
+
sourcePathFor.set("audit/_chain-tail.json", path.join(harness.value.real, ".crewhaus", "audit", "_chain-tail.json"));
|
|
488
|
+
// The preview: the library's own dry run, which is the same enumeration
|
|
489
|
+
// the real export performs.
|
|
490
|
+
let preview;
|
|
491
|
+
try {
|
|
492
|
+
preview = await runRetentionExport({
|
|
493
|
+
rootDir: harness.value.real,
|
|
494
|
+
outDir: dest.value.real,
|
|
495
|
+
...(sinceMs !== undefined ? { since: sinceMs } : {}),
|
|
496
|
+
dryRun: true,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
if (err instanceof RetentionConfigError)
|
|
501
|
+
return refusal(tool, "refused", err.message);
|
|
502
|
+
return refusal(tool, "unreadable", `the store could not be read: ${err.message}`);
|
|
503
|
+
}
|
|
504
|
+
const planned = plannedPaths({
|
|
505
|
+
sessions: preview.sessions,
|
|
506
|
+
auditDays: preview.auditDays,
|
|
507
|
+
chainTail: preview.chainTailCopied,
|
|
508
|
+
withEventLog,
|
|
509
|
+
});
|
|
510
|
+
// Everything the run WRITES, which is more than what it copies: the
|
|
511
|
+
// export path drops its own manifest.json beside the copies and this tool
|
|
512
|
+
// adds a receipt. Asking the conflict check only about the copies is how
|
|
513
|
+
// a destination's manifest.json got overwritten with no `overwrite` and
|
|
514
|
+
// no mention of it in the preview.
|
|
515
|
+
const written = writtenPaths(planned);
|
|
516
|
+
// Contain the write paths, not just the directory. `contain` above proved
|
|
517
|
+
// `to` is inside the workspace; it proved nothing about a symlink sitting
|
|
518
|
+
// at <to>/sessions/<id>.json, which copyFile would follow straight out of
|
|
519
|
+
// the workspace — and a DANGLING one does not even register as a
|
|
520
|
+
// conflict. Checked on the preview too, so the refusal is visible before
|
|
521
|
+
// the run that would have written through it.
|
|
522
|
+
const escaping = containWritePaths(tool, dest.value, written);
|
|
523
|
+
if (escaping !== undefined)
|
|
524
|
+
return refusal(tool, escaping.code, escaping.reason);
|
|
525
|
+
const destState = inspectDestination(dest.value.real, written);
|
|
526
|
+
if (!destState.ok)
|
|
527
|
+
return refusal(tool, destState.code, destState.reason);
|
|
528
|
+
const previousReceipt = destState.value.receipt;
|
|
529
|
+
if (previousReceipt !== undefined && previousReceipt.sourceRoot !== harness.value.real) {
|
|
530
|
+
return refusal(tool, "refused", `the destination already holds a ${RECEIPT_FILENAME} from a DIFFERENT store (${renderPath(previousReceipt.sourceRoot)}). Merging two harnesses' stores into one root cannot be undone, and their audit chains cannot both verify.`);
|
|
531
|
+
}
|
|
532
|
+
if (destState.value.receiptProblem !== undefined && input.overwrite !== true) {
|
|
533
|
+
return refusal(tool, "refused", `${destState.value.receiptProblem} — refusing to migrate into a destination whose previous migration cannot be identified. Pass overwrite if it is really yours.`);
|
|
534
|
+
}
|
|
535
|
+
if (destState.value.conflicts.length > 0 && input.overwrite !== true) {
|
|
536
|
+
return refusal(tool, "refused", `${destState.value.conflicts.length} file(s) at the destination would be written over. Pass overwrite to replace them.`, { conflicts: sample(destState.value.conflicts, 20) });
|
|
537
|
+
}
|
|
538
|
+
const uncovered = uncoveredStateEntries(harness.value.real);
|
|
539
|
+
const notMigrated = uncovered.ok
|
|
540
|
+
? uncovered.value
|
|
541
|
+
: [{ entry: ".crewhaus", reason: uncovered.reason }];
|
|
542
|
+
const partial = sinceMs !== undefined;
|
|
543
|
+
const chainNote = preview.chainTailCopied
|
|
544
|
+
? "the audit chain-tail anchor is included, so the destination audit set is independently verifiable"
|
|
545
|
+
: `the audit chain-tail anchor is NOT included${partial ? " (a since-filtered export starts mid-chain by construction)" : ""}, so the destination audit set will not verify on its own`;
|
|
546
|
+
const common = {
|
|
547
|
+
tool,
|
|
548
|
+
harnessDir: harness.value.rel === "" ? "." : harness.value.rel,
|
|
549
|
+
destination: dest.value.rel,
|
|
550
|
+
storeVersion: version,
|
|
551
|
+
...(sinceMs !== undefined ? { since: iso(sinceMs) } : {}),
|
|
552
|
+
chainTail: { copied: preview.chainTailCopied, note: chainNote },
|
|
553
|
+
notMigrated,
|
|
554
|
+
...(preview.skipped.length > 0 ? { skipped: sample([...preview.skipped], 20) } : {}),
|
|
555
|
+
...(previousReceipt !== undefined
|
|
556
|
+
? {
|
|
557
|
+
previousMigration: {
|
|
558
|
+
at: previousReceipt.writtenAt,
|
|
559
|
+
verified: previousReceipt.verified,
|
|
560
|
+
},
|
|
561
|
+
}
|
|
562
|
+
: {}),
|
|
563
|
+
};
|
|
564
|
+
if (dryRun) {
|
|
565
|
+
return json({
|
|
566
|
+
...common,
|
|
567
|
+
status: "preview",
|
|
568
|
+
dryRun: true,
|
|
569
|
+
wouldMigrate: {
|
|
570
|
+
sessions: preview.sessions.length,
|
|
571
|
+
auditDays: preview.auditDays.length,
|
|
572
|
+
files: planned.length,
|
|
573
|
+
},
|
|
574
|
+
wouldWrite: sample(written, 20),
|
|
575
|
+
conflicts: sample([...destState.value.conflicts], 20),
|
|
576
|
+
nothingWasTouched: true,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// Fingerprint the sources BEFORE the copy. Hashing them afterwards would
|
|
580
|
+
// compare against a store the export has already appended its own audit
|
|
581
|
+
// evidence record to, and would report today's audit file as a mismatch
|
|
582
|
+
// when nothing went wrong.
|
|
583
|
+
const before = new Map();
|
|
584
|
+
const unhashable = [];
|
|
585
|
+
for (const rel of planned) {
|
|
586
|
+
const src = sourcePathFor.get(rel);
|
|
587
|
+
if (src === undefined) {
|
|
588
|
+
unhashable.push({ rel, reason: "no source path for this planned file" });
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const digest = hashFile(src);
|
|
592
|
+
if (digest.ok)
|
|
593
|
+
before.set(rel, digest.value);
|
|
594
|
+
else
|
|
595
|
+
unhashable.push({ rel, reason: digest.reason });
|
|
596
|
+
}
|
|
597
|
+
let applied;
|
|
598
|
+
try {
|
|
599
|
+
applied = await runRetentionExport({
|
|
600
|
+
rootDir: harness.value.real,
|
|
601
|
+
outDir: dest.value.real,
|
|
602
|
+
...(sinceMs !== undefined ? { since: sinceMs } : {}),
|
|
603
|
+
dryRun: false,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
catch (err) {
|
|
607
|
+
if (err instanceof RetentionConfigError)
|
|
608
|
+
return refusal(tool, "refused", err.message);
|
|
609
|
+
return json({
|
|
610
|
+
...common,
|
|
611
|
+
status: "failed",
|
|
612
|
+
dryRun: false,
|
|
613
|
+
reason: `the migration failed part-way: ${err.message}`,
|
|
614
|
+
sourceIntact: true,
|
|
615
|
+
note: "the source store was not modified, so it is still the whole store; re-run to resume",
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
const appliedPaths = plannedPaths({
|
|
619
|
+
sessions: applied.sessions,
|
|
620
|
+
auditDays: applied.auditDays,
|
|
621
|
+
chainTail: applied.chainTailCopied,
|
|
622
|
+
withEventLog,
|
|
623
|
+
});
|
|
624
|
+
const files = [];
|
|
625
|
+
const mismatched = [];
|
|
626
|
+
for (const rel of appliedPaths) {
|
|
627
|
+
const abs = path.join(dest.value.real, ...rel.split("/"));
|
|
628
|
+
const digest = hashFile(abs);
|
|
629
|
+
if (!digest.ok) {
|
|
630
|
+
mismatched.push({ rel, problem: "missing", detail: digest.reason });
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const expected = before.get(rel);
|
|
634
|
+
if (expected === undefined) {
|
|
635
|
+
mismatched.push({
|
|
636
|
+
rel,
|
|
637
|
+
problem: "unverifiable",
|
|
638
|
+
detail: "the source was not fingerprinted before the copy, so this file cannot be verified",
|
|
639
|
+
});
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (expected !== digest.value) {
|
|
643
|
+
mismatched.push({
|
|
644
|
+
rel,
|
|
645
|
+
problem: "sha256",
|
|
646
|
+
detail: "the destination bytes differ from the source's",
|
|
647
|
+
});
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
let bytes = 0;
|
|
651
|
+
try {
|
|
652
|
+
bytes = statSync(abs).size;
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
// Size is decoration next to the digest that already matched.
|
|
656
|
+
}
|
|
657
|
+
files.push({ rel, bytes, sha256: digest.value });
|
|
658
|
+
}
|
|
659
|
+
const previewedSet = new Set(planned);
|
|
660
|
+
const appliedSet = new Set(appliedPaths);
|
|
661
|
+
const divergence = {
|
|
662
|
+
onlyPreviewed: planned.filter((p) => !appliedSet.has(p)),
|
|
663
|
+
onlyApplied: appliedPaths.filter((p) => !previewedSet.has(p)),
|
|
664
|
+
};
|
|
665
|
+
const receiptWrite = writeReceipt(dest.value.real, {
|
|
666
|
+
writtenAt: new Date().toISOString(),
|
|
667
|
+
sourceRoot: harness.value.real,
|
|
668
|
+
destinationRoot: dest.value.real,
|
|
669
|
+
storeVersion: version,
|
|
670
|
+
...(sinceMs !== undefined ? { since: iso(sinceMs) } : {}),
|
|
671
|
+
sessions: applied.sessions,
|
|
672
|
+
auditDays: applied.auditDays,
|
|
673
|
+
chainTailCopied: applied.chainTailCopied,
|
|
674
|
+
files,
|
|
675
|
+
verified: mismatched.length === 0 && unhashable.length === 0,
|
|
676
|
+
});
|
|
677
|
+
return json({
|
|
678
|
+
...common,
|
|
679
|
+
status: "applied",
|
|
680
|
+
dryRun: false,
|
|
681
|
+
migrated: {
|
|
682
|
+
sessions: applied.sessions.length,
|
|
683
|
+
auditDays: applied.auditDays.length,
|
|
684
|
+
// What LANDED, not what verified. `files` holds only the entries
|
|
685
|
+
// whose destination digest matched, so reporting its length as the
|
|
686
|
+
// file count under-reported a partly-failed migration — which is the
|
|
687
|
+
// number someone cleaning one up acts on. The two are reported
|
|
688
|
+
// separately, and `verified` says whether they agree.
|
|
689
|
+
files: appliedPaths.length,
|
|
690
|
+
filesVerified: files.length,
|
|
691
|
+
},
|
|
692
|
+
verified: mismatched.length === 0 && unhashable.length === 0,
|
|
693
|
+
...(mismatched.length > 0 ? { mismatched: sample(mismatched, 20) } : {}),
|
|
694
|
+
...(unhashable.length > 0 ? { unverifiable: sample(unhashable, 20) } : {}),
|
|
695
|
+
...(divergence.onlyPreviewed.length > 0 || divergence.onlyApplied.length > 0
|
|
696
|
+
? { divergedFromPreview: divergence }
|
|
697
|
+
: {}),
|
|
698
|
+
...(applied.evidence !== undefined ? { evidence: applied.evidence } : {}),
|
|
699
|
+
receipt: receiptWrite.ok ? receiptWrite.value : undefined,
|
|
700
|
+
...(receiptWrite.ok ? {} : { receiptProblem: receiptWrite.reason }),
|
|
701
|
+
sourceIntact: true,
|
|
702
|
+
rollback: "the source store was not deleted — it is still the authoritative copy until you remove it yourself",
|
|
703
|
+
});
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
// RetentionEnforce
|
|
708
|
+
// ---------------------------------------------------------------------------
|
|
709
|
+
export const retentionEnforce = buildTool({
|
|
710
|
+
name: "RetentionEnforce",
|
|
711
|
+
description: "Run a retention sweep or purge over a harness's stores: delete sessions past the age rule in .crewhaus/retention.json, honouring its pins and audit windows, and append a retention_enforcement record to the audit chain on a real run. The age rule, the pins, the windows and the audit-chain exclusion are all @crewhaus/harness-lifecycle's. dryRun defaults to true and reports the selection by COUNT and by the oldest and newest timestamps in it, so a misconfigured window is visible before it runs rather than after. It REFUSES a selection that covers every session in the store (unless allowDeleteAll says that is the intent), a purge cutoff in the future, a selection over maxDeletions, and — with no override at all — a selection containing a record whose timestamp is at or before 1971, because a timestamp that low is a broken clock rather than data that old and every age rule reads it as infinitely old. Audit data is never deleted. A store that cannot be enumerated is reported as unreadable, never as empty.",
|
|
712
|
+
inputSchema: z.object({
|
|
713
|
+
action: z
|
|
714
|
+
.enum(["sweep", "purge"])
|
|
715
|
+
.describe("sweep: the scheduled TTL pass. purge: the same rules restricted to records before a cutoff."),
|
|
716
|
+
dir: z.string().optional().describe("the harness directory (default: the working directory)"),
|
|
717
|
+
before: z
|
|
718
|
+
.string()
|
|
719
|
+
.optional()
|
|
720
|
+
.describe("purge only — an ISO date/datetime; only records older than this are candidates"),
|
|
721
|
+
dryRun: dryRunField,
|
|
722
|
+
allowDeleteAll: z
|
|
723
|
+
.boolean()
|
|
724
|
+
.optional()
|
|
725
|
+
.describe("confirm that deleting every session in the store is the intent"),
|
|
726
|
+
maxDeletions: z
|
|
727
|
+
.number()
|
|
728
|
+
.int()
|
|
729
|
+
.positive()
|
|
730
|
+
.optional()
|
|
731
|
+
.describe("refuse if the selection is larger than this"),
|
|
732
|
+
}),
|
|
733
|
+
destructive: true,
|
|
734
|
+
requireJustification: true,
|
|
735
|
+
execute: async (input) => {
|
|
736
|
+
const tool = "RetentionEnforce";
|
|
737
|
+
const dryRun = input.dryRun ?? true;
|
|
738
|
+
const harness = containExistingDir(tool, input.dir ?? ".");
|
|
739
|
+
if (!harness.ok)
|
|
740
|
+
return refusal(tool, harness.code, harness.reason);
|
|
741
|
+
const rootDir = harness.value.real;
|
|
742
|
+
// The store directories this sweep deletes from, contained. `dir` being
|
|
743
|
+
// inside the workspace says nothing about `.crewhaus/sessions` being a
|
|
744
|
+
// symlink out of it: readdir follows the link, session-store unlinks
|
|
745
|
+
// inside the link's target, and a sweep run against a harness in the
|
|
746
|
+
// workspace deleted files that were never in it. There is no flag for
|
|
747
|
+
// this — an irreversible delete outside the boundary is not something a
|
|
748
|
+
// caller can opt into here.
|
|
749
|
+
const storeEscapes = containWritePaths(tool, harness.value, COVERED_STORE_DIRS.map((d) => `${STATE_RELDIR}/${d}`));
|
|
750
|
+
if (storeEscapes !== undefined)
|
|
751
|
+
return refusal(tool, storeEscapes.code, storeEscapes.reason);
|
|
752
|
+
if (input.before !== undefined && input.action !== "purge") {
|
|
753
|
+
return refusal(tool, "bad-input", '"before" is a purge cutoff; a sweep has no cutoff. Use action:"purge" or drop it.');
|
|
754
|
+
}
|
|
755
|
+
let beforeMs;
|
|
756
|
+
if (input.before !== undefined) {
|
|
757
|
+
try {
|
|
758
|
+
beforeMs = parseRetentionDate("before", input.before);
|
|
759
|
+
}
|
|
760
|
+
catch (err) {
|
|
761
|
+
if (err instanceof InvalidRetentionDateError)
|
|
762
|
+
return refusal(tool, "bad-input", err.message);
|
|
763
|
+
throw err;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
// One clock reading for both passes, so the preview and the real run
|
|
767
|
+
// evaluate the age rule against the same instant.
|
|
768
|
+
const nowMs = Date.now();
|
|
769
|
+
const now = () => nowMs;
|
|
770
|
+
let inventory;
|
|
771
|
+
try {
|
|
772
|
+
inventory = await openHarnessRecordStore({ rootDir, dryRun: true });
|
|
773
|
+
}
|
|
774
|
+
catch (err) {
|
|
775
|
+
// Unreadable is not empty: a sweep must not proceed on a store it could
|
|
776
|
+
// not enumerate.
|
|
777
|
+
return refusal(tool, "unreadable", `the harness store could not be enumerated, so nothing was deleted: ${err.message}`);
|
|
778
|
+
}
|
|
779
|
+
const runPass = async (isDry) => input.action === "purge"
|
|
780
|
+
? await runRetentionPurge({
|
|
781
|
+
rootDir,
|
|
782
|
+
...(beforeMs !== undefined ? { before: beforeMs } : {}),
|
|
783
|
+
dryRun: isDry,
|
|
784
|
+
now,
|
|
785
|
+
})
|
|
786
|
+
: await runRetentionSweep({ rootDir, dryRun: isDry, now });
|
|
787
|
+
let preview;
|
|
788
|
+
try {
|
|
789
|
+
preview = await runPass(true);
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
if (err instanceof RetentionConfigError) {
|
|
793
|
+
return refusal(tool, "refused", `${err.message} — an enforcer that half-understands its policy must not guess, so nothing was deleted`);
|
|
794
|
+
}
|
|
795
|
+
return refusal(tool, "unreadable", `the retention pass could not be planned: ${err.message}`);
|
|
796
|
+
}
|
|
797
|
+
const selection = summarizeSelection(preview, inventory.sessions);
|
|
798
|
+
const policy = {
|
|
799
|
+
configPath: RETENTION_CONFIG_RELPATH,
|
|
800
|
+
fromFile: preview.config.fromFile,
|
|
801
|
+
sessionMaxAgeDays: preview.config.sessionMaxAgeDays,
|
|
802
|
+
pins: preview.config.pins.length,
|
|
803
|
+
cutoff: iso(beforeMs),
|
|
804
|
+
evaluatedAt: new Date(nowMs).toISOString(),
|
|
805
|
+
};
|
|
806
|
+
const kept = {
|
|
807
|
+
pinned: sample([...preview.keptPinned], 20),
|
|
808
|
+
withinRetention: sample([...preview.keptWithinRetention], 20),
|
|
809
|
+
auditWindow: sample([...preview.keptAuditWindow], 20),
|
|
810
|
+
outsideCutoff: sample([...preview.keptOutsideCutoff], 20),
|
|
811
|
+
auditChain: {
|
|
812
|
+
dayFiles: preview.keptAuditChain.length,
|
|
813
|
+
reason: AUDIT_CHAIN_EXCLUSION_REASON,
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
const selectionReport = {
|
|
817
|
+
count: selection.count,
|
|
818
|
+
ofSessionsInStore: selection.sessionsInStore,
|
|
819
|
+
oldest: iso(selection.oldestMs),
|
|
820
|
+
newest: iso(selection.newestMs),
|
|
821
|
+
ids: sample(selection.records.map((r) => r.id), 50),
|
|
822
|
+
...(selection.unknownTimestamp.length > 0
|
|
823
|
+
? { timestampUnknown: sample(selection.unknownTimestamp, 20) }
|
|
824
|
+
: {}),
|
|
825
|
+
};
|
|
826
|
+
const common = {
|
|
827
|
+
tool,
|
|
828
|
+
action: input.action,
|
|
829
|
+
harnessDir: harness.value.rel === "" ? "." : harness.value.rel,
|
|
830
|
+
policy,
|
|
831
|
+
selection: selectionReport,
|
|
832
|
+
kept,
|
|
833
|
+
...(preview.skipped.length > 0 ? { skipped: sample([...preview.skipped], 20) } : {}),
|
|
834
|
+
...(preview.activeAuditWindows.length > 0
|
|
835
|
+
? { activeAuditWindows: preview.activeAuditWindows }
|
|
836
|
+
: {}),
|
|
837
|
+
};
|
|
838
|
+
const refused = breadthRefusal(selection, {
|
|
839
|
+
allowDeleteAll: input.allowDeleteAll === true,
|
|
840
|
+
...(input.maxDeletions !== undefined ? { maxDeletions: input.maxDeletions } : {}),
|
|
841
|
+
...(beforeMs !== undefined ? { cutoffMs: beforeMs } : {}),
|
|
842
|
+
nowMs,
|
|
843
|
+
});
|
|
844
|
+
if (refused !== undefined) {
|
|
845
|
+
return json({
|
|
846
|
+
...common,
|
|
847
|
+
status: "refused",
|
|
848
|
+
code: refused.code,
|
|
849
|
+
reason: refused.reason,
|
|
850
|
+
deleted: 0,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
if (dryRun) {
|
|
854
|
+
return json({
|
|
855
|
+
...common,
|
|
856
|
+
status: "preview",
|
|
857
|
+
dryRun: true,
|
|
858
|
+
wouldDelete: selection.count,
|
|
859
|
+
nothingWasTouched: true,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
let appliedReport;
|
|
863
|
+
try {
|
|
864
|
+
appliedReport = await runPass(false);
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
return json({
|
|
868
|
+
...common,
|
|
869
|
+
status: "failed",
|
|
870
|
+
dryRun: false,
|
|
871
|
+
reason: `the enforcement pass failed: ${err.message}`,
|
|
872
|
+
deleted: 0,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
// The library re-enumerates on the real pass, so the two selections can
|
|
876
|
+
// genuinely differ (a session written or removed in between). Report the
|
|
877
|
+
// difference instead of presenting the preview as what happened.
|
|
878
|
+
const previewedIds = new Set(preview.deleted.map((d) => d.id));
|
|
879
|
+
const appliedIds = appliedReport.deleted.map((d) => d.id);
|
|
880
|
+
const divergence = {
|
|
881
|
+
onlyPreviewed: [...previewedIds].filter((id) => !appliedIds.includes(id)),
|
|
882
|
+
onlyApplied: appliedIds.filter((id) => !previewedIds.has(id)),
|
|
883
|
+
};
|
|
884
|
+
return json({
|
|
885
|
+
...common,
|
|
886
|
+
status: "applied",
|
|
887
|
+
dryRun: false,
|
|
888
|
+
deleted: appliedReport.deleted.length,
|
|
889
|
+
deletedIds: sample(appliedIds, 50),
|
|
890
|
+
...(divergence.onlyPreviewed.length > 0 || divergence.onlyApplied.length > 0
|
|
891
|
+
? { divergedFromPreview: divergence }
|
|
892
|
+
: {}),
|
|
893
|
+
evidence: appliedReport.evidence ?? null,
|
|
894
|
+
...(appliedReport.evidence === undefined
|
|
895
|
+
? {
|
|
896
|
+
evidenceNote: "no retention_enforcement record was appended: this harness has no audit store, and one was not fabricated to log that nothing happened",
|
|
897
|
+
}
|
|
898
|
+
: {}),
|
|
899
|
+
});
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
// ---------------------------------------------------------------------------
|
|
903
|
+
// KnowledgeSync
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
export const knowledgeSync = buildTool({
|
|
906
|
+
name: "KnowledgeSync",
|
|
907
|
+
description: "Push a harness's memories, grader and prompt fragments into a shared knowledge store, or pull the store's into the harness. Deduped by content hash and redacted by @crewhaus/harness-lifecycle, which drops any artifact still carrying a credential-shaped token after masking. A push requires the harness to have opted in (a .crewhaus/knowledge.json marker) and requires allowWithoutPiiRedaction, because @crewhaus/pii-redactor is not a dependency here: credential masking runs, but names, emails and phone numbers are NOT removed. Shared records that failed validation — a forged content hash, an oversized body — are COUNTED and reported rather than silently skipped, because a poisoned shared store is untrusted input, and a sync that moves more than maxArtifacts is refused. The text of artifacts dropped for still looking secret is never echoed back. dryRun defaults to true.",
|
|
908
|
+
inputSchema: z.object({
|
|
909
|
+
direction: z
|
|
910
|
+
.enum(["push", "pull"])
|
|
911
|
+
.describe("push: harness → shared store. pull: shared store → harness."),
|
|
912
|
+
dir: z.string().optional().describe("the harness directory (default: the working directory)"),
|
|
913
|
+
sharedDir: z
|
|
914
|
+
.string()
|
|
915
|
+
.optional()
|
|
916
|
+
.describe(`the shared knowledge store (default: ${SHARED_DIR_DEFAULT})`),
|
|
917
|
+
harness: z
|
|
918
|
+
.string()
|
|
919
|
+
.optional()
|
|
920
|
+
.describe("the label recorded as push provenance (default: the harness directory's name)"),
|
|
921
|
+
dryRun: dryRunField,
|
|
922
|
+
maxArtifacts: z
|
|
923
|
+
.number()
|
|
924
|
+
.int()
|
|
925
|
+
.positive()
|
|
926
|
+
.optional()
|
|
927
|
+
.describe(`refuse a sync moving more artifacts than this (default ${DEFAULT_MAX_ARTIFACTS})`),
|
|
928
|
+
allowWithoutPiiRedaction: z
|
|
929
|
+
.boolean()
|
|
930
|
+
.optional()
|
|
931
|
+
.describe("push although the PII pass of the redactor is not available in this build"),
|
|
932
|
+
}),
|
|
933
|
+
destructive: true,
|
|
934
|
+
requireJustification: true,
|
|
935
|
+
execute: async (input) => {
|
|
936
|
+
const tool = "KnowledgeSync";
|
|
937
|
+
const dryRun = input.dryRun ?? true;
|
|
938
|
+
const maxArtifacts = input.maxArtifacts ?? DEFAULT_MAX_ARTIFACTS;
|
|
939
|
+
const harnessDir = containExistingDir(tool, input.dir ?? ".");
|
|
940
|
+
if (!harnessDir.ok)
|
|
941
|
+
return refusal(tool, harnessDir.code, harnessDir.reason);
|
|
942
|
+
const shared = contain(tool, input.sharedDir ?? SHARED_DIR_DEFAULT);
|
|
943
|
+
if (!shared.ok)
|
|
944
|
+
return refusal(tool, shared.code, shared.reason);
|
|
945
|
+
const stateDir = path.join(harnessDir.value.real, STATE_RELDIR);
|
|
946
|
+
if (overlaps(shared.value.real, stateDir)) {
|
|
947
|
+
return refusal(tool, "refused", `the shared store and the harness's ${STATE_RELDIR} overlap — a store inside the harness it syncs with would re-ingest its own pushes`);
|
|
948
|
+
}
|
|
949
|
+
const label = input.harness ?? path.basename(harnessDir.value.real);
|
|
950
|
+
// A label is written into the shared store's provenance and read back by
|
|
951
|
+
// every harness that pulls: no control characters, and a bound.
|
|
952
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control characters is the point
|
|
953
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
954
|
+
if (label.length === 0 || label.length > MAX_HARNESS_LABEL || CONTROL_CHARS.test(label)) {
|
|
955
|
+
return refusal(tool, "bad-input", `"${renderPath(label)}" is not a usable harness label — it is empty, over ${MAX_HARNESS_LABEL} characters, or carries control characters`);
|
|
956
|
+
}
|
|
957
|
+
const sharedMemories = auditSharedMemories(shared.value.real);
|
|
958
|
+
if (!sharedMemories.ok)
|
|
959
|
+
return refusal(tool, sharedMemories.code, sharedMemories.reason);
|
|
960
|
+
const sharedGraders = auditSharedFragments(shared.value.real, "grader");
|
|
961
|
+
if (!sharedGraders.ok)
|
|
962
|
+
return refusal(tool, sharedGraders.code, sharedGraders.reason);
|
|
963
|
+
const sharedPrompts = auditSharedFragments(shared.value.real, "prompt");
|
|
964
|
+
if (!sharedPrompts.ok)
|
|
965
|
+
return refusal(tool, sharedPrompts.code, sharedPrompts.reason);
|
|
966
|
+
const sharedFragments = [...sharedGraders.value.fragments, ...sharedPrompts.value.fragments];
|
|
967
|
+
const rejectedShared = [
|
|
968
|
+
...sharedMemories.value.rejected,
|
|
969
|
+
...sharedGraders.value.rejected,
|
|
970
|
+
...sharedPrompts.value.rejected,
|
|
971
|
+
];
|
|
972
|
+
// Harness-side reads. Each can throw on an unreadable file, and that is a
|
|
973
|
+
// failure with a reason, not an empty harness.
|
|
974
|
+
let harnessMemories;
|
|
975
|
+
let harnessGraders;
|
|
976
|
+
let harnessPrompts;
|
|
977
|
+
try {
|
|
978
|
+
harnessMemories = readHarnessMemories(harnessDir.value.real);
|
|
979
|
+
harnessGraders = readHarnessGraders(harnessDir.value.real);
|
|
980
|
+
harnessPrompts = readHarnessPrompts(harnessDir.value.real);
|
|
981
|
+
}
|
|
982
|
+
catch (err) {
|
|
983
|
+
return refusal(tool, "unreadable", `the harness's knowledge could not be read: ${err.message}`);
|
|
984
|
+
}
|
|
985
|
+
const memoryFiles = auditHarnessMemoryFiles(harnessDir.value.real);
|
|
986
|
+
if (!memoryFiles.ok)
|
|
987
|
+
return refusal(tool, memoryFiles.code, memoryFiles.reason);
|
|
988
|
+
const harnessReport = {
|
|
989
|
+
memories: harnessMemories.length,
|
|
990
|
+
memoryFiles: memoryFiles.value.files,
|
|
991
|
+
memoryLines: memoryFiles.value.linesSeen,
|
|
992
|
+
...(memoryFiles.value.linesSeen > harnessMemories.length
|
|
993
|
+
? {
|
|
994
|
+
linesNotParsedAsMemories: memoryFiles.value.linesSeen - harnessMemories.length,
|
|
995
|
+
}
|
|
996
|
+
: {}),
|
|
997
|
+
graders: harnessGraders === undefined ? 0 : 1,
|
|
998
|
+
prompts: harnessPrompts.length,
|
|
999
|
+
};
|
|
1000
|
+
const sharedReport = {
|
|
1001
|
+
memories: sharedMemories.value.memories.length,
|
|
1002
|
+
fragments: sharedFragments.length,
|
|
1003
|
+
...(rejectedShared.length > 0 ? { rejected: sample(rejectedShared, 20) } : {}),
|
|
1004
|
+
};
|
|
1005
|
+
const common = {
|
|
1006
|
+
tool,
|
|
1007
|
+
direction: input.direction,
|
|
1008
|
+
harnessDir: harnessDir.value.rel === "" ? "." : harnessDir.value.rel,
|
|
1009
|
+
sharedDir: shared.value.rel,
|
|
1010
|
+
harness: harnessReport,
|
|
1011
|
+
shared: sharedReport,
|
|
1012
|
+
};
|
|
1013
|
+
if (input.direction === "push") {
|
|
1014
|
+
if (!harnessOptedIn(harnessDir.value.real)) {
|
|
1015
|
+
return refusal(tool, "refused", `this harness has not opted into knowledge sharing — ${KNOWLEDGE_MARKER_RELPATH} is absent or does not say { "share": true }`, common);
|
|
1016
|
+
}
|
|
1017
|
+
if (input.allowWithoutPiiRedaction !== true) {
|
|
1018
|
+
return refusal(tool, "refused", NO_PII_PASS_REASON, common);
|
|
1019
|
+
}
|
|
1020
|
+
const plan = await planPush({
|
|
1021
|
+
harness: label,
|
|
1022
|
+
memories: harnessMemories,
|
|
1023
|
+
graders: harnessGraders,
|
|
1024
|
+
prompts: harnessPrompts,
|
|
1025
|
+
existingMemoryHashes: new Set(sharedMemories.value.memories.map((m) => m.contentHash)),
|
|
1026
|
+
existingFragmentHashes: new Set(sharedFragments.map((f) => f.contentHash)),
|
|
1027
|
+
redact: makeRedactor(),
|
|
1028
|
+
now: () => new Date(),
|
|
1029
|
+
});
|
|
1030
|
+
const total = plan.memories.length + plan.fragments.length;
|
|
1031
|
+
const planReport = {
|
|
1032
|
+
memories: plan.memories.length,
|
|
1033
|
+
fragments: plan.fragments.length,
|
|
1034
|
+
duplicates: plan.skippedDuplicates,
|
|
1035
|
+
// COUNT ONLY. The library's own report prints a 60-character preview
|
|
1036
|
+
// of each dropped memory; those are the artifacts that still looked
|
|
1037
|
+
// like a credential, so echoing them here would publish the secret
|
|
1038
|
+
// into the caller's transcript.
|
|
1039
|
+
droppedForSecrets: plan.droppedSecrets.length,
|
|
1040
|
+
redaction: {
|
|
1041
|
+
credentialMasking: "applied",
|
|
1042
|
+
piiPass: "not performed",
|
|
1043
|
+
reason: NO_PII_PASS_REASON,
|
|
1044
|
+
},
|
|
1045
|
+
};
|
|
1046
|
+
// Contain what the push WRITES. `contain` proved the shared directory
|
|
1047
|
+
// is inside the workspace; it proved nothing about `memories.jsonl`
|
|
1048
|
+
// INSIDE it being a symlink out — and a shared store is untrusted input
|
|
1049
|
+
// by this tool's own description, so a link planted there would have
|
|
1050
|
+
// appended the harness's memories to whatever it pointed at. Checked on
|
|
1051
|
+
// the preview too, so the refusal shows before the run that would write.
|
|
1052
|
+
const pushWrites = [
|
|
1053
|
+
...(plan.memories.length > 0 ? [SHARED_MEMORIES_RELPATH] : []),
|
|
1054
|
+
SHARED_MANIFEST_RELPATH,
|
|
1055
|
+
...plan.fragments.map(sharedFragmentRelPath),
|
|
1056
|
+
];
|
|
1057
|
+
const pushEscapes = containWritePaths(tool, shared.value, pushWrites);
|
|
1058
|
+
if (pushEscapes !== undefined) {
|
|
1059
|
+
return refusal(tool, pushEscapes.code, pushEscapes.reason, { ...common, plan: planReport });
|
|
1060
|
+
}
|
|
1061
|
+
if (total > maxArtifacts) {
|
|
1062
|
+
return json({
|
|
1063
|
+
...common,
|
|
1064
|
+
status: "refused",
|
|
1065
|
+
code: "refused",
|
|
1066
|
+
reason: `refusing: this push would move ${total} artifact(s), over the maxArtifacts limit of ${maxArtifacts}.`,
|
|
1067
|
+
plan: planReport,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
if (dryRun) {
|
|
1071
|
+
return json({
|
|
1072
|
+
...common,
|
|
1073
|
+
status: "preview",
|
|
1074
|
+
dryRun: true,
|
|
1075
|
+
plan: planReport,
|
|
1076
|
+
nothingWasTouched: true,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
try {
|
|
1080
|
+
applyPush(shared.value.real, plan, () => new Date());
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
return json({
|
|
1084
|
+
...common,
|
|
1085
|
+
status: "failed",
|
|
1086
|
+
dryRun: false,
|
|
1087
|
+
reason: `the push failed part-way: ${err.message}`,
|
|
1088
|
+
plan: planReport,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
// Verify by re-reading the shared store through the same validator: an
|
|
1092
|
+
// artifact that did not land, or landed unreadable, is reported.
|
|
1093
|
+
const after = auditSharedMemories(shared.value.real);
|
|
1094
|
+
const landedHashes = after.ok
|
|
1095
|
+
? new Set(after.value.memories.map((m) => m.contentHash))
|
|
1096
|
+
: new Set();
|
|
1097
|
+
const missing = after.ok
|
|
1098
|
+
? plan.memories.filter((m) => !landedHashes.has(m.contentHash)).map((m) => m.contentHash)
|
|
1099
|
+
: [];
|
|
1100
|
+
const fragmentsMissing = plan.fragments.filter((f) => !existsSync(path.join(shared.value.real, ...sharedFragmentRelPath(f).split("/"))));
|
|
1101
|
+
return json({
|
|
1102
|
+
...common,
|
|
1103
|
+
status: "applied",
|
|
1104
|
+
dryRun: false,
|
|
1105
|
+
pushed: { memories: plan.memories.length, fragments: plan.fragments.length },
|
|
1106
|
+
plan: planReport,
|
|
1107
|
+
verified: after.ok && missing.length === 0 && fragmentsMissing.length === 0,
|
|
1108
|
+
...(after.ok
|
|
1109
|
+
? {}
|
|
1110
|
+
: {
|
|
1111
|
+
verificationProblem: `the shared store could not be re-read to verify the push: ${after.reason}`,
|
|
1112
|
+
}),
|
|
1113
|
+
...(missing.length > 0 ? { memoriesMissingAfterPush: sample(missing, 20) } : {}),
|
|
1114
|
+
...(fragmentsMissing.length > 0
|
|
1115
|
+
? {
|
|
1116
|
+
fragmentsMissingAfterPush: sample(fragmentsMissing.map((f) => f.filename), 20),
|
|
1117
|
+
}
|
|
1118
|
+
: {}),
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
// pull
|
|
1122
|
+
const localMemoryHashes = harnessMemoryHashes(harnessMemories);
|
|
1123
|
+
// A grader fragment a previous pull landed is written to
|
|
1124
|
+
// `graders.shared-<hash>.yaml`, which `readHarnessGraders` deliberately
|
|
1125
|
+
// does not look at — so without this every pull counted it as new and
|
|
1126
|
+
// reported pulling it again. Unreadable is a refusal, not an empty set:
|
|
1127
|
+
// guessing "not present" here is what makes a duplicate look new.
|
|
1128
|
+
const landedGraders = landedSharedGraderHashes(harnessDir.value.real);
|
|
1129
|
+
if (!landedGraders.ok)
|
|
1130
|
+
return refusal(tool, landedGraders.code, landedGraders.reason);
|
|
1131
|
+
const harnessFragmentHashes = new Set([
|
|
1132
|
+
...[
|
|
1133
|
+
...(harnessGraders === undefined ? [] : [harnessGraders.contents]),
|
|
1134
|
+
...harnessPrompts.map((p) => p.contents),
|
|
1135
|
+
].map((contents) => fragmentContentHash(contents)),
|
|
1136
|
+
...landedGraders.value,
|
|
1137
|
+
]);
|
|
1138
|
+
const plan = planPull({
|
|
1139
|
+
sharedMemories: sharedMemories.value.memories,
|
|
1140
|
+
sharedFragments,
|
|
1141
|
+
harnessMemoryHashes: localMemoryHashes,
|
|
1142
|
+
harnessFragmentHashes,
|
|
1143
|
+
});
|
|
1144
|
+
const total = plan.memories.length + plan.fragments.length;
|
|
1145
|
+
const planReport = {
|
|
1146
|
+
memories: plan.memories.length,
|
|
1147
|
+
fragments: plan.fragments.length,
|
|
1148
|
+
duplicates: plan.skippedDuplicates,
|
|
1149
|
+
};
|
|
1150
|
+
// Contain what the pull WRITES, for the same reason the push does: the
|
|
1151
|
+
// harness directory being inside the workspace says nothing about
|
|
1152
|
+
// `.crewhaus/prompts` being a symlink pointing out of it.
|
|
1153
|
+
const pullWrites = [
|
|
1154
|
+
...(plan.memories.length > 0 ? [PULLED_MEMORIES_RELPATH] : []),
|
|
1155
|
+
...plan.fragments.map(pulledFragmentRelPath),
|
|
1156
|
+
];
|
|
1157
|
+
const pullEscapes = containWritePaths(tool, harnessDir.value, pullWrites);
|
|
1158
|
+
if (pullEscapes !== undefined) {
|
|
1159
|
+
return refusal(tool, pullEscapes.code, pullEscapes.reason, { ...common, plan: planReport });
|
|
1160
|
+
}
|
|
1161
|
+
if (total > maxArtifacts) {
|
|
1162
|
+
return json({
|
|
1163
|
+
...common,
|
|
1164
|
+
status: "refused",
|
|
1165
|
+
code: "refused",
|
|
1166
|
+
reason: `refusing: this pull would bring in ${total} artifact(s) from an untrusted shared store, over the maxArtifacts limit of ${maxArtifacts}.`,
|
|
1167
|
+
plan: planReport,
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
if (dryRun) {
|
|
1171
|
+
return json({
|
|
1172
|
+
...common,
|
|
1173
|
+
status: "preview",
|
|
1174
|
+
dryRun: true,
|
|
1175
|
+
plan: planReport,
|
|
1176
|
+
nothingWasTouched: true,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
applyPull(harnessDir.value.real, plan, () => new Date());
|
|
1181
|
+
}
|
|
1182
|
+
catch (err) {
|
|
1183
|
+
return json({
|
|
1184
|
+
...common,
|
|
1185
|
+
status: "failed",
|
|
1186
|
+
dryRun: false,
|
|
1187
|
+
reason: `the pull failed part-way: ${err.message}`,
|
|
1188
|
+
plan: planReport,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
let landed;
|
|
1192
|
+
try {
|
|
1193
|
+
landed = harnessMemoryHashes(readHarnessMemories(harnessDir.value.real));
|
|
1194
|
+
}
|
|
1195
|
+
catch (err) {
|
|
1196
|
+
return json({
|
|
1197
|
+
...common,
|
|
1198
|
+
status: "applied",
|
|
1199
|
+
dryRun: false,
|
|
1200
|
+
pulled: planReport,
|
|
1201
|
+
verified: false,
|
|
1202
|
+
verificationProblem: `the harness memories could not be re-read to verify the pull: ${err.message}`,
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
const missing = plan.memories
|
|
1206
|
+
.filter((m) => !landed.has(m.contentHash))
|
|
1207
|
+
.map((m) => m.contentHash);
|
|
1208
|
+
// Fragments are verified too, as the push verifies its own. Checking only
|
|
1209
|
+
// the memories and calling the whole pull `verified` would be a partial
|
|
1210
|
+
// check reported as a total one.
|
|
1211
|
+
const fragmentsMissing = plan.fragments
|
|
1212
|
+
.map((f) => pulledFragmentRelPath(f))
|
|
1213
|
+
.filter((rel) => !existsSync(path.join(harnessDir.value.real, ...rel.split("/"))));
|
|
1214
|
+
return json({
|
|
1215
|
+
...common,
|
|
1216
|
+
status: "applied",
|
|
1217
|
+
dryRun: false,
|
|
1218
|
+
pulled: planReport,
|
|
1219
|
+
verified: missing.length === 0 && fragmentsMissing.length === 0,
|
|
1220
|
+
...(missing.length > 0 ? { memoriesMissingAfterPull: sample(missing, 20) } : {}),
|
|
1221
|
+
...(fragmentsMissing.length > 0
|
|
1222
|
+
? { fragmentsMissingAfterPull: sample(fragmentsMissing, 20) }
|
|
1223
|
+
: {}),
|
|
1224
|
+
});
|
|
1225
|
+
},
|
|
1226
|
+
});
|
|
1227
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
1228
|
+
export const LIFECYCLE_TOOLS = Object.freeze([
|
|
1229
|
+
harnessRetire,
|
|
1230
|
+
knowledgeSync,
|
|
1231
|
+
retentionEnforce,
|
|
1232
|
+
storeMigrate,
|
|
1233
|
+
]);
|