@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,683 @@
|
|
|
1
|
+
import { lstat, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { relative, resolve, sep } from "node:path";
|
|
4
|
+
import { atomicWriteFile, AtomicWriteDirectorySyncError } from "./atomicWrite.ts";
|
|
5
|
+
import { canonicalJson, hashKnowledgeText, parseKnowledgeRecord, serializeKnowledgeRecord } from "./knowledgeRecord.ts";
|
|
6
|
+
import { validateKnowledgeEnvelope } from "./knowledgeValidation.ts";
|
|
7
|
+
import { retrieveRelatedRecords, type RetrievalReceipt } from "./knowledgeRetrieval.ts";
|
|
8
|
+
import { acquireTransactionLock, transactionLockDirectory, type TransactionLock, type TransactionLockHooks } from "./transactionLock.ts";
|
|
9
|
+
import { REFRESH_NOT_ATTEMPTED, refreshQmdCollection, type AttemptedRefreshReport, type RefreshReport, type SpawnFn } from "./qmdRunner.ts";
|
|
10
|
+
import { resolveRecordPath } from "./spaceBinding.ts";
|
|
11
|
+
import type { ActiveSpace } from "./spaceRegistry.ts";
|
|
12
|
+
import { KNOWLEDGE_DISPOSITIONS } from "./knowledgeTypes.ts";
|
|
13
|
+
import type {
|
|
14
|
+
KnowledgeDisposition,
|
|
15
|
+
KnowledgeEnvelope,
|
|
16
|
+
KnowledgeError,
|
|
17
|
+
KnowledgePack,
|
|
18
|
+
KnowledgeRecord,
|
|
19
|
+
KnowledgeResult,
|
|
20
|
+
PackMutation,
|
|
21
|
+
PackReconciliation,
|
|
22
|
+
} from "./knowledgeTypes.ts";
|
|
23
|
+
|
|
24
|
+
export { transactionLockDirectory } from "./transactionLock.ts";
|
|
25
|
+
|
|
26
|
+
export type CandidateSubmissionOutcome =
|
|
27
|
+
| { schema_version: 0; status: "submitted"; candidate: KnowledgeEnvelope }
|
|
28
|
+
| { schema_version: 0; status: "invalid"; errors: KnowledgeError[] };
|
|
29
|
+
|
|
30
|
+
export type PlannedMutation = {
|
|
31
|
+
recordId: string;
|
|
32
|
+
action: "create" | "update";
|
|
33
|
+
path: string;
|
|
34
|
+
beforeText: string | null;
|
|
35
|
+
beforeHash: string | null;
|
|
36
|
+
afterText: string;
|
|
37
|
+
after: KnowledgeRecord;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type AuthoritativeInput = {
|
|
41
|
+
recordId: string;
|
|
42
|
+
path: string;
|
|
43
|
+
beforeText: string;
|
|
44
|
+
beforeHash: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type KnowledgeMutationPlan = {
|
|
48
|
+
classification: "additive" | "non-additive" | "no-change";
|
|
49
|
+
disposition: KnowledgeDisposition;
|
|
50
|
+
summary: string;
|
|
51
|
+
mutations: PlannedMutation[];
|
|
52
|
+
authoritativeInputs: AuthoritativeInput[];
|
|
53
|
+
protectedPaths: string[];
|
|
54
|
+
bindingFingerprint: string;
|
|
55
|
+
candidateFingerprint: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type KnowledgeProposal = {
|
|
59
|
+
schema_version: 0;
|
|
60
|
+
candidate: KnowledgeEnvelope;
|
|
61
|
+
retrieval: RetrievalReceipt;
|
|
62
|
+
plan: KnowledgeMutationPlan;
|
|
63
|
+
plan_hash: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type ReconcileOutcome =
|
|
67
|
+
| { schema_version: 0; status: "invalid"; errors: KnowledgeError[]; retrieval: { attempted: false } }
|
|
68
|
+
| { schema_version: 0; status: "retrieval_failed"; errors: KnowledgeError[]; retrieval: RetrievalReceipt }
|
|
69
|
+
| { schema_version: 0; status: "proposal"; proposal: KnowledgeProposal };
|
|
70
|
+
|
|
71
|
+
export type WriteKnowledgeRecordFn = (path: string, content: string) => Promise<void>;
|
|
72
|
+
|
|
73
|
+
export type ApplyKnowledgeInput = {
|
|
74
|
+
binding: ActiveSpace;
|
|
75
|
+
proposal: KnowledgeProposal;
|
|
76
|
+
decision: "approve" | "reject";
|
|
77
|
+
expectedPlanHash?: string;
|
|
78
|
+
candidateInput?: unknown;
|
|
79
|
+
pack: KnowledgePack;
|
|
80
|
+
writeRecord?: WriteKnowledgeRecordFn;
|
|
81
|
+
spawnFn?: SpawnFn;
|
|
82
|
+
transactionLockHooks?: TransactionLockHooks;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type ApplyKnowledgeOutcome =
|
|
86
|
+
| { schema_version: 0; status: "invalid"; errors: KnowledgeError[]; refresh: RefreshReport }
|
|
87
|
+
| { schema_version: 0; status: "stale_approval"; expected_plan_hash: string; actual_plan_hash: string; reason: string; refresh: RefreshReport }
|
|
88
|
+
| { schema_version: 0; status: "rejected"; plan_hash: string; mutations: PlannedMutation[]; refresh: RefreshReport; lock: { state: "acquired" | "recovered" } }
|
|
89
|
+
| { schema_version: 0; status: "no_change"; plan_hash: string; mutations: []; refresh: RefreshReport; lock: { state: "acquired" | "recovered" } }
|
|
90
|
+
| { schema_version: 0; status: "committed"; plan_hash: string; mutations: PlannedMutation[]; refresh: AttemptedRefreshReport; lock: { state: "acquired" | "recovered" } }
|
|
91
|
+
| { schema_version: 0; status: "approval_required"; plan_hash: string; mutations: PlannedMutation[]; refresh: RefreshReport }
|
|
92
|
+
| { schema_version: 0; status: "lock_conflict" | "lock_owner_unverifiable"; errors: KnowledgeError[]; refresh: RefreshReport }
|
|
93
|
+
| { schema_version: 0; status: "recovery_required"; plan_hash: string; mutations: PlannedMutation[]; recovery: { required: true; paths: string[]; detail: string }; refresh: RefreshReport };
|
|
94
|
+
|
|
95
|
+
function invalidOutcome(errors: KnowledgeError[]): ReconcileOutcome {
|
|
96
|
+
return { schema_version: 0, status: "invalid", errors, retrieval: { attempted: false } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function transactionError(code: string, message: string, field?: string): KnowledgeError {
|
|
100
|
+
return field === undefined
|
|
101
|
+
? { kind: "transaction", code, message }
|
|
102
|
+
: { kind: "transaction", code, field, message };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validationError(code: string, message: string, field?: string): KnowledgeError {
|
|
106
|
+
return field === undefined
|
|
107
|
+
? { kind: "validation", code, message }
|
|
108
|
+
: { kind: "validation", code, field, message };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hashBytes(bytes: Buffer): string {
|
|
112
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pathWithin(root: string, candidate: string): boolean {
|
|
116
|
+
const pathFromRoot = relative(resolve(root), resolve(candidate));
|
|
117
|
+
return pathFromRoot === "" || (!pathFromRoot.startsWith(".." + sep) && pathFromRoot !== ".." && !pathFromRoot.startsWith(sep));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function bindingFingerprint(binding: ActiveSpace): string {
|
|
121
|
+
return hashKnowledgeText(canonicalJson({
|
|
122
|
+
spaceId: binding.spaceId,
|
|
123
|
+
spaceRoot: binding.spaceRoot,
|
|
124
|
+
recordsRoot: binding.recordsRoot,
|
|
125
|
+
manifestPath: binding.manifestPath,
|
|
126
|
+
qmdConfigDir: binding.qmdConfigDir,
|
|
127
|
+
qmdCacheHome: binding.qmdCacheHome,
|
|
128
|
+
qmdCollectionName: binding.qmdCollectionName,
|
|
129
|
+
sessionsDir: binding.sessionsDir,
|
|
130
|
+
readRoots: [...binding.readRoots].sort(),
|
|
131
|
+
writeRoots: [...binding.writeRoots].sort(),
|
|
132
|
+
allowedModels: [...binding.allowedModels].sort(),
|
|
133
|
+
credentialEnv: [...binding.credentialEnv].sort(),
|
|
134
|
+
knowledgeSchemaVersion: binding.knowledgeSchemaVersion,
|
|
135
|
+
packs: binding.packs.map((pack) => ({ id: pack.id, version: pack.version })).sort((left, right) => left.id.localeCompare(right.id)),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function installedPack(binding: ActiveSpace, pack: KnowledgePack, candidate: KnowledgeEnvelope): KnowledgeResult<void> {
|
|
140
|
+
const installed = binding.packs.some((item) => item.id === candidate.pack.id && item.version === candidate.pack.version);
|
|
141
|
+
if (!installed) return { ok: false, errors: [validationError("pack_not_installed", `pack ${candidate.pack.id}@${candidate.pack.version} is not installed in the active binding`, "pack")] };
|
|
142
|
+
if (candidate.pack.id !== pack.id || candidate.pack.version !== pack.version) {
|
|
143
|
+
return { ok: false, errors: [validationError("pack_mismatch", "candidate pack and loaded pack do not match", "pack")] };
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, value: undefined };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isKnowledgeDisposition(value: unknown): value is KnowledgeDisposition {
|
|
149
|
+
return KNOWLEDGE_DISPOSITIONS.some((item) => item === value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function prepareCandidate(binding: ActiveSpace, input: unknown, pack: KnowledgePack): KnowledgeResult<KnowledgeEnvelope> {
|
|
153
|
+
const parsed = validateKnowledgeEnvelope(input);
|
|
154
|
+
if (!parsed.ok) return parsed;
|
|
155
|
+
if (parsed.value.scope.space !== binding.spaceId) {
|
|
156
|
+
return { ok: false, errors: [validationError("scope_space_mismatch", `scope.space must equal the active space ${binding.spaceId}`, "scope.space")] };
|
|
157
|
+
}
|
|
158
|
+
const packResult = installedPack(binding, pack, parsed.value);
|
|
159
|
+
if (!packResult.ok) return packResult;
|
|
160
|
+
let packValidation: KnowledgeResult<void>;
|
|
161
|
+
try {
|
|
162
|
+
packValidation = pack.validateEnvelope(parsed.value);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { ok: false, errors: [validationError("pack_validation_failed", `pack validation failed: ${error instanceof Error ? error.message : String(error)}`, "pack")] };
|
|
165
|
+
}
|
|
166
|
+
if (!packValidation.ok) return packValidation;
|
|
167
|
+
return parsed;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function rawEnvelope(candidate: KnowledgeEnvelope): Record<string, unknown> {
|
|
171
|
+
return {
|
|
172
|
+
id: candidate.id,
|
|
173
|
+
kind: candidate.kind,
|
|
174
|
+
status: candidate.status,
|
|
175
|
+
statement: candidate.statement,
|
|
176
|
+
details: candidate.details,
|
|
177
|
+
scope: candidate.scope,
|
|
178
|
+
pack: candidate.pack,
|
|
179
|
+
sources: candidate.sources,
|
|
180
|
+
session: candidate.session,
|
|
181
|
+
submitted_at: candidate.submittedAt,
|
|
182
|
+
disposition: candidate.disposition,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function submitKnowledgeCandidate(input: { binding: ActiveSpace; candidateInput: unknown; pack: KnowledgePack }): CandidateSubmissionOutcome {
|
|
187
|
+
const candidateResult = prepareCandidate(input.binding, input.candidateInput, input.pack);
|
|
188
|
+
if (!candidateResult.ok) return { schema_version: 0, status: "invalid", errors: candidateResult.errors };
|
|
189
|
+
return { schema_version: 0, status: "submitted", candidate: candidateResult.value };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
193
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function errorCode(error: unknown): string | undefined {
|
|
197
|
+
if (!isObject(error)) return undefined;
|
|
198
|
+
return typeof error.code === "string" ? error.code : undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function readCurrent(binding: ActiveSpace, path: string): Promise<KnowledgeResult<{ bytes: Buffer; text: string; record: KnowledgeRecord } | null>> {
|
|
202
|
+
if (!pathWithin(binding.recordsRoot, path)) {
|
|
203
|
+
return { ok: false, errors: [transactionError("path_escape", `authoritative record path is outside the active records root: ${path}`, path)] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let linkStatus;
|
|
207
|
+
try {
|
|
208
|
+
linkStatus = await lstat(path);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (errorCode(error) === "ENOENT") return { ok: true, value: null };
|
|
211
|
+
return { ok: false, errors: [transactionError("record_read_failed", `failed to inspect planned path ${path}: ${error instanceof Error ? error.message : String(error)}`, path)] };
|
|
212
|
+
}
|
|
213
|
+
if (!linkStatus.isFile() && !linkStatus.isSymbolicLink()) {
|
|
214
|
+
return { ok: false, errors: [transactionError("record_shape_invalid", `authoritative record path is not a regular file: ${path}`, path)] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let recordsRoot: string;
|
|
218
|
+
try {
|
|
219
|
+
recordsRoot = await realpath(binding.recordsRoot);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return { ok: false, errors: [transactionError("path_escape", `active records root could not be resolved: ${error instanceof Error ? error.message : String(error)}`, path)] };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let resolvedPath: string;
|
|
225
|
+
try {
|
|
226
|
+
resolvedPath = await realpath(path);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return { ok: false, errors: [transactionError("path_escape", `authoritative record path could not be resolved safely: ${path}: ${error instanceof Error ? error.message : String(error)}`, path)] };
|
|
229
|
+
}
|
|
230
|
+
if (!pathWithin(recordsRoot, resolvedPath)) {
|
|
231
|
+
return { ok: false, errors: [transactionError("path_escape", `authoritative record path escapes the active records root: ${path}`, path)] };
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
if (!(await stat(resolvedPath)).isFile()) {
|
|
235
|
+
return { ok: false, errors: [transactionError("record_shape_invalid", `authoritative record path is not a regular file: ${path}`, path)] };
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return { ok: false, errors: [transactionError("record_read_failed", `failed to inspect authoritative record ${path}: ${error instanceof Error ? error.message : String(error)}`, path)] };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let bytes: Buffer;
|
|
242
|
+
try {
|
|
243
|
+
bytes = await readFile(resolvedPath);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (errorCode(error) === "ENOENT") return { ok: false, errors: [transactionError("record_read_failed", `authoritative record disappeared while it was being read: ${path}`, path)] };
|
|
246
|
+
return { ok: false, errors: [transactionError("record_read_failed", `failed to read planned path ${path}: ${error instanceof Error ? error.message : String(error)}`, path)] };
|
|
247
|
+
}
|
|
248
|
+
const text = bytes.toString("utf8");
|
|
249
|
+
const parsed = parseKnowledgeRecord(text);
|
|
250
|
+
if (!parsed.ok) return { ok: false, errors: parsed.errors.map((item) => transactionError("record_invalid", `${path}: ${item.message}`, path)) };
|
|
251
|
+
return { ok: true, value: { bytes, text, record: parsed.value } };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function recordBase(record: KnowledgeRecord): Record<string, unknown> {
|
|
255
|
+
return {
|
|
256
|
+
id: record.id,
|
|
257
|
+
kind: record.kind,
|
|
258
|
+
status: record.status,
|
|
259
|
+
statement: record.statement,
|
|
260
|
+
details: record.details,
|
|
261
|
+
scope: record.scope,
|
|
262
|
+
pack: record.pack,
|
|
263
|
+
sources: record.sources,
|
|
264
|
+
session: record.session,
|
|
265
|
+
submittedAt: record.submittedAt,
|
|
266
|
+
disposition: record.disposition,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function arrayIsAppendOnly(before: string[], after: string[]): boolean {
|
|
271
|
+
if (after.length < before.length) return false;
|
|
272
|
+
for (let index = 0; index < before.length; index++) if (before[index] !== after[index]) return false;
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function mutationIsAdditive(before: KnowledgeRecord, after: KnowledgeRecord): boolean {
|
|
277
|
+
if (canonicalJson(recordBase(before)) !== canonicalJson(recordBase(after))) return false;
|
|
278
|
+
for (const key of ["supports", "contradicts", "refines", "supersedes"] as const) {
|
|
279
|
+
if (!arrayIsAppendOnly(before.relationships[key], after.relationships[key])) return false;
|
|
280
|
+
}
|
|
281
|
+
if (before.history.length > after.history.length) return false;
|
|
282
|
+
for (let index = 0; index < before.history.length; index++) {
|
|
283
|
+
if (canonicalJson(before.history[index]) !== canonicalJson(after.history[index])) return false;
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function preservesRecordTrace(before: KnowledgeRecord, after: KnowledgeRecord): boolean {
|
|
289
|
+
for (const key of ["supports", "contradicts", "refines", "supersedes"] as const) {
|
|
290
|
+
for (const relatedId of before.relationships[key]) {
|
|
291
|
+
if (!after.relationships[key].includes(relatedId)) return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const entry of before.history) {
|
|
295
|
+
if (!after.history.some((candidate) => canonicalJson(candidate) === canonicalJson(entry))) return false;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function preservesRecordProvenance(before: KnowledgeRecord, after: KnowledgeRecord): boolean {
|
|
301
|
+
return canonicalJson(before.sources) === canonicalJson(after.sources)
|
|
302
|
+
&& canonicalJson(before.session) === canonicalJson(after.session)
|
|
303
|
+
&& canonicalJson(before.scope) === canonicalJson(after.scope);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function stateTransitionAllowed(before: KnowledgeRecord, after: KnowledgeRecord): boolean {
|
|
307
|
+
if (before.status === "retired" && after.status !== "retired") return false;
|
|
308
|
+
if ((before.status === "active" || before.status === "contested") && after.status === "candidate") return false;
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function planHash(candidate: KnowledgeEnvelope, binding: ActiveSpace, plan: KnowledgeMutationPlan): string {
|
|
313
|
+
return hashKnowledgeText(canonicalJson({
|
|
314
|
+
candidate,
|
|
315
|
+
bindingFingerprint: bindingFingerprint(binding),
|
|
316
|
+
plan,
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function planPath(binding: ActiveSpace, recordId: string): KnowledgeResult<string> {
|
|
321
|
+
const resolved = resolveRecordPath(binding, recordId);
|
|
322
|
+
if (!resolved.ok) return { ok: false, errors: resolved.errors.map((message) => transactionError("protected_path", message, "path")) };
|
|
323
|
+
if (binding.writeRoots.length === 0 || !binding.writeRoots.some((root) => pathWithin(root, resolved.value))) {
|
|
324
|
+
return { ok: false, errors: [transactionError("protected_path", `planned path is outside every active write root: ${resolved.value}`, "path")] };
|
|
325
|
+
}
|
|
326
|
+
if (!pathWithin(binding.spaceRoot, resolved.value)) {
|
|
327
|
+
return { ok: false, errors: [transactionError("protected_path", `planned path is outside the active space root: ${resolved.value}`, "path")] };
|
|
328
|
+
}
|
|
329
|
+
return resolved;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function validatePackMutation(mutation: PackMutation, candidate: KnowledgeEnvelope, binding: ActiveSpace): KnowledgeResult<void> {
|
|
333
|
+
if (mutation.action !== "create" && mutation.action !== "update") {
|
|
334
|
+
return { ok: false, errors: [transactionError("mutation_action_invalid", `pack returned an unsupported mutation action for ${candidate.id}`, "plan.mutations")] };
|
|
335
|
+
}
|
|
336
|
+
if (mutation.record.scope.space !== binding.spaceId) {
|
|
337
|
+
return { ok: false, errors: [transactionError("scope_space_mismatch", `pack mutation ${mutation.record.id} is outside the active space`, "scope.space")] };
|
|
338
|
+
}
|
|
339
|
+
if (mutation.record.pack.id !== candidate.pack.id || mutation.record.pack.version !== candidate.pack.version) {
|
|
340
|
+
return { ok: false, errors: [transactionError("provenance_mismatch", `pack mutation ${mutation.record.id} changes pack provenance`, "pack")] };
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
const serialized = serializeKnowledgeRecord(mutation.record);
|
|
344
|
+
const parsed = parseKnowledgeRecord(serialized);
|
|
345
|
+
if (!parsed.ok) return { ok: false, errors: parsed.errors.map((item) => transactionError("pack_record_invalid", item.message, mutation.record.id)) };
|
|
346
|
+
return { ok: true, value: undefined };
|
|
347
|
+
} catch (error) {
|
|
348
|
+
return { ok: false, errors: [transactionError("pack_record_invalid", `pack mutation ${mutation.record.id} could not be serialized: ${error instanceof Error ? error.message : String(error)}`, mutation.record.id)] };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function validateReconciliation(reconciliation: PackReconciliation, candidate: KnowledgeEnvelope): KnowledgeResult<PackReconciliation> {
|
|
353
|
+
if (!isKnowledgeDisposition(reconciliation.disposition) || reconciliation.disposition !== candidate.disposition) {
|
|
354
|
+
return { ok: false, errors: [transactionError("disposition_mismatch", "pack reconciliation disposition must match the submitted envelope", "disposition")] };
|
|
355
|
+
}
|
|
356
|
+
if (typeof reconciliation.summary !== "string" || reconciliation.summary.trim().length === 0 || /[\r\n]/.test(reconciliation.summary)) {
|
|
357
|
+
return { ok: false, errors: [transactionError("summary_invalid", "pack reconciliation summary must be a non-empty single-line string", "plan.summary")] };
|
|
358
|
+
}
|
|
359
|
+
if (!Array.isArray(reconciliation.mutations)) {
|
|
360
|
+
return { ok: false, errors: [transactionError("mutations_invalid", "pack reconciliation mutations must be an array", "plan.mutations")] };
|
|
361
|
+
}
|
|
362
|
+
return { ok: true, value: reconciliation };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function buildPlan(
|
|
366
|
+
binding: ActiveSpace,
|
|
367
|
+
candidate: KnowledgeEnvelope,
|
|
368
|
+
reconciliation: PackReconciliation,
|
|
369
|
+
relatedIds: Set<string>,
|
|
370
|
+
authoritativeInputs: AuthoritativeInput[],
|
|
371
|
+
): Promise<KnowledgeResult<KnowledgeMutationPlan>> {
|
|
372
|
+
const errors: KnowledgeError[] = [];
|
|
373
|
+
const seen = new Set<string>();
|
|
374
|
+
const mutations: PlannedMutation[] = [];
|
|
375
|
+
for (const mutation of reconciliation.mutations) {
|
|
376
|
+
if (seen.has(mutation.record.id)) {
|
|
377
|
+
errors.push(transactionError("duplicate_mutation", `pack returned more than one mutation for ${mutation.record.id}`, "plan.mutations"));
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
seen.add(mutation.record.id);
|
|
381
|
+
const packMutation = validatePackMutation(mutation, candidate, binding);
|
|
382
|
+
if (!packMutation.ok) {
|
|
383
|
+
errors.push(...packMutation.errors);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const path = planPath(binding, mutation.record.id);
|
|
387
|
+
if (!path.ok) {
|
|
388
|
+
errors.push(...path.errors);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const current = await readCurrent(binding, path.value);
|
|
392
|
+
if (!current.ok) {
|
|
393
|
+
errors.push(...current.errors);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (mutation.action === "create") {
|
|
397
|
+
if (current.value !== null) {
|
|
398
|
+
errors.push(transactionError("create_target_exists", `pack requested creation of existing record ${mutation.record.id}`, mutation.record.id));
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
mutations.push({ recordId: mutation.record.id, action: "create", path: path.value, beforeText: null, beforeHash: null, afterText: serializeKnowledgeRecord(mutation.record), after: mutation.record });
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (!relatedIds.has(mutation.record.id)) {
|
|
405
|
+
errors.push(transactionError("update_target_not_retrieved", `pack update target ${mutation.record.id} was not returned by active-space retrieval`, mutation.record.id));
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (current.value === null) {
|
|
409
|
+
errors.push(transactionError("update_target_missing", `pack requested update of missing record ${mutation.record.id}`, mutation.record.id));
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (!preservesRecordTrace(current.value.record, mutation.record) || !preservesRecordProvenance(current.value.record, mutation.record)) {
|
|
413
|
+
errors.push(transactionError("record_trace_loss", `pack update ${mutation.record.id} would delete or change existing relationships, history, sources, session, or scope`, mutation.record.id));
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (!stateTransitionAllowed(current.value.record, mutation.record)) {
|
|
417
|
+
errors.push(transactionError("state_transition_invalid", `pack update ${mutation.record.id} requests an invalid lifecycle transition`, mutation.record.id));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
mutations.push({
|
|
421
|
+
recordId: mutation.record.id,
|
|
422
|
+
action: "update",
|
|
423
|
+
path: path.value,
|
|
424
|
+
beforeText: current.value.text,
|
|
425
|
+
beforeHash: hashBytes(current.value.bytes),
|
|
426
|
+
afterText: serializeKnowledgeRecord(mutation.record),
|
|
427
|
+
after: mutation.record,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
431
|
+
const classification = mutations.length === 0
|
|
432
|
+
? "no-change"
|
|
433
|
+
: "additive";
|
|
434
|
+
let actualClassification: "additive" | "non-additive" | "no-change" = classification;
|
|
435
|
+
for (const mutation of mutations) {
|
|
436
|
+
if (mutation.action !== "create" && mutation.beforeText !== null) {
|
|
437
|
+
const before = parseKnowledgeRecord(mutation.beforeText);
|
|
438
|
+
if (!before.ok || !mutationIsAdditive(before.value, mutation.after)) actualClassification = "non-additive";
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const plan: KnowledgeMutationPlan = {
|
|
442
|
+
classification: actualClassification,
|
|
443
|
+
disposition: reconciliation.disposition,
|
|
444
|
+
summary: reconciliation.summary,
|
|
445
|
+
mutations,
|
|
446
|
+
authoritativeInputs,
|
|
447
|
+
protectedPaths: mutations.map((mutation) => mutation.path),
|
|
448
|
+
bindingFingerprint: bindingFingerprint(binding),
|
|
449
|
+
candidateFingerprint: hashKnowledgeText(canonicalJson(candidate)),
|
|
450
|
+
};
|
|
451
|
+
return { ok: true, value: plan };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function reconcileKnowledgeTransaction(input: {
|
|
455
|
+
binding: ActiveSpace;
|
|
456
|
+
candidateInput: unknown;
|
|
457
|
+
pack: KnowledgePack;
|
|
458
|
+
spawnFn?: SpawnFn;
|
|
459
|
+
beforePlanBuild?: () => Promise<void>;
|
|
460
|
+
// Test-only seam mirroring beforePlanBuild: fires after retrieval has read each
|
|
461
|
+
// related record but before the authoritative comparison re-reads and validates
|
|
462
|
+
// them against current disk state. Lets a test deterministically simulate a related
|
|
463
|
+
// record changing underneath a transaction in that specific window, instead of
|
|
464
|
+
// racing real filesystem I/O against JS's synchronous continuation.
|
|
465
|
+
afterRetrieval?: () => Promise<void>;
|
|
466
|
+
}): Promise<ReconcileOutcome> {
|
|
467
|
+
const candidateResult = prepareCandidate(input.binding, input.candidateInput, input.pack);
|
|
468
|
+
if (!candidateResult.ok) return invalidOutcome(candidateResult.errors);
|
|
469
|
+
const query = input.pack.relatedQuery(candidateResult.value);
|
|
470
|
+
if (typeof query !== "string" || query.trim().length === 0 || /[\r\n]/.test(query)) {
|
|
471
|
+
return invalidOutcome([validationError("query_invalid", "pack retrieval query must be a non-empty single-line string")]);
|
|
472
|
+
}
|
|
473
|
+
const retrieval = await retrieveRelatedRecords(input.binding, query, input.spawnFn);
|
|
474
|
+
if (retrieval.kind === "failure") return { schema_version: 0, status: "retrieval_failed", errors: retrieval.errors, retrieval: retrieval.receipt };
|
|
475
|
+
const relatedRecords = retrieval.kind === "hit" ? retrieval.records : [];
|
|
476
|
+
if (input.afterRetrieval !== undefined) await input.afterRetrieval();
|
|
477
|
+
const authoritativeInputs: AuthoritativeInput[] = [];
|
|
478
|
+
const authoritativeErrors: KnowledgeError[] = [];
|
|
479
|
+
for (const related of relatedRecords) {
|
|
480
|
+
const path = planPath(input.binding, related.record.id);
|
|
481
|
+
if (!path.ok) {
|
|
482
|
+
authoritativeErrors.push(...path.errors);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const current = await readCurrent(input.binding, path.value);
|
|
486
|
+
if (!current.ok) {
|
|
487
|
+
authoritativeErrors.push(...current.errors);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (current.value === null) {
|
|
491
|
+
authoritativeErrors.push(transactionError("related_record_missing", `related record ${related.record.id} vanished before planning`, related.record.id));
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (canonicalJson(current.value.record) !== canonicalJson(related.record)) {
|
|
495
|
+
authoritativeErrors.push(transactionError("related_record_changed", `related record ${related.record.id} changed while the transaction was being planned`, related.record.id));
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
authoritativeInputs.push({ recordId: related.record.id, path: path.value, beforeText: current.value.text, beforeHash: hashBytes(current.value.bytes) });
|
|
499
|
+
}
|
|
500
|
+
if (authoritativeErrors.length > 0) return invalidOutcome(authoritativeErrors);
|
|
501
|
+
if (input.beforePlanBuild !== undefined) await input.beforePlanBuild();
|
|
502
|
+
const related = relatedRecords.map((item) => item.record);
|
|
503
|
+
let reconciliation: KnowledgeResult<PackReconciliation>;
|
|
504
|
+
try {
|
|
505
|
+
reconciliation = input.pack.reconcile({ candidate: candidateResult.value, related });
|
|
506
|
+
} catch (error) {
|
|
507
|
+
return invalidOutcome([transactionError("pack_reconcile_failed", `pack reconciliation failed: ${error instanceof Error ? error.message : String(error)}`)]);
|
|
508
|
+
}
|
|
509
|
+
if (!reconciliation.ok) return invalidOutcome(reconciliation.errors);
|
|
510
|
+
const checkedReconciliation = validateReconciliation(reconciliation.value, candidateResult.value);
|
|
511
|
+
if (!checkedReconciliation.ok) return invalidOutcome(checkedReconciliation.errors);
|
|
512
|
+
const planResult = await buildPlan(
|
|
513
|
+
input.binding,
|
|
514
|
+
candidateResult.value,
|
|
515
|
+
checkedReconciliation.value,
|
|
516
|
+
new Set(related.map((record) => record.id)),
|
|
517
|
+
authoritativeInputs,
|
|
518
|
+
);
|
|
519
|
+
if (!planResult.ok) return invalidOutcome(planResult.errors);
|
|
520
|
+
const proposal: KnowledgeProposal = {
|
|
521
|
+
schema_version: 0,
|
|
522
|
+
candidate: candidateResult.value,
|
|
523
|
+
retrieval: retrieval.receipt,
|
|
524
|
+
plan: planResult.value,
|
|
525
|
+
plan_hash: planHash(candidateResult.value, input.binding, planResult.value),
|
|
526
|
+
};
|
|
527
|
+
return { schema_version: 0, status: "proposal", proposal };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function currentMutationState(binding: ActiveSpace, mutation: PlannedMutation): Promise<KnowledgeResult<{ current: { bytes: Buffer; text: string; record: KnowledgeRecord } | null; stable: boolean }>> {
|
|
531
|
+
const path = planPath(binding, mutation.recordId);
|
|
532
|
+
if (!path.ok) return path;
|
|
533
|
+
if (path.value !== mutation.path) return { ok: false, errors: [transactionError("protected_path", `plan path is not derived from the active binding: ${mutation.path}`, "path")] };
|
|
534
|
+
const current = await readCurrent(binding, path.value);
|
|
535
|
+
if (!current.ok) return current;
|
|
536
|
+
const stable = mutation.action === "create"
|
|
537
|
+
? current.value === null
|
|
538
|
+
: current.value !== null && mutation.beforeText === current.value.text && mutation.beforeHash === hashBytes(current.value.bytes);
|
|
539
|
+
return { ok: true, value: { current: current.value, stable } };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function currentAuthoritativeState(binding: ActiveSpace, input: AuthoritativeInput): Promise<KnowledgeResult<{ current: { bytes: Buffer; text: string; record: KnowledgeRecord } | null; stable: boolean }>> {
|
|
543
|
+
const path = planPath(binding, input.recordId);
|
|
544
|
+
if (!path.ok) return path;
|
|
545
|
+
if (path.value !== input.path) return { ok: false, errors: [transactionError("protected_path", `authoritative input path is not derived from the active binding: ${input.path}`, "path")] };
|
|
546
|
+
const current = await readCurrent(binding, path.value);
|
|
547
|
+
if (!current.ok) return current;
|
|
548
|
+
const stable = current.value !== null && input.beforeText === current.value.text && input.beforeHash === hashBytes(current.value.bytes);
|
|
549
|
+
return { ok: true, value: { current: current.value, stable } };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function revalidateProposal(input: ApplyKnowledgeInput, candidate: KnowledgeEnvelope): Promise<KnowledgeResult<{ actualPlanHash: string; stable: boolean }>> {
|
|
553
|
+
const currentMutations: PlannedMutation[] = [];
|
|
554
|
+
const currentInputs: AuthoritativeInput[] = [];
|
|
555
|
+
let stable = input.proposal.plan.bindingFingerprint === bindingFingerprint(input.binding);
|
|
556
|
+
if (input.proposal.plan.candidateFingerprint !== hashKnowledgeText(canonicalJson(candidate))) stable = false;
|
|
557
|
+
for (const mutation of input.proposal.plan.mutations) {
|
|
558
|
+
const currentResult = await currentMutationState(input.binding, mutation);
|
|
559
|
+
if (!currentResult.ok) {
|
|
560
|
+
if (input.proposal.plan.bindingFingerprint !== bindingFingerprint(input.binding) && currentResult.errors.some((error) => error.code === "protected_path")) {
|
|
561
|
+
stable = false;
|
|
562
|
+
currentMutations.push({ ...mutation, beforeText: null, beforeHash: null });
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
return currentResult;
|
|
566
|
+
}
|
|
567
|
+
const current = currentResult.value.current;
|
|
568
|
+
if (!currentResult.value.stable) stable = false;
|
|
569
|
+
currentMutations.push({
|
|
570
|
+
...mutation,
|
|
571
|
+
beforeText: current?.text ?? null,
|
|
572
|
+
beforeHash: current === null ? null : hashBytes(current.bytes),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
for (const authoritativeInput of input.proposal.plan.authoritativeInputs) {
|
|
576
|
+
const currentResult = await currentAuthoritativeState(input.binding, authoritativeInput);
|
|
577
|
+
if (!currentResult.ok) {
|
|
578
|
+
if (input.proposal.plan.bindingFingerprint !== bindingFingerprint(input.binding) && currentResult.errors.some((error) => error.code === "protected_path")) {
|
|
579
|
+
stable = false;
|
|
580
|
+
currentInputs.push({ ...authoritativeInput, beforeText: "", beforeHash: "" });
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
return currentResult;
|
|
584
|
+
}
|
|
585
|
+
if (!currentResult.value.stable) stable = false;
|
|
586
|
+
if (currentResult.value.current === null) currentInputs.push({ ...authoritativeInput, beforeText: "", beforeHash: "" });
|
|
587
|
+
else currentInputs.push({ ...authoritativeInput, beforeText: currentResult.value.current.text, beforeHash: hashBytes(currentResult.value.current.bytes) });
|
|
588
|
+
}
|
|
589
|
+
const currentPlan: KnowledgeMutationPlan = { ...input.proposal.plan, mutations: currentMutations, authoritativeInputs: currentInputs, protectedPaths: currentMutations.map((mutation) => mutation.path) };
|
|
590
|
+
const actualPlanHash = planHash(candidate, input.binding, currentPlan);
|
|
591
|
+
if (actualPlanHash !== input.proposal.plan_hash) stable = false;
|
|
592
|
+
return { ok: true, value: { actualPlanHash, stable } };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function lockOutcome(lock: KnowledgeResult<TransactionLock>): ApplyKnowledgeOutcome {
|
|
596
|
+
const status = lock.ok ? "lock_conflict" : lock.errors[0]?.code === "lock_owner_unverifiable" ? "lock_owner_unverifiable" : "lock_conflict";
|
|
597
|
+
return { schema_version: 0, status, errors: lock.ok ? [] : lock.errors, refresh: REFRESH_NOT_ATTEMPTED };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export async function applyKnowledgeProposal(input: ApplyKnowledgeInput): Promise<ApplyKnowledgeOutcome> {
|
|
601
|
+
const candidateResult = prepareCandidate(input.binding, input.candidateInput ?? rawEnvelope(input.proposal.candidate), input.pack);
|
|
602
|
+
let candidate: KnowledgeEnvelope;
|
|
603
|
+
if (!candidateResult.ok) {
|
|
604
|
+
// A previously valid proposal presented against another active space must
|
|
605
|
+
// be reported as stale, not reclassified as a new invalid submission.
|
|
606
|
+
// The proposal's original envelope remains an authoritative input for the
|
|
607
|
+
// binding-mismatch check below; it can never be written under the new root.
|
|
608
|
+
if (input.candidateInput === undefined && input.proposal.plan.bindingFingerprint !== bindingFingerprint(input.binding)) {
|
|
609
|
+
candidate = input.proposal.candidate;
|
|
610
|
+
} else {
|
|
611
|
+
return { schema_version: 0, status: "invalid", errors: candidateResult.errors, refresh: REFRESH_NOT_ATTEMPTED };
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
candidate = candidateResult.value;
|
|
615
|
+
}
|
|
616
|
+
const plan = input.proposal.plan;
|
|
617
|
+
if (plan.classification === "non-additive" && input.expectedPlanHash === undefined) {
|
|
618
|
+
return { schema_version: 0, status: "approval_required", plan_hash: input.proposal.plan_hash, mutations: plan.mutations, refresh: REFRESH_NOT_ATTEMPTED };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const lock = await acquireTransactionLock(input.binding, input.transactionLockHooks);
|
|
622
|
+
if (!lock.ok) return lockOutcome(lock);
|
|
623
|
+
const held = lock.value;
|
|
624
|
+
try {
|
|
625
|
+
const revalidated = await revalidateProposal(input, candidate);
|
|
626
|
+
if (!revalidated.ok) return { schema_version: 0, status: "invalid", errors: revalidated.errors, refresh: REFRESH_NOT_ATTEMPTED };
|
|
627
|
+
if (!revalidated.value.stable || input.expectedPlanHash !== undefined && input.expectedPlanHash !== revalidated.value.actualPlanHash) {
|
|
628
|
+
return {
|
|
629
|
+
schema_version: 0,
|
|
630
|
+
status: "stale_approval",
|
|
631
|
+
expected_plan_hash: input.expectedPlanHash ?? "",
|
|
632
|
+
actual_plan_hash: revalidated.value.actualPlanHash,
|
|
633
|
+
reason: "the candidate, binding, authoritative source record, related record, submission date, or complete mutation plan changed after preview",
|
|
634
|
+
refresh: REFRESH_NOT_ATTEMPTED,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (plan.classification === "no-change") {
|
|
638
|
+
return { schema_version: 0, status: "no_change", plan_hash: revalidated.value.actualPlanHash, mutations: [], refresh: REFRESH_NOT_ATTEMPTED, lock: { state: held.state } };
|
|
639
|
+
}
|
|
640
|
+
if (input.decision === "reject") {
|
|
641
|
+
return { schema_version: 0, status: "rejected", plan_hash: revalidated.value.actualPlanHash, mutations: plan.mutations, refresh: REFRESH_NOT_ATTEMPTED, lock: { state: held.state } };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const writeRecord = input.writeRecord ?? atomicWriteFile;
|
|
645
|
+
const writtenPaths: string[] = [];
|
|
646
|
+
try {
|
|
647
|
+
for (const mutation of plan.mutations) {
|
|
648
|
+
await writeRecord(mutation.path, mutation.afterText);
|
|
649
|
+
writtenPaths.push(mutation.path);
|
|
650
|
+
}
|
|
651
|
+
} catch (error) {
|
|
652
|
+
const isAmbiguous = error instanceof AtomicWriteDirectorySyncError;
|
|
653
|
+
const rollbackErrors: string[] = [];
|
|
654
|
+
if (!isAmbiguous) {
|
|
655
|
+
for (const mutation of plan.mutations) {
|
|
656
|
+
try {
|
|
657
|
+
if (mutation.beforeText === null) await rm(mutation.path, { force: true });
|
|
658
|
+
else await atomicWriteFile(mutation.path, mutation.beforeText);
|
|
659
|
+
} catch (rollbackError) {
|
|
660
|
+
rollbackErrors.push(`${mutation.path}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const detail = isAmbiguous
|
|
665
|
+
? `post-rename ambiguity at ${writtenPaths.join(", ") || plan.mutations[0]?.path || transactionLockDirectory(input.binding)}; exact recovery is required before retrying`
|
|
666
|
+
: rollbackErrors.length === 0
|
|
667
|
+
? `durable Markdown transaction failed before completion and all planned paths were restored: ${error instanceof Error ? error.message : String(error)}`
|
|
668
|
+
: `durable Markdown transaction failed and rollback also failed: ${error instanceof Error ? error.message : String(error)}; exact recovery is required for ${rollbackErrors.join("; ")}`;
|
|
669
|
+
return {
|
|
670
|
+
schema_version: 0,
|
|
671
|
+
status: "recovery_required",
|
|
672
|
+
plan_hash: revalidated.value.actualPlanHash,
|
|
673
|
+
mutations: plan.mutations,
|
|
674
|
+
recovery: { required: true, paths: plan.mutations.map((mutation) => mutation.path), detail },
|
|
675
|
+
refresh: REFRESH_NOT_ATTEMPTED,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
const refresh = await refreshQmdCollection(input.binding, input.spawnFn);
|
|
679
|
+
return { schema_version: 0, status: "committed", plan_hash: revalidated.value.actualPlanHash, mutations: plan.mutations, refresh, lock: { state: held.state } };
|
|
680
|
+
} finally {
|
|
681
|
+
await held.release();
|
|
682
|
+
}
|
|
683
|
+
}
|