@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.
- package/LICENSE +187 -0
- package/README.md +41 -0
- package/bin/engram +14 -0
- package/package.json +32 -0
- package/release/engram-release.ts +1493 -0
- package/src/atomicWrite.ts +80 -0
- package/src/candidate.ts +229 -0
- package/src/classify.ts +275 -0
- package/src/cli.ts +709 -0
- package/src/contentHash.ts +54 -0
- package/src/deepFreeze.ts +13 -0
- package/src/diff.ts +81 -0
- package/src/guardedRetrieval.ts +128 -0
- package/src/guardedRetrievalInternal.ts +321 -0
- package/src/knowledgeRecord.ts +256 -0
- package/src/knowledgeRetrieval.ts +564 -0
- package/src/knowledgeRollup.ts +479 -0
- package/src/knowledgeTransaction.ts +683 -0
- package/src/knowledgeTypes.ts +249 -0
- package/src/knowledgeValidation.ts +269 -0
- package/src/markdownRecord.ts +265 -0
- package/src/packLoader.ts +188 -0
- package/src/packTypes.ts +12 -0
- package/src/presentation.ts +673 -0
- package/src/qmdConfigGuard.ts +245 -0
- package/src/qmdRunner.ts +392 -0
- package/src/realPath.ts +47 -0
- package/src/spaceBinding.ts +37 -0
- package/src/spaceRegistry.ts +1139 -0
- package/src/submit.ts +228 -0
- package/src/symlinkGuard.ts +71 -0
- package/src/transactionLock.ts +188 -0
- package/src/types.ts +38 -0
|
@@ -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
|
+
}
|