@isparling/engram-cli 0.1.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.
@@ -0,0 +1,479 @@
1
+ // A stateless, reviewable, ordered preview over a batch of knowledge
2
+ // candidates. This module composes the existing per-candidate transaction
3
+ // (`reconcileKnowledgeTransaction`) rather than re-implementing
4
+ // plan-building, diffing, or hashing: it privately prepares one ordered
5
+ // `KnowledgeProposal` per candidate, renders the complete diff for every
6
+ // mutation, and hashes the ordered (candidate id, plan hash) pairs so a
7
+ // caller can review and later re-approve the exact same batch.
8
+ //
9
+ // Public output is path-free by construction: `RollupReviewMutation` never
10
+ // carries `PlannedMutation.path`, only the record id and the rendered diff
11
+ // lines. The private `prepareKnowledgeRollup` additionally returns the
12
+ // ordered internal proposals the approval pass needs to apply the batch;
13
+ // those proposals are never exported.
14
+
15
+ import { createHash } from "node:crypto";
16
+ import { canonicalJson } from "./knowledgeRecord.ts";
17
+ import {
18
+ applyKnowledgeProposal,
19
+ reconcileKnowledgeTransaction,
20
+ type ApplyKnowledgeOutcome,
21
+ type KnowledgeProposal,
22
+ type PlannedMutation,
23
+ type ReconcileOutcome,
24
+ } from "./knowledgeTransaction.ts";
25
+ import { renderUnifiedDiff } from "./diff.ts";
26
+ import type { ActiveSpace } from "./spaceRegistry.ts";
27
+ import type { KnowledgeDisposition, KnowledgeError, KnowledgePack, KnowledgeResult } from "./knowledgeTypes.ts";
28
+ import { embedBoundCollection, type EmbedReport, type RefreshReport, type SpawnFn } from "./qmdRunner.ts";
29
+ import { requireDefined } from "./types.ts";
30
+
31
+ export type RollupClassification = "no-change" | "additive" | "non-additive";
32
+
33
+ export type KnowledgeRollupInput = {
34
+ schema_version: 0;
35
+ candidates: unknown[];
36
+ };
37
+
38
+ export type RollupReviewMutation = {
39
+ action: "create" | "update";
40
+ record_id: string;
41
+ diff: string[];
42
+ };
43
+
44
+ export type RollupReviewItem = {
45
+ index: number;
46
+ candidate_id: string;
47
+ plan_hash: string;
48
+ classification: RollupClassification;
49
+ disposition: KnowledgeDisposition;
50
+ summary: string;
51
+ mutations: RollupReviewMutation[];
52
+ };
53
+
54
+ export type KnowledgeRollupPreview = {
55
+ schema_version: 0;
56
+ status: "preview";
57
+ rollup_hash: string;
58
+ classification: RollupClassification;
59
+ approval_required: boolean;
60
+ items: RollupReviewItem[];
61
+ };
62
+
63
+ export type RollupPreparationFailure = {
64
+ schema_version: 0;
65
+ status: "invalid" | "retrieval_failed";
66
+ failed_index?: number;
67
+ failed_candidate_id?: string;
68
+ errors: KnowledgeError[];
69
+ };
70
+
71
+ /** One ordered, successfully reconciled batch member: kept private so a preview never leaks a full proposal. */
72
+ type PreparedItem = {
73
+ candidateId: string;
74
+ proposal: KnowledgeProposal;
75
+ };
76
+
77
+ type PreparedRollup = {
78
+ preview: KnowledgeRollupPreview;
79
+ items: readonly PreparedItem[];
80
+ };
81
+
82
+ function isObject(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === "object" && value !== null && !Array.isArray(value);
84
+ }
85
+
86
+ function rollupError(code: string, message: string, field?: string): KnowledgeError {
87
+ return field === undefined ? { kind: "validation", code, message } : { kind: "validation", code, field, message };
88
+ }
89
+
90
+ /**
91
+ * `reconcileKnowledgeTransaction`'s errors are an internal contract, not a
92
+ * public one: several transaction-layer codes (see `readCurrent` in
93
+ * knowledgeTransaction.ts, e.g. `path_escape`, `record_read_failed`,
94
+ * `record_shape_invalid`, `record_invalid`) embed the absolute on-disk
95
+ * record path directly into both `message` and `field`. `RollupPreparationFailure`
96
+ * is public, so every non-proposal outcome's errors are rewritten here to a
97
+ * fixed, code-derived message with no `field` at all before they leave this
98
+ * module — `kind` and `code` are the only parts of the original error kept
99
+ * verbatim, and neither can carry an interpolated path.
100
+ */
101
+ function sanitizeRollupError(error: KnowledgeError): KnowledgeError {
102
+ return { kind: error.kind, code: error.code, message: `rollup candidate preparation failed: ${error.code}` };
103
+ }
104
+
105
+ const ROLLUP_TOP_LEVEL_KEYS = ["schema_version", "candidates"];
106
+
107
+ /**
108
+ * Structural, cast-free wrapper validation. Only checks the batch envelope
109
+ * (exactly `schema_version`/`candidates`, a non-empty candidate array, and
110
+ * that every candidate carries a unique non-empty string `id`) — full
111
+ * candidate shape validation stays owned by `reconcileKnowledgeTransaction`.
112
+ */
113
+ function validateRollupInput(
114
+ raw: unknown,
115
+ ): KnowledgeResult<{ candidates: KnowledgeRollupInput["candidates"]; candidateIds: string[] }> {
116
+ if (!isObject(raw)) {
117
+ return { ok: false, errors: [rollupError("rollup_shape_invalid", "rollup batch must be an object with schema_version and candidates")] };
118
+ }
119
+ const errors: KnowledgeError[] = [];
120
+ for (const key of Object.keys(raw).filter((candidateKey) => !ROLLUP_TOP_LEVEL_KEYS.includes(candidateKey))) {
121
+ errors.push(rollupError("rollup_unknown_field", `rollup batch contains unknown field ${key}`, key));
122
+ }
123
+ if (raw.schema_version !== 0) {
124
+ errors.push(rollupError("rollup_schema_invalid", "rollup batch schema_version must be 0", "schema_version"));
125
+ }
126
+ if (!Array.isArray(raw.candidates)) {
127
+ errors.push(rollupError("rollup_shape_invalid", "rollup batch candidates must be an array", "candidates"));
128
+ return { ok: false, errors };
129
+ }
130
+ if (raw.candidates.length === 0) {
131
+ errors.push(rollupError("rollup_empty", "rollup batch must contain at least one candidate", "candidates"));
132
+ }
133
+
134
+ const candidateIds: string[] = [];
135
+ const seenIds = new Set<string>();
136
+ for (let index = 0; index < raw.candidates.length; index++) {
137
+ const candidateInput = raw.candidates[index];
138
+ const id = isObject(candidateInput) && typeof candidateInput.id === "string" && candidateInput.id.trim().length > 0
139
+ ? candidateInput.id
140
+ : undefined;
141
+ if (id === undefined) {
142
+ errors.push(rollupError("rollup_candidate_id_invalid", `candidates[${index}] must have a non-empty string id`, `candidates[${index}].id`));
143
+ continue;
144
+ }
145
+ if (seenIds.has(id)) {
146
+ errors.push(rollupError("rollup_candidate_duplicate", `candidate id ${id} appears more than once in the batch`, `candidates[${index}].id`));
147
+ continue;
148
+ }
149
+ seenIds.add(id);
150
+ candidateIds.push(id);
151
+ }
152
+
153
+ if (errors.length > 0) return { ok: false, errors };
154
+ return { ok: true, value: { candidates: raw.candidates, candidateIds } };
155
+ }
156
+
157
+ /** For creates `mutation.beforeText` is already `null`, so this uniformly diffs `""` -> `afterText` for creates and `beforeText` -> `afterText` for updates. */
158
+ function reviewMutation(mutation: PlannedMutation): RollupReviewMutation {
159
+ const diffText = renderUnifiedDiff(mutation.recordId, mutation.beforeText ?? "", mutation.afterText);
160
+ return { action: mutation.action, record_id: mutation.recordId, diff: diffText.split("\n") };
161
+ }
162
+
163
+ function reviewItem(index: number, item: PreparedItem): RollupReviewItem {
164
+ return {
165
+ index,
166
+ candidate_id: item.candidateId,
167
+ plan_hash: item.proposal.plan_hash,
168
+ classification: item.proposal.plan.classification,
169
+ disposition: item.proposal.plan.disposition,
170
+ summary: item.proposal.plan.summary,
171
+ mutations: item.proposal.plan.mutations.map(reviewMutation),
172
+ };
173
+ }
174
+
175
+ const RANK: Record<RollupClassification, number> = {
176
+ "no-change": 0,
177
+ additive: 1,
178
+ "non-additive": 2,
179
+ };
180
+
181
+ function rollupHash(items: readonly PreparedItem[]): string {
182
+ const identity = {
183
+ schema_version: 0,
184
+ items: items.map((item) => ({
185
+ candidate_id: item.candidateId,
186
+ plan_hash: item.proposal.plan_hash,
187
+ })),
188
+ };
189
+ return createHash("sha256").update(canonicalJson(identity)).digest("hex");
190
+ }
191
+
192
+ /**
193
+ * Privately prepares the ordered batch: validates the wrapper, then
194
+ * reconciles each candidate through the real per-candidate transaction in
195
+ * order, stopping at the first non-proposal outcome. Returns both the
196
+ * public preview and the ordered internal proposals the approval pass
197
+ * applies.
198
+ */
199
+ async function prepareKnowledgeRollup(input: {
200
+ binding: ActiveSpace;
201
+ batchInput: unknown;
202
+ pack: KnowledgePack;
203
+ spawnFn?: SpawnFn;
204
+ }): Promise<PreparedRollup | RollupPreparationFailure> {
205
+ const validated = validateRollupInput(input.batchInput);
206
+ if (!validated.ok) return { schema_version: 0, status: "invalid", errors: validated.errors };
207
+
208
+ const prepared: PreparedItem[] = [];
209
+ for (let index = 0; index < validated.value.candidates.length; index++) {
210
+ const candidateId = requireDefined(validated.value.candidateIds[index], `rollup candidate id missing for index ${index}`);
211
+ const outcome: ReconcileOutcome = await reconcileKnowledgeTransaction({
212
+ binding: input.binding,
213
+ candidateInput: validated.value.candidates[index],
214
+ pack: input.pack,
215
+ ...(input.spawnFn === undefined ? {} : { spawnFn: input.spawnFn }),
216
+ });
217
+ if (outcome.status !== "proposal") {
218
+ return {
219
+ schema_version: 0,
220
+ status: outcome.status === "retrieval_failed" ? "retrieval_failed" : "invalid",
221
+ failed_index: index,
222
+ failed_candidate_id: candidateId,
223
+ errors: outcome.errors.map(sanitizeRollupError),
224
+ };
225
+ }
226
+ prepared.push({ candidateId, proposal: outcome.proposal });
227
+ }
228
+
229
+ const items = prepared.map((item, index) => reviewItem(index, item));
230
+ const classification = items.reduce<RollupClassification>(
231
+ (worst, item) => (RANK[item.classification] > RANK[worst] ? item.classification : worst),
232
+ "no-change",
233
+ );
234
+ return {
235
+ preview: {
236
+ schema_version: 0,
237
+ status: "preview",
238
+ rollup_hash: rollupHash(prepared),
239
+ classification,
240
+ approval_required: classification === "non-additive",
241
+ items,
242
+ },
243
+ items: prepared,
244
+ };
245
+ }
246
+
247
+ export async function previewKnowledgeRollup(input: {
248
+ binding: ActiveSpace;
249
+ batchInput: unknown;
250
+ pack: KnowledgePack;
251
+ spawnFn?: SpawnFn;
252
+ }): Promise<KnowledgeRollupPreview | RollupPreparationFailure> {
253
+ const result = await prepareKnowledgeRollup(input);
254
+ return "preview" in result ? result.preview : result;
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Bound sequential approval with fail-stop application and one embedding
259
+ // pass. Consumes the private `prepareKnowledgeRollup` / `PreparedItem` and
260
+ // the existing per-candidate `applyKnowledgeProposal` rather than reinventing
261
+ // plan application, revalidation, or hashing.
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /** One item `approveKnowledgeRollup`'s apply loop actually processed to
265
+ * completion (`committed` or `no_change`) before any stop. Path-free and
266
+ * text-free by construction, matching `RollupReviewMutation`'s own public
267
+ * discipline. */
268
+ export type RollupApplyItem = {
269
+ index: number;
270
+ candidate_id: string;
271
+ plan_hash: string;
272
+ status: "committed" | "no_change";
273
+ mutations: { action: "create" | "update"; record_id: string }[];
274
+ refresh: RefreshReport;
275
+ };
276
+
277
+ /** A sanitized reason a rollup approval fail-stopped: every `KnowledgeError`
278
+ * is rewritten through `sanitizeRollupError`, and no variant carries a
279
+ * filesystem path except `recovery_required`'s `recovery.paths` — the one
280
+ * documented exception, since recovery is impossible without the affected
281
+ * paths (the same operator-only exception the underlying transaction
282
+ * already exposes). */
283
+ export type RollupStopCause = {
284
+ status: Exclude<ApplyKnowledgeOutcome["status"], "committed" | "no_change">;
285
+ plan_hash?: string;
286
+ expected_plan_hash?: string;
287
+ actual_plan_hash?: string;
288
+ errors?: KnowledgeError[];
289
+ reason?: string;
290
+ refresh: RefreshReport;
291
+ recovery?: { required: true; paths: string[]; detail: string };
292
+ };
293
+
294
+ export type RollupEmbeddingReport =
295
+ | EmbedReport
296
+ | { attempted: false; state: "not-attempted"; detail: "rollup wrote no records" };
297
+
298
+ export type KnowledgeRollupApplyOutcome =
299
+ | {
300
+ schema_version: 0;
301
+ status: "committed" | "no_change";
302
+ rollup_hash: string;
303
+ classification: RollupClassification;
304
+ items: RollupApplyItem[];
305
+ embedding: RollupEmbeddingReport;
306
+ }
307
+ | {
308
+ schema_version: 0;
309
+ status: "stopped";
310
+ rollup_hash: string;
311
+ committed_items: RollupApplyItem[];
312
+ stopped_index: number;
313
+ stopped_candidate_id: string;
314
+ remaining_candidate_ids: string[];
315
+ cause: RollupStopCause;
316
+ embedding: RollupEmbeddingReport;
317
+ }
318
+ | { schema_version: 0; status: "stale_approval"; expected_rollup_hash: string; actual_rollup_hash: string; committed_items: []; embedding: RollupEmbeddingReport }
319
+ | RollupPreparationFailure;
320
+
321
+ const ROLLUP_EMBEDDING_NOT_ATTEMPTED: RollupEmbeddingReport = {
322
+ attempted: false,
323
+ state: "not-attempted",
324
+ detail: "rollup wrote no records",
325
+ };
326
+
327
+ /** `embedBoundCollection`'s `EmbedReport.detail` can carry qmd's raw
328
+ * stdout/stderr verbatim, which may include an absolute path (for example
329
+ * `binding.recordsRoot`, echoed back by qmd itself). Mirrors
330
+ * `sanitizeRollupError`'s discipline: the free-text detail is replaced with
331
+ * a fixed string derived only from the already-public `state`, never
332
+ * passed through unmodified. */
333
+ function sanitizeEmbeddingReport(report: EmbedReport): EmbedReport {
334
+ return {
335
+ attempted: report.attempted,
336
+ state: report.state,
337
+ detail: `rollup embedding reported state: ${report.state}`,
338
+ };
339
+ }
340
+
341
+ /** `refreshQmdCollection`'s `RefreshReport.detail` can carry qmd's raw
342
+ * stdout/stderr verbatim, which may include an absolute path (for example
343
+ * `binding.recordsRoot`, echoed back by qmd itself). Mirrors
344
+ * `sanitizeEmbeddingReport`'s discipline: the free-text detail is replaced
345
+ * with a fixed string derived only from the already-public `state`, never
346
+ * passed through unmodified. */
347
+ function sanitizeRefreshReport(report: RefreshReport): RefreshReport {
348
+ return { ...report, detail: `rollup refresh reported state: ${report.state}` };
349
+ }
350
+
351
+ /** The apply loop's one shared embedding call site: never called from
352
+ * inside the loop, and only if at least one item literally committed a
353
+ * write (a `no_change` item alone never embeds). */
354
+ async function boundaryEmbedding(binding: ActiveSpace, spawnFn: SpawnFn | undefined, committedCount: number): Promise<RollupEmbeddingReport> {
355
+ if (committedCount === 0) return ROLLUP_EMBEDDING_NOT_ATTEMPTED;
356
+ return sanitizeEmbeddingReport(await embedBoundCollection(binding, spawnFn));
357
+ }
358
+
359
+ function reviewApplyItem(
360
+ index: number,
361
+ item: PreparedItem,
362
+ outcome: Extract<ApplyKnowledgeOutcome, { status: "committed" | "no_change" }>,
363
+ ): RollupApplyItem {
364
+ return {
365
+ index,
366
+ candidate_id: item.candidateId,
367
+ plan_hash: outcome.plan_hash,
368
+ status: outcome.status,
369
+ mutations: outcome.mutations.map((mutation) => ({ action: mutation.action, record_id: mutation.recordId })),
370
+ refresh: sanitizeRefreshReport(outcome.refresh),
371
+ };
372
+ }
373
+
374
+ /** `outcome` must never be `committed`/`no_change` — those are the only two
375
+ * statuses the apply loop continues past instead of stopping on. */
376
+ function toStopCause(outcome: ApplyKnowledgeOutcome): RollupStopCause {
377
+ switch (outcome.status) {
378
+ case "invalid":
379
+ return { status: "invalid", errors: outcome.errors.map(sanitizeRollupError), refresh: outcome.refresh };
380
+ case "stale_approval":
381
+ return {
382
+ status: "stale_approval",
383
+ expected_plan_hash: outcome.expected_plan_hash,
384
+ actual_plan_hash: outcome.actual_plan_hash,
385
+ reason: outcome.reason,
386
+ refresh: outcome.refresh,
387
+ };
388
+ case "rejected":
389
+ return { status: "rejected", plan_hash: outcome.plan_hash, refresh: outcome.refresh };
390
+ case "approval_required":
391
+ return { status: "approval_required", plan_hash: outcome.plan_hash, refresh: outcome.refresh };
392
+ case "lock_conflict":
393
+ case "lock_owner_unverifiable":
394
+ return { status: outcome.status, errors: outcome.errors.map(sanitizeRollupError), refresh: outcome.refresh };
395
+ case "recovery_required":
396
+ return { status: "recovery_required", plan_hash: outcome.plan_hash, recovery: outcome.recovery, refresh: outcome.refresh };
397
+ case "committed":
398
+ case "no_change":
399
+ throw new Error(`internal invariant violated: toStopCause called with a continuable outcome status ${outcome.status}`);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Re-runs batch preparation against the CURRENT on-disk state — never a
405
+ * stored proposal from an earlier preview — and refuses the whole batch
406
+ * before any write if the freshly rebuilt `rollup_hash` no longer matches
407
+ * what the caller approved. Once past that check, applies each item's
408
+ * freshly rebuilt proposal in order via the existing `applyKnowledgeProposal`,
409
+ * bound to that item's own current plan hash. The first outcome that is
410
+ * neither `committed` nor `no_change` stops the batch: earlier commits are
411
+ * left in place (no cross-candidate rollback) and every untried candidate
412
+ * id is reported so the caller knows what still needs a decision.
413
+ *
414
+ * `embedBoundCollection` is called at most once, after the loop exits
415
+ * (whether by finishing or by stopping), and only if at least one item
416
+ * actually committed — never inside the loop, and never for an all-`no_change`
417
+ * or pre-write-refused batch that wrote nothing.
418
+ */
419
+ export async function approveKnowledgeRollup(input: {
420
+ binding: ActiveSpace;
421
+ batchInput: unknown;
422
+ expectedRollupHash: string;
423
+ pack: KnowledgePack;
424
+ spawnFn?: SpawnFn;
425
+ }): Promise<KnowledgeRollupApplyOutcome> {
426
+ const prepared = await prepareKnowledgeRollup(input);
427
+ if (!("items" in prepared)) return prepared;
428
+
429
+ if (prepared.preview.rollup_hash !== input.expectedRollupHash) {
430
+ return {
431
+ schema_version: 0,
432
+ status: "stale_approval",
433
+ expected_rollup_hash: input.expectedRollupHash,
434
+ actual_rollup_hash: prepared.preview.rollup_hash,
435
+ committed_items: [],
436
+ embedding: ROLLUP_EMBEDDING_NOT_ATTEMPTED,
437
+ };
438
+ }
439
+
440
+ const items: RollupApplyItem[] = [];
441
+ let committedCount = 0;
442
+ for (let index = 0; index < prepared.items.length; index++) {
443
+ const item = requireDefined(prepared.items[index], `rollup approval item missing for index ${index}`);
444
+ const outcome = await applyKnowledgeProposal({
445
+ binding: input.binding,
446
+ proposal: item.proposal,
447
+ decision: "approve",
448
+ expectedPlanHash: item.proposal.plan_hash,
449
+ pack: input.pack,
450
+ ...(input.spawnFn === undefined ? {} : { spawnFn: input.spawnFn }),
451
+ });
452
+ if (outcome.status === "committed" || outcome.status === "no_change") {
453
+ if (outcome.status === "committed") committedCount++;
454
+ items.push(reviewApplyItem(index, item, outcome));
455
+ continue;
456
+ }
457
+
458
+ return {
459
+ schema_version: 0,
460
+ status: "stopped",
461
+ rollup_hash: prepared.preview.rollup_hash,
462
+ committed_items: items,
463
+ stopped_index: index,
464
+ stopped_candidate_id: item.candidateId,
465
+ cause: toStopCause(outcome),
466
+ remaining_candidate_ids: prepared.items.slice(index + 1).map((remaining) => remaining.candidateId),
467
+ embedding: await boundaryEmbedding(input.binding, input.spawnFn, committedCount),
468
+ };
469
+ }
470
+
471
+ return {
472
+ schema_version: 0,
473
+ status: committedCount > 0 ? "committed" : "no_change",
474
+ rollup_hash: prepared.preview.rollup_hash,
475
+ classification: prepared.preview.classification,
476
+ items,
477
+ embedding: await boundaryEmbedding(input.binding, input.spawnFn, committedCount),
478
+ };
479
+ }