@brianmsm/quarto-bento 0.0.0-stage → 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 +21 -0
- package/README.md +86 -2
- package/bin/qbento-npm.mjs +62 -0
- package/distribution/LICENSE +21 -0
- package/distribution/PACKAGING.md +59 -0
- package/distribution/bin/qbento +5 -0
- package/distribution/bin/qbento.cmd +4 -0
- package/distribution/manifest.json +720 -0
- package/distribution/runtime/LICENSE +21 -0
- package/distribution/runtime/_extensions/qbento/_extension.yml +24 -0
- package/distribution/runtime/_extensions/qbento/capture.lua +21 -0
- package/distribution/runtime/_extensions/qbento/guard.lua +7 -0
- package/distribution/runtime/_extensions/qbento/post.ts +2 -0
- package/distribution/runtime/_extensions/qbento/pre.ts +2 -0
- package/distribution/runtime/_extensions/qbento/producer-activation.js +129 -0
- package/distribution/runtime/src/bridge/browser.js +168 -0
- package/distribution/runtime/src/bridge/isolation.ts +55 -0
- package/distribution/runtime/src/browser/chromium.ts +218 -0
- package/distribution/runtime/src/cli.ts +219 -0
- package/distribution/runtime/src/core/agent.ts +565 -0
- package/distribution/runtime/src/core/annotations.ts +384 -0
- package/distribution/runtime/src/core/capture.ts +203 -0
- package/distribution/runtime/src/core/component-actions.ts +550 -0
- package/distribution/runtime/src/core/component-frames.ts +59 -0
- package/distribution/runtime/src/core/component-sync.ts +290 -0
- package/distribution/runtime/src/core/design-diagnostics.ts +78 -0
- package/distribution/runtime/src/core/design-registry.ts +287 -0
- package/distribution/runtime/src/core/graph-relations.ts +354 -0
- package/distribution/runtime/src/core/graph-scope.ts +63 -0
- package/distribution/runtime/src/core/graph-sync.ts +228 -0
- package/distribution/runtime/src/core/io.ts +27 -0
- package/distribution/runtime/src/core/kpi-appearance.ts +205 -0
- package/distribution/runtime/src/core/layouts.ts +245 -0
- package/distribution/runtime/src/core/project.ts +759 -0
- package/distribution/runtime/src/core/reconcile.ts +384 -0
- package/distribution/runtime/src/core/recovery.ts +1255 -0
- package/distribution/runtime/src/core/slide-actions.ts +158 -0
- package/distribution/runtime/src/core/source-scope.ts +50 -0
- package/distribution/runtime/src/core/sync.ts +2423 -0
- package/distribution/runtime/src/core/widgets.ts +300 -0
- package/distribution/runtime/src/integration/install.ts +98 -0
- package/distribution/runtime/src/integration/invocation.ts +100 -0
- package/distribution/runtime/src/integration/quarto.ts +457 -0
- package/distribution/runtime/src/render/assets.ts +66 -0
- package/distribution/runtime/src/render/capture.lua +8 -0
- package/distribution/runtime/src/render/chart.ts +165 -0
- package/distribution/runtime/src/render/components.ts +199 -0
- package/distribution/runtime/src/render/design.ts +820 -0
- package/distribution/runtime/src/render/diagnostics.ts +36 -0
- package/distribution/runtime/src/render/graphs.ts +747 -0
- package/distribution/runtime/src/render/html.ts +82 -0
- package/distribution/runtime/src/render/images.ts +268 -0
- package/distribution/runtime/src/render/inline.ts +259 -0
- package/distribution/runtime/src/render/kpi.ts +385 -0
- package/distribution/runtime/src/render/lists.ts +183 -0
- package/distribution/runtime/src/render/math.ts +413 -0
- package/distribution/runtime/src/render/measure.ts +185 -0
- package/distribution/runtime/src/render/native-layout.ts +174 -0
- package/distribution/runtime/src/render/producer-capture.ts +319 -0
- package/distribution/runtime/src/render/producer-environment.R +49 -0
- package/distribution/runtime/src/render/producer-environment.ts +96 -0
- package/distribution/runtime/src/render/producer-profile.R +91 -0
- package/distribution/runtime/src/render/producer-site-profile.R +18 -0
- package/distribution/runtime/src/render/project.ts +1806 -0
- package/distribution/runtime/src/render/quarto.ts +127 -0
- package/distribution/runtime/src/render/registry.ts +233 -0
- package/distribution/runtime/src/render/resources.ts +292 -0
- package/distribution/runtime/src/render/shell.ts +110 -0
- package/distribution/runtime/src/render/source-inline.ts +169 -0
- package/distribution/runtime/src/render/table.ts +220 -0
- package/distribution/runtime/src/render/text-overflow.ts +208 -0
- package/distribution/runtime/src/render/types.ts +71 -0
- package/distribution/runtime/src/render/validate.ts +572 -0
- package/distribution/runtime/src/rich/project.ts +117 -0
- package/distribution/runtime/src/rich/table.ts +374 -0
- package/distribution/runtime/src/source/annotations.ts +490 -0
- package/distribution/runtime/src/source/attributes.ts +415 -0
- package/distribution/runtime/src/source/chart.ts +165 -0
- package/distribution/runtime/src/source/code-language.ts +277 -0
- package/distribution/runtime/src/source/component-insertion.ts +160 -0
- package/distribution/runtime/src/source/deletion.ts +278 -0
- package/distribution/runtime/src/source/graphs.ts +517 -0
- package/distribution/runtime/src/source/identities.ts +256 -0
- package/distribution/runtime/src/source/identity-id.ts +6 -0
- package/distribution/runtime/src/source/image.ts +50 -0
- package/distribution/runtime/src/source/kpi.ts +196 -0
- package/distribution/runtime/src/source/mod.ts +876 -0
- package/distribution/runtime/src/source/notes.ts +308 -0
- package/distribution/runtime/src/source/structure.ts +647 -0
- package/distribution/runtime/src/source/table.ts +290 -0
- package/distribution/runtime/src/source/text-conversion.ts +146 -0
- package/distribution/runtime/src/state/components.ts +281 -0
- package/distribution/runtime/src/state/graphs.ts +200 -0
- package/distribution/runtime/src/state/json.ts +162 -0
- package/distribution/runtime/src/state/merge.ts +1034 -0
- package/distribution/runtime/src/state/mod.ts +5 -0
- package/distribution/runtime/src/state/store.ts +226 -0
- package/distribution/runtime/src/state/types.ts +151 -0
- package/distribution/runtime/src/studio/client.js +790 -0
- package/distribution/runtime/src/studio/components.js +188 -0
- package/distribution/runtime/src/studio/index.html +278 -0
- package/distribution/runtime/src/studio/layouts.js +170 -0
- package/distribution/runtime/src/studio/registry.js +283 -0
- package/distribution/runtime/src/studio/relations.js +169 -0
- package/distribution/runtime/src/studio/server.ts +433 -0
- package/distribution/runtime/src/widgets/deck.ts +39 -0
- package/distribution/runtime/src/widgets/plotly.ts +354 -0
- package/distribution/runtime/src/widgets/presenter.ts +367 -0
- package/distribution/runtime/src/widgets/snapshot.ts +116 -0
- package/distribution/runtime/vendor/bento/FONT_NOTICES.md +49 -0
- package/distribution/runtime/vendor/bento/LICENSE +21 -0
- package/distribution/runtime/vendor/bento/THIRD_PARTY_NOTICES.md +51 -0
- package/distribution/runtime/vendor/bento/font-licenses/fraunces-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/font-licenses/instrument-sans-OFL.txt +93 -0
- package/distribution/runtime/vendor/bento/pin.json +69 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package-lock.json +1928 -0
- package/distribution/runtime/vendor/bento/provenance/slides.package.json +28 -0
- package/distribution/runtime/vendor/bento/runtime-dependencies.json +244 -0
- package/distribution/runtime/vendor/bento/runtime-licenses.json +4038 -0
- package/distribution/runtime/vendor/bento/shell.html +269 -0
- package/distribution/runtime/vendor/bento/slides.schema.json +2379 -0
- package/package.json +40 -4
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
import { assertWidgetFiles } from "./widgets.ts";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { projectPaths } from "./project.ts";
|
|
5
|
+
import { validateSyncApproval } from "./sync.ts";
|
|
6
|
+
import { hashSource } from "../source/mod.ts";
|
|
7
|
+
import {
|
|
8
|
+
type AnnotationSummary,
|
|
9
|
+
validateAnnotationSummary,
|
|
10
|
+
} from "./annotations.ts";
|
|
11
|
+
import { extractDocument } from "../render/html.ts";
|
|
12
|
+
import {
|
|
13
|
+
canonical,
|
|
14
|
+
clone,
|
|
15
|
+
equal,
|
|
16
|
+
type ProjectState,
|
|
17
|
+
sha256,
|
|
18
|
+
StateError,
|
|
19
|
+
StateStore,
|
|
20
|
+
validateState,
|
|
21
|
+
} from "../state/mod.ts";
|
|
22
|
+
|
|
23
|
+
export type RecoveryAction = "inspect" | "finish" | "rollback";
|
|
24
|
+
interface RenderJournal {
|
|
25
|
+
version: 1;
|
|
26
|
+
staging: string;
|
|
27
|
+
backup: string;
|
|
28
|
+
output: string;
|
|
29
|
+
oldRevision: number;
|
|
30
|
+
nextRevision: number;
|
|
31
|
+
sha256: string;
|
|
32
|
+
previousOutput: { path: string; sha256: string } | null;
|
|
33
|
+
approval?: { path: string; sha256: string; archive: string };
|
|
34
|
+
}
|
|
35
|
+
interface SyncJournal {
|
|
36
|
+
version: 1;
|
|
37
|
+
transactionId: string;
|
|
38
|
+
source: string;
|
|
39
|
+
sourceBackup: string;
|
|
40
|
+
stateBackup: string;
|
|
41
|
+
staging: string;
|
|
42
|
+
receiptPath: string;
|
|
43
|
+
sourceHash: string;
|
|
44
|
+
resultHash: string;
|
|
45
|
+
stateRevision: number;
|
|
46
|
+
planHash: string;
|
|
47
|
+
selectedChangeIds: string[];
|
|
48
|
+
approvalStaging?: string;
|
|
49
|
+
approvalPath?: string;
|
|
50
|
+
annotations?: AnnotationSummary;
|
|
51
|
+
}
|
|
52
|
+
export interface RecoveryFile {
|
|
53
|
+
path: string;
|
|
54
|
+
exists: boolean;
|
|
55
|
+
sha256?: string;
|
|
56
|
+
bytes?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface RecoveryLock {
|
|
59
|
+
path: string;
|
|
60
|
+
status: "absent" | "dead" | "alive" | "ambiguous";
|
|
61
|
+
owner?: unknown;
|
|
62
|
+
ownerHash?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface RecoveryReport {
|
|
65
|
+
action: RecoveryAction;
|
|
66
|
+
kind: "none" | "render" | "sync";
|
|
67
|
+
status: string;
|
|
68
|
+
journal?: RenderJournal | SyncJournal;
|
|
69
|
+
stateRevision?: number;
|
|
70
|
+
statePhase?: "before" | "after";
|
|
71
|
+
files: Record<string, RecoveryFile>;
|
|
72
|
+
locks: RecoveryLock[];
|
|
73
|
+
available: RecoveryAction[];
|
|
74
|
+
preserved: string[];
|
|
75
|
+
diagnostics: string[];
|
|
76
|
+
}
|
|
77
|
+
const digest = /^[a-f0-9]{64}$/;
|
|
78
|
+
const uuid = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
|
|
79
|
+
function fail(code: string, message: string, details?: unknown): never {
|
|
80
|
+
throw new StateError(code, message, details);
|
|
81
|
+
}
|
|
82
|
+
/** Refuse symlinks in every existing parent, not just the leaf named by a journal. */
|
|
83
|
+
async function safePath(
|
|
84
|
+
path: string,
|
|
85
|
+
kind: "file" | "directory",
|
|
86
|
+
missing = true,
|
|
87
|
+
): Promise<boolean> {
|
|
88
|
+
const absolute = resolve(path);
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
let cursor = absolute;
|
|
91
|
+
while (true) {
|
|
92
|
+
parts.unshift(cursor);
|
|
93
|
+
const parent = dirname(cursor);
|
|
94
|
+
if (parent === cursor) break;
|
|
95
|
+
cursor = parent;
|
|
96
|
+
}
|
|
97
|
+
for (let i = 0; i < parts.length; i++) {
|
|
98
|
+
let stat: Deno.FileInfo;
|
|
99
|
+
try {
|
|
100
|
+
stat = await Deno.lstat(parts[i]);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e instanceof Deno.errors.NotFound && missing) return false;
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
const wanted = i === parts.length - 1 ? kind : "directory";
|
|
106
|
+
if (
|
|
107
|
+
stat.isSymlink || (wanted === "file" ? !stat.isFile : !stat.isDirectory)
|
|
108
|
+
) {
|
|
109
|
+
fail(
|
|
110
|
+
"UNSAFE_RECOVERY_PATH",
|
|
111
|
+
`Expected a regular ${wanted} without symlink parents: ${parts[i]}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
async function evidence(path: string): Promise<RecoveryFile> {
|
|
118
|
+
if (!await safePath(path, "file")) return { path, exists: false };
|
|
119
|
+
const bytes = await Deno.readFile(path);
|
|
120
|
+
return {
|
|
121
|
+
path,
|
|
122
|
+
exists: true,
|
|
123
|
+
sha256: await sha256(bytes),
|
|
124
|
+
bytes: bytes.length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function lockEvidence(path: string): Promise<RecoveryLock> {
|
|
128
|
+
if (!await safePath(path, "directory")) return { path, status: "absent" };
|
|
129
|
+
const ownerPath = join(path, "owner.json");
|
|
130
|
+
if (!await safePath(ownerPath, "file")) return { path, status: "ambiguous" };
|
|
131
|
+
const text = await Deno.readTextFile(ownerPath),
|
|
132
|
+
ownerHash = await sha256(text);
|
|
133
|
+
let owner: { pid?: unknown; started?: unknown };
|
|
134
|
+
try {
|
|
135
|
+
owner = JSON.parse(text);
|
|
136
|
+
} catch {
|
|
137
|
+
return { path, status: "ambiguous", ownerHash };
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
!owner || !Number.isSafeInteger(owner.pid) || Number(owner.pid) <= 0 ||
|
|
141
|
+
typeof owner.started !== "string" ||
|
|
142
|
+
!Number.isFinite(Date.parse(owner.started))
|
|
143
|
+
) return { path, status: "ambiguous", owner, ownerHash };
|
|
144
|
+
try {
|
|
145
|
+
process.kill(Number(owner.pid), 0);
|
|
146
|
+
return { path, status: "alive", owner, ownerHash };
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return {
|
|
149
|
+
path,
|
|
150
|
+
status: (e as { code?: string }).code === "ESRCH" ? "dead" : "ambiguous",
|
|
151
|
+
owner,
|
|
152
|
+
ownerHash,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function validateJournal(
|
|
157
|
+
value: unknown,
|
|
158
|
+
paths: ReturnType<typeof projectPaths>,
|
|
159
|
+
): RenderJournal {
|
|
160
|
+
const j = value as RenderJournal;
|
|
161
|
+
if (
|
|
162
|
+
!j || j.version !== 1 || !Number.isSafeInteger(j.oldRevision) ||
|
|
163
|
+
j.oldRevision < 0 || j.nextRevision !== j.oldRevision + 1 ||
|
|
164
|
+
typeof j.sha256 !== "string" || !digest.test(j.sha256)
|
|
165
|
+
) {
|
|
166
|
+
fail(
|
|
167
|
+
"INVALID_RECOVERY_JOURNAL",
|
|
168
|
+
"Unsupported or malformed render transaction.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (
|
|
172
|
+
j.output !== paths.output || typeof j.staging !== "string" ||
|
|
173
|
+
dirname(j.staging) !== paths.directory ||
|
|
174
|
+
!new RegExp("^" + uuid + "$").test(
|
|
175
|
+
j.staging.slice((paths.output + ".pending-").length),
|
|
176
|
+
) || !j.staging.startsWith(paths.output + ".pending-")
|
|
177
|
+
) {
|
|
178
|
+
fail(
|
|
179
|
+
"INVALID_RECOVERY_PATH",
|
|
180
|
+
"Journal output/staging paths do not belong to this project.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
typeof j.backup !== "string" ||
|
|
185
|
+
dirname(j.backup) !== join(paths.stateDir, "history") ||
|
|
186
|
+
!new RegExp("^output-" + uuid + "\\.html$").test(basename(j.backup))
|
|
187
|
+
) {
|
|
188
|
+
fail(
|
|
189
|
+
"INVALID_RECOVERY_PATH",
|
|
190
|
+
"Journal backup path is not a project output history entry.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (
|
|
194
|
+
j.previousOutput !== null &&
|
|
195
|
+
(!j.previousOutput || j.previousOutput.path !== paths.output ||
|
|
196
|
+
!digest.test(j.previousOutput.sha256))
|
|
197
|
+
) fail("INVALID_RECOVERY_JOURNAL", "Invalid previous output receipt.");
|
|
198
|
+
if (
|
|
199
|
+
j.approval !== undefined &&
|
|
200
|
+
(!j.approval ||
|
|
201
|
+
j.approval.path !== join(paths.stateDir, "sync-approved.json") ||
|
|
202
|
+
typeof j.approval.sha256 !== "string" ||
|
|
203
|
+
!digest.test(j.approval.sha256) ||
|
|
204
|
+
typeof j.approval.archive !== "string" ||
|
|
205
|
+
dirname(j.approval.archive) !== join(paths.stateDir, "history") ||
|
|
206
|
+
!new RegExp("^consumed-sync-" + uuid + "\\.json$").test(
|
|
207
|
+
basename(j.approval.archive),
|
|
208
|
+
))
|
|
209
|
+
) {
|
|
210
|
+
fail(
|
|
211
|
+
"INVALID_RECOVERY_PATH",
|
|
212
|
+
"Render approval paths and digest must match scoped project history.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return j;
|
|
216
|
+
}
|
|
217
|
+
interface Snapshot {
|
|
218
|
+
paths: ReturnType<typeof projectPaths>;
|
|
219
|
+
report: RecoveryReport;
|
|
220
|
+
state?: ProjectState;
|
|
221
|
+
previous?: ProjectState;
|
|
222
|
+
journalHash?: string;
|
|
223
|
+
}
|
|
224
|
+
async function inspect(source: string): Promise<Snapshot> {
|
|
225
|
+
const paths = projectPaths(source),
|
|
226
|
+
report: RecoveryReport = {
|
|
227
|
+
action: "inspect",
|
|
228
|
+
kind: "none",
|
|
229
|
+
status: "clean",
|
|
230
|
+
files: {},
|
|
231
|
+
locks: [],
|
|
232
|
+
available: ["inspect"],
|
|
233
|
+
preserved: [],
|
|
234
|
+
diagnostics: [],
|
|
235
|
+
};
|
|
236
|
+
await safePath(paths.source, "file");
|
|
237
|
+
if (!await safePath(paths.stateDir, "directory")) return { paths, report };
|
|
238
|
+
const identity = join(paths.stateDir, "project.json");
|
|
239
|
+
await safePath(identity, "file", false);
|
|
240
|
+
const meta = JSON.parse(await Deno.readTextFile(identity));
|
|
241
|
+
if (meta.version !== 1 || meta.source !== basename(paths.source)) {
|
|
242
|
+
fail(
|
|
243
|
+
"PROJECT_SOURCE_MISMATCH",
|
|
244
|
+
"Recovery directory belongs to another source.",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
report.locks = await Promise.all(
|
|
248
|
+
["operation.lock", "write.lock"].map((name) =>
|
|
249
|
+
lockEvidence(join(paths.stateDir, name))
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
const sync = await evidence(join(paths.stateDir, "sync-transaction.json"));
|
|
253
|
+
if (sync.exists) {
|
|
254
|
+
if (
|
|
255
|
+
(await evidence(join(paths.stateDir, "render-transaction.json"))).exists
|
|
256
|
+
) {
|
|
257
|
+
fail(
|
|
258
|
+
"MULTIPLE_RECOVERY_TRANSACTIONS",
|
|
259
|
+
"Render and sync journals coexist; inspect manually instead of guessing operation order.",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return await inspectSync(paths, report, sync);
|
|
263
|
+
}
|
|
264
|
+
const journalFile = await evidence(
|
|
265
|
+
join(paths.stateDir, "render-transaction.json"),
|
|
266
|
+
);
|
|
267
|
+
report.files.journal = journalFile;
|
|
268
|
+
if (!journalFile.exists) {
|
|
269
|
+
report.status = report.locks.some((l) => l.status !== "absent")
|
|
270
|
+
? "lock-without-transaction"
|
|
271
|
+
: "clean";
|
|
272
|
+
report.diagnostics.push(
|
|
273
|
+
"No interrupted render transaction. Lock-only cleanup requires owner investigation.",
|
|
274
|
+
);
|
|
275
|
+
return { paths, report };
|
|
276
|
+
}
|
|
277
|
+
let parsed;
|
|
278
|
+
try {
|
|
279
|
+
parsed = JSON.parse(await Deno.readTextFile(journalFile.path));
|
|
280
|
+
} catch {
|
|
281
|
+
fail("INVALID_RECOVERY_JOURNAL", "Render transaction is not valid JSON.");
|
|
282
|
+
}
|
|
283
|
+
const journal = validateJournal(parsed, paths);
|
|
284
|
+
report.kind = "render";
|
|
285
|
+
report.journal = journal;
|
|
286
|
+
report.status = "interrupted";
|
|
287
|
+
for (
|
|
288
|
+
const [name, path] of Object.entries({
|
|
289
|
+
staging: journal.staging,
|
|
290
|
+
backup: journal.backup,
|
|
291
|
+
output: journal.output,
|
|
292
|
+
state: join(paths.stateDir, "state.json"),
|
|
293
|
+
previous: join(paths.stateDir, "state.previous.json"),
|
|
294
|
+
})
|
|
295
|
+
) report.files[name] = await evidence(path);
|
|
296
|
+
if (journal.approval) {
|
|
297
|
+
report.files.approval = await evidence(journal.approval.path);
|
|
298
|
+
report.files.approvalArchive = await evidence(journal.approval.archive);
|
|
299
|
+
if (!report.files.approval.exists && !report.files.approvalArchive.exists) {
|
|
300
|
+
fail(
|
|
301
|
+
"RECOVERY_APPROVAL_MISSING",
|
|
302
|
+
"Consumed render approval is missing from both active and archived paths.",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
for (const name of ["approval", "approvalArchive"]) {
|
|
306
|
+
if (
|
|
307
|
+
report.files[name].exists &&
|
|
308
|
+
report.files[name].sha256 !== journal.approval.sha256
|
|
309
|
+
) {
|
|
310
|
+
fail(
|
|
311
|
+
"RECOVERY_APPROVAL_MISMATCH",
|
|
312
|
+
"Render approval evidence differs from the exact transaction receipt; it will not be overwritten.",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (report.files.approvalArchive.exists) {
|
|
317
|
+
report.preserved.push(journal.approval.archive);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (
|
|
321
|
+
report.files.staging.exists &&
|
|
322
|
+
report.files.staging.sha256 !== journal.sha256
|
|
323
|
+
) {
|
|
324
|
+
fail(
|
|
325
|
+
"RECOVERY_STAGING_CHANGED",
|
|
326
|
+
"Staged HTML differs from the transaction receipt; preserve it for manual review.",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const store = new StateStore(paths.stateDir), state = await store.load();
|
|
330
|
+
const revision = state?.revision ?? 0;
|
|
331
|
+
report.stateRevision = revision;
|
|
332
|
+
if (revision === journal.oldRevision) report.statePhase = "before";
|
|
333
|
+
else if (revision === journal.nextRevision) report.statePhase = "after";
|
|
334
|
+
else {fail(
|
|
335
|
+
"RECOVERY_STATE_CHANGED",
|
|
336
|
+
"State revision is neither the transaction baseline nor its next revision; later edits must not be rolled back.",
|
|
337
|
+
{ revision, journal },
|
|
338
|
+
);}
|
|
339
|
+
let previous: ProjectState | undefined;
|
|
340
|
+
if (report.statePhase === "after") {
|
|
341
|
+
if (
|
|
342
|
+
!state?.output || state.output.path !== journal.output ||
|
|
343
|
+
state.output.sha256 !== journal.sha256
|
|
344
|
+
) {
|
|
345
|
+
fail(
|
|
346
|
+
"RECOVERY_RECEIPT_MISMATCH",
|
|
347
|
+
"Current state does not carry the interrupted render receipt.",
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (journal.oldRevision > 0) {
|
|
351
|
+
previous = await store.inspectRecovery();
|
|
352
|
+
if (
|
|
353
|
+
!previous || previous.revision !== journal.oldRevision ||
|
|
354
|
+
previous.docId !== state.docId ||
|
|
355
|
+
!equal(previous.output ?? null, journal.previousOutput)
|
|
356
|
+
) {
|
|
357
|
+
fail(
|
|
358
|
+
"RECOVERY_PREVIOUS_MISMATCH",
|
|
359
|
+
"Previous envelope does not match the exact pre-render state.",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const name of ["staging", "output"]) {
|
|
364
|
+
const file = report.files[name];
|
|
365
|
+
if (file.exists && file.sha256 === journal.sha256) {
|
|
366
|
+
const doc = extractDocument(await Deno.readTextFile(file.path));
|
|
367
|
+
if (!equal(doc, state.emitted)) {
|
|
368
|
+
fail(
|
|
369
|
+
"RECOVERY_DOCUMENT_MISMATCH",
|
|
370
|
+
"Output payload differs from the committed state snapshot.",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} else if (!equal(state?.output ?? null, journal.previousOutput)) {
|
|
376
|
+
fail(
|
|
377
|
+
"RECOVERY_RECEIPT_MISMATCH",
|
|
378
|
+
"Baseline state does not match the recorded previous output.",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (report.files.backup.exists) report.preserved.push(journal.backup);
|
|
382
|
+
const output = report.files.output;
|
|
383
|
+
const knownOutput = !output.exists || output.sha256 === journal.sha256 ||
|
|
384
|
+
output.sha256 === journal.previousOutput?.sha256 ||
|
|
385
|
+
output.sha256 === report.files.backup.sha256;
|
|
386
|
+
let widgetsValid = true;
|
|
387
|
+
if (report.statePhase === "after" && state?.widgets) {
|
|
388
|
+
try {
|
|
389
|
+
await assertWidgetFiles(paths.stateDir, state);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
widgetsValid = false;
|
|
392
|
+
report.diagnostics.push(
|
|
393
|
+
`Widget resources block finishing this render: ${
|
|
394
|
+
error instanceof Error ? error.message : String(error)
|
|
395
|
+
}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const locksSafe = report.locks.every((l) =>
|
|
400
|
+
l.status === "absent" || l.status === "dead"
|
|
401
|
+
);
|
|
402
|
+
if (!knownOutput) {
|
|
403
|
+
report.diagnostics.push(
|
|
404
|
+
"Concurrent output differs from all transaction evidence. Recovery refuses to overwrite it.",
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (!locksSafe) {
|
|
408
|
+
report.diagnostics.push(
|
|
409
|
+
"A live or ambiguous lock owner blocks mutation. Inspect is read-only.",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (report.statePhase === "before") {
|
|
413
|
+
report.diagnostics.push(
|
|
414
|
+
"Next generated baseline was not committed. Finish cannot reconstruct it from merged HTML; rollback and render again.",
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (locksSafe && knownOutput) {
|
|
418
|
+
report.available.push("rollback");
|
|
419
|
+
if (
|
|
420
|
+
widgetsValid && report.statePhase === "after" &&
|
|
421
|
+
(!output.exists || output.sha256 === journal.sha256) &&
|
|
422
|
+
(report.files.staging.exists || output.sha256 === journal.sha256)
|
|
423
|
+
) report.available.push("finish");
|
|
424
|
+
}
|
|
425
|
+
return { paths, report, state, previous, journalHash: journalFile.sha256 };
|
|
426
|
+
}
|
|
427
|
+
async function durable(path: string, bytes: Uint8Array) {
|
|
428
|
+
const file = await Deno.open(path, {
|
|
429
|
+
createNew: true,
|
|
430
|
+
write: true,
|
|
431
|
+
mode: 0o600,
|
|
432
|
+
});
|
|
433
|
+
try {
|
|
434
|
+
let offset = 0;
|
|
435
|
+
while (offset < bytes.length) {
|
|
436
|
+
offset += await file.write(bytes.subarray(offset));
|
|
437
|
+
}
|
|
438
|
+
await file.sync();
|
|
439
|
+
} finally {
|
|
440
|
+
file.close();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function acquireLock(
|
|
444
|
+
path: string,
|
|
445
|
+
history: string,
|
|
446
|
+
preserved: string[],
|
|
447
|
+
): Promise<() => Promise<void>> {
|
|
448
|
+
const before = await lockEvidence(path);
|
|
449
|
+
if (before.status === "alive" || before.status === "ambiguous") {
|
|
450
|
+
fail(
|
|
451
|
+
"RECOVERY_LOCKED",
|
|
452
|
+
"Recovery cannot release a live or ambiguous lock.",
|
|
453
|
+
before,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
if (before.status === "dead") {
|
|
457
|
+
const again = await lockEvidence(path);
|
|
458
|
+
if (again.status !== "dead" || again.ownerHash !== before.ownerHash) {
|
|
459
|
+
fail(
|
|
460
|
+
"RECOVERY_LOCK_CHANGED",
|
|
461
|
+
"Lock ownership changed during inspection.",
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
const archived = join(history, basename(path) + "-" + crypto.randomUUID());
|
|
465
|
+
await Deno.rename(path, archived);
|
|
466
|
+
preserved.push(archived);
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
await Deno.mkdir(path);
|
|
470
|
+
} catch (e) {
|
|
471
|
+
if (e instanceof Deno.errors.AlreadyExists) {
|
|
472
|
+
fail(
|
|
473
|
+
"RECOVERY_LOCKED",
|
|
474
|
+
"A concurrent operation acquired the project lock.",
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
throw e;
|
|
478
|
+
}
|
|
479
|
+
const owner = JSON.stringify({
|
|
480
|
+
pid: Deno.pid,
|
|
481
|
+
started: new Date().toISOString(),
|
|
482
|
+
recovery: true,
|
|
483
|
+
token: crypto.randomUUID(),
|
|
484
|
+
});
|
|
485
|
+
await Deno.writeTextFile(join(path, "owner.json"), owner, {
|
|
486
|
+
createNew: true,
|
|
487
|
+
});
|
|
488
|
+
return async () => {
|
|
489
|
+
if (await Deno.readTextFile(join(path, "owner.json")) !== owner) {
|
|
490
|
+
fail(
|
|
491
|
+
"RECOVERY_LOCK_CHANGED",
|
|
492
|
+
"Recovery lock owner changed; it will not be removed.",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
await Deno.remove(path, { recursive: true });
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
async function publishNoClobber(from: string, to: string) {
|
|
499
|
+
try {
|
|
500
|
+
await Deno.link(from, to);
|
|
501
|
+
} catch (e) {
|
|
502
|
+
if (e instanceof Deno.errors.AlreadyExists) {
|
|
503
|
+
fail(
|
|
504
|
+
"RECOVERY_OUTPUT_CHANGED",
|
|
505
|
+
"An output appeared during recovery. It was not overwritten.",
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
throw e;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async function archiveOutput(
|
|
512
|
+
path: string,
|
|
513
|
+
expected: string,
|
|
514
|
+
history: string,
|
|
515
|
+
preserved: string[],
|
|
516
|
+
): Promise<void> {
|
|
517
|
+
const archive = join(
|
|
518
|
+
history,
|
|
519
|
+
"recovery-output-" + crypto.randomUUID() +
|
|
520
|
+
(path.endsWith(".json")
|
|
521
|
+
? ".json"
|
|
522
|
+
: path.endsWith(".qmd")
|
|
523
|
+
? ".qmd"
|
|
524
|
+
: ".html"),
|
|
525
|
+
);
|
|
526
|
+
await Deno.rename(path, archive);
|
|
527
|
+
preserved.push(archive);
|
|
528
|
+
const actual = await evidence(archive);
|
|
529
|
+
if (actual.sha256 !== expected) {
|
|
530
|
+
try {
|
|
531
|
+
await publishNoClobber(archive, path);
|
|
532
|
+
} catch (e) {
|
|
533
|
+
if (!(e instanceof StateError && e.code === "RECOVERY_OUTPUT_CHANGED")) {
|
|
534
|
+
throw e;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
fail(
|
|
538
|
+
"RECOVERY_OUTPUT_CHANGED",
|
|
539
|
+
"A concurrent output change was preserved; recovery stopped.",
|
|
540
|
+
{ archive },
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/** Deliberate recovery only. Inspect is read-only and never removes a lock or journal. */
|
|
545
|
+
export async function recoverProject(
|
|
546
|
+
source: string,
|
|
547
|
+
action: RecoveryAction = "inspect",
|
|
548
|
+
): Promise<RecoveryReport> {
|
|
549
|
+
if (!["inspect", "finish", "rollback"].includes(action)) {
|
|
550
|
+
fail("INVALID_RECOVERY_ACTION", "Expected inspect, finish or rollback.");
|
|
551
|
+
}
|
|
552
|
+
const initial = await inspect(source);
|
|
553
|
+
if (action === "inspect") return initial.report;
|
|
554
|
+
if (initial.report.kind === "sync") {
|
|
555
|
+
return await recoverSyncTransaction(initial, action);
|
|
556
|
+
}
|
|
557
|
+
if (initial.report.kind !== "render") {
|
|
558
|
+
fail(
|
|
559
|
+
"NO_RENDER_TRANSACTION",
|
|
560
|
+
"No render transaction exists to finish or roll back.",
|
|
561
|
+
initial.report,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (!initial.report.available.includes(action)) {
|
|
565
|
+
fail(
|
|
566
|
+
initial.report.statePhase === "before" && action === "finish"
|
|
567
|
+
? "RECOVERY_STATE_NOT_COMMITTED"
|
|
568
|
+
: "RECOVERY_BLOCKED",
|
|
569
|
+
"Requested recovery action is unsafe for the observed evidence.",
|
|
570
|
+
initial.report,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
const history = join(initial.paths.stateDir, "history");
|
|
574
|
+
await Deno.mkdir(history, { recursive: true });
|
|
575
|
+
await safePath(history, "directory", false);
|
|
576
|
+
const preserved = [...initial.report.preserved],
|
|
577
|
+
release = await acquireLock(
|
|
578
|
+
join(initial.paths.stateDir, "operation.lock"),
|
|
579
|
+
history,
|
|
580
|
+
preserved,
|
|
581
|
+
);
|
|
582
|
+
try {
|
|
583
|
+
const releaseWrite = await acquireLock(
|
|
584
|
+
join(initial.paths.stateDir, "write.lock"),
|
|
585
|
+
history,
|
|
586
|
+
preserved,
|
|
587
|
+
);
|
|
588
|
+
try {
|
|
589
|
+
const snapshot = await inspect(source),
|
|
590
|
+
{ paths, report, state } = snapshot,
|
|
591
|
+
j = report.journal as RenderJournal;
|
|
592
|
+
if (
|
|
593
|
+
snapshot.journalHash !== initial.journalHash ||
|
|
594
|
+
report.files.state.sha256 !== initial.report.files.state.sha256 ||
|
|
595
|
+
report.files.previous.sha256 !== initial.report.files.previous.sha256
|
|
596
|
+
) {
|
|
597
|
+
fail(
|
|
598
|
+
"RECOVERY_CHANGED",
|
|
599
|
+
"Transaction or state changed while acquiring recovery locks.",
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
// Our locks are now live. Recheck all file evidence and reject unmanaged output changes.
|
|
603
|
+
for (const name of ["staging", "backup", "output"]) {
|
|
604
|
+
if (
|
|
605
|
+
report.files[name].sha256 !== initial.report.files[name].sha256 ||
|
|
606
|
+
report.files[name].exists !== initial.report.files[name].exists
|
|
607
|
+
) {
|
|
608
|
+
fail(
|
|
609
|
+
"RECOVERY_CHANGED",
|
|
610
|
+
"Transaction files changed while acquiring recovery locks.",
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (j.approval) {
|
|
615
|
+
for (const name of ["approval", "approvalArchive"]) {
|
|
616
|
+
if (
|
|
617
|
+
report.files[name].sha256 !== initial.report.files[name].sha256 ||
|
|
618
|
+
report.files[name].exists !== initial.report.files[name].exists
|
|
619
|
+
) {
|
|
620
|
+
fail(
|
|
621
|
+
"RECOVERY_CHANGED",
|
|
622
|
+
"Render approval changed while recovery acquired locks.",
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (action === "finish") {
|
|
628
|
+
if (state?.widgets) await assertWidgetFiles(paths.stateDir, state);
|
|
629
|
+
if (!report.files.output.exists) {
|
|
630
|
+
await publishNoClobber(j.staging, j.output);
|
|
631
|
+
}
|
|
632
|
+
if ((await evidence(j.output)).sha256 !== j.sha256) {
|
|
633
|
+
fail(
|
|
634
|
+
"RECOVERY_OUTPUT_CHANGED",
|
|
635
|
+
"Published output changed; transaction remains for review.",
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
} else {
|
|
639
|
+
const output = report.files.output;
|
|
640
|
+
if (
|
|
641
|
+
output.exists && output.sha256 === j.sha256 &&
|
|
642
|
+
output.sha256 !== j.previousOutput?.sha256
|
|
643
|
+
) await archiveOutput(j.output, j.sha256, history, preserved);
|
|
644
|
+
const current = await evidence(j.output);
|
|
645
|
+
if (!current.exists && report.files.backup.exists) {
|
|
646
|
+
await publishNoClobber(j.backup, j.output);
|
|
647
|
+
} else if (!current.exists && j.previousOutput) {
|
|
648
|
+
fail(
|
|
649
|
+
"RECOVERY_BACKUP_MISSING",
|
|
650
|
+
"Prior output was present but no recoverable backup remains.",
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
if (report.statePhase === "after") {
|
|
654
|
+
const statePath = join(paths.stateDir, "state.json"),
|
|
655
|
+
archive = join(
|
|
656
|
+
history,
|
|
657
|
+
"state-next-" + crypto.randomUUID() + ".json",
|
|
658
|
+
);
|
|
659
|
+
if (
|
|
660
|
+
(await evidence(statePath)).sha256 !== report.files.state.sha256
|
|
661
|
+
) fail("RECOVERY_STATE_CHANGED", "State changed during rollback.");
|
|
662
|
+
await Deno.link(statePath, archive);
|
|
663
|
+
preserved.push(archive);
|
|
664
|
+
if (j.oldRevision === 0) {
|
|
665
|
+
if (!state || state.revision !== 1) {
|
|
666
|
+
fail(
|
|
667
|
+
"RECOVERY_STATE_CHANGED",
|
|
668
|
+
"Initial render rollback expected revision one.",
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
await Deno.remove(statePath);
|
|
672
|
+
} else {
|
|
673
|
+
const oldBytes = await Deno.readFile(report.files.previous.path),
|
|
674
|
+
temp = join(
|
|
675
|
+
history,
|
|
676
|
+
"restore-state-" + crypto.randomUUID() + ".json",
|
|
677
|
+
);
|
|
678
|
+
await durable(temp, oldBytes);
|
|
679
|
+
await Deno.rename(temp, statePath);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (j.approval) {
|
|
684
|
+
if (action === "finish") {
|
|
685
|
+
if (!report.files.approvalArchive.exists) {
|
|
686
|
+
await publishNoClobber(j.approval.path, j.approval.archive);
|
|
687
|
+
}
|
|
688
|
+
if (
|
|
689
|
+
(await evidence(j.approval.archive)).sha256 !== j.approval.sha256
|
|
690
|
+
) {
|
|
691
|
+
fail(
|
|
692
|
+
"RECOVERY_APPROVAL_MISMATCH",
|
|
693
|
+
"Archived approval changed during render recovery.",
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
preserved.push(j.approval.archive);
|
|
697
|
+
if (report.files.approval.exists) {
|
|
698
|
+
await archiveOutput(
|
|
699
|
+
j.approval.path,
|
|
700
|
+
j.approval.sha256,
|
|
701
|
+
history,
|
|
702
|
+
preserved,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
} else if (!report.files.approval.exists) {
|
|
706
|
+
await publishNoClobber(j.approval.archive, j.approval.path);
|
|
707
|
+
if ((await evidence(j.approval.path)).sha256 !== j.approval.sha256) {
|
|
708
|
+
fail(
|
|
709
|
+
"RECOVERY_APPROVAL_MISMATCH",
|
|
710
|
+
"Restored approval changed during rollback.",
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
preserved.push(j.approval.archive);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// Keep staged bytes, original HTML and the exact journal for forensic recovery.
|
|
717
|
+
if (report.files.staging.exists) preserved.push(j.staging);
|
|
718
|
+
const archivedJournal = join(
|
|
719
|
+
history,
|
|
720
|
+
"render-transaction-" + crypto.randomUUID() + ".json",
|
|
721
|
+
);
|
|
722
|
+
await Deno.rename(report.files.journal.path, archivedJournal);
|
|
723
|
+
preserved.push(archivedJournal);
|
|
724
|
+
if (Deno.build.os !== "windows") {
|
|
725
|
+
for (const dir of [paths.directory, paths.stateDir, history]) {
|
|
726
|
+
const f = await Deno.open(dir, { read: true });
|
|
727
|
+
try {
|
|
728
|
+
await f.sync();
|
|
729
|
+
} finally {
|
|
730
|
+
f.close();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
...report,
|
|
736
|
+
action,
|
|
737
|
+
status: action === "finish" ? "finished" : "rolled-back",
|
|
738
|
+
available: ["inspect"],
|
|
739
|
+
preserved,
|
|
740
|
+
diagnostics: action === "rollback" && report.files.backup.exists &&
|
|
741
|
+
report.files.backup.sha256 !== j.previousOutput?.sha256
|
|
742
|
+
? [
|
|
743
|
+
"Concurrent native edits restored from backup; capture them before rendering.",
|
|
744
|
+
]
|
|
745
|
+
: [],
|
|
746
|
+
};
|
|
747
|
+
} finally {
|
|
748
|
+
await releaseWrite();
|
|
749
|
+
}
|
|
750
|
+
} finally {
|
|
751
|
+
await release();
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function readEnvelope(path: string): Promise<ProjectState> {
|
|
756
|
+
await safePath(path, "file", false);
|
|
757
|
+
let parsed;
|
|
758
|
+
try {
|
|
759
|
+
parsed = JSON.parse(await Deno.readTextFile(path));
|
|
760
|
+
} catch {
|
|
761
|
+
fail(
|
|
762
|
+
"CORRUPT_RECOVERY_STATE",
|
|
763
|
+
"Recovery state envelope is not valid JSON.",
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
if (parsed?.format !== "qbento-state-envelope/1") {
|
|
767
|
+
fail("CORRUPT_RECOVERY_STATE", "Unsupported recovery envelope.");
|
|
768
|
+
}
|
|
769
|
+
validateState(parsed.state);
|
|
770
|
+
if (await sha256(canonical(parsed.state)) !== parsed.sha256) {
|
|
771
|
+
fail(
|
|
772
|
+
"CORRUPT_RECOVERY_STATE",
|
|
773
|
+
"Recovery state envelope checksum does not match.",
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
return parsed.state;
|
|
777
|
+
}
|
|
778
|
+
function validateSync(
|
|
779
|
+
value: unknown,
|
|
780
|
+
paths: ReturnType<typeof projectPaths>,
|
|
781
|
+
): SyncJournal {
|
|
782
|
+
const j = value as SyncJournal;
|
|
783
|
+
if (
|
|
784
|
+
!j || j.version !== 1 || typeof j.transactionId !== "string" ||
|
|
785
|
+
!new RegExp("^" + uuid + "$").test(j.transactionId) ||
|
|
786
|
+
!Number.isSafeInteger(j.stateRevision) || j.stateRevision < 1 ||
|
|
787
|
+
![j.sourceHash, j.resultHash, j.planHash].every((v) =>
|
|
788
|
+
typeof v === "string" && digest.test(v)
|
|
789
|
+
) || !Array.isArray(j.selectedChangeIds) ||
|
|
790
|
+
!j.selectedChangeIds.every((v) => typeof v === "string") ||
|
|
791
|
+
new Set(j.selectedChangeIds).size !== j.selectedChangeIds.length
|
|
792
|
+
) {
|
|
793
|
+
fail(
|
|
794
|
+
"INVALID_RECOVERY_JOURNAL",
|
|
795
|
+
"Unsupported or malformed source sync transaction.",
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
if (j.annotations !== undefined) validateAnnotationSummary(j.annotations);
|
|
799
|
+
const history = join(paths.stateDir, "history");
|
|
800
|
+
if (
|
|
801
|
+
j.source !== paths.source ||
|
|
802
|
+
j.sourceBackup !== join(history, "source-" + j.transactionId + ".qmd") ||
|
|
803
|
+
j.stateBackup !== join(history, "state-" + j.transactionId + ".json") ||
|
|
804
|
+
j.receiptPath !== join(history, "sync-" + j.transactionId + ".json") ||
|
|
805
|
+
j.staging !==
|
|
806
|
+
join(
|
|
807
|
+
paths.directory,
|
|
808
|
+
"." + basename(paths.source) + ".sync-" + j.transactionId,
|
|
809
|
+
)
|
|
810
|
+
) {
|
|
811
|
+
fail(
|
|
812
|
+
"INVALID_RECOVERY_PATH",
|
|
813
|
+
"Sync journal paths are not the exact scoped transaction paths.",
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
if (
|
|
817
|
+
(j.approvalStaging !== undefined || j.approvalPath !== undefined) &&
|
|
818
|
+
(j.approvalStaging !==
|
|
819
|
+
join(history, "sync-approval-" + j.transactionId + ".json") ||
|
|
820
|
+
j.approvalPath !== join(paths.stateDir, "sync-approved.json"))
|
|
821
|
+
) {
|
|
822
|
+
fail(
|
|
823
|
+
"INVALID_RECOVERY_PATH",
|
|
824
|
+
"Sync approval paths do not match the exact transaction stage and project approval file.",
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return j;
|
|
828
|
+
}
|
|
829
|
+
function syncReceipt(j: SyncJournal) {
|
|
830
|
+
return {
|
|
831
|
+
version: 1,
|
|
832
|
+
transactionId: j.transactionId,
|
|
833
|
+
source: j.source,
|
|
834
|
+
planHash: j.planHash,
|
|
835
|
+
previousSourceHash: j.sourceHash,
|
|
836
|
+
sourceHash: j.resultHash,
|
|
837
|
+
previousRevision: j.stateRevision,
|
|
838
|
+
revision: j.stateRevision + 1,
|
|
839
|
+
appliedChangeIds: j.selectedChangeIds,
|
|
840
|
+
sourceBackup: j.sourceBackup,
|
|
841
|
+
stateBackup: j.stateBackup,
|
|
842
|
+
receiptPath: j.receiptPath,
|
|
843
|
+
renderNeeded: true,
|
|
844
|
+
pendingRetained: true,
|
|
845
|
+
...(j.annotations ? { annotations: clone(j.annotations) } : {}),
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
async function inspectSync(
|
|
849
|
+
paths: ReturnType<typeof projectPaths>,
|
|
850
|
+
report: RecoveryReport,
|
|
851
|
+
file: RecoveryFile,
|
|
852
|
+
): Promise<Snapshot> {
|
|
853
|
+
let parsed;
|
|
854
|
+
try {
|
|
855
|
+
parsed = JSON.parse(await Deno.readTextFile(file.path));
|
|
856
|
+
} catch {
|
|
857
|
+
fail("INVALID_RECOVERY_JOURNAL", "Sync transaction is not valid JSON.");
|
|
858
|
+
}
|
|
859
|
+
const j = validateSync(parsed, paths);
|
|
860
|
+
report.kind = "sync";
|
|
861
|
+
report.status = "interrupted";
|
|
862
|
+
report.journal = j;
|
|
863
|
+
report.files.journal = file;
|
|
864
|
+
for (
|
|
865
|
+
const [name, path] of Object.entries({
|
|
866
|
+
staging: j.staging,
|
|
867
|
+
source: j.source,
|
|
868
|
+
backup: j.sourceBackup,
|
|
869
|
+
stateBackup: j.stateBackup,
|
|
870
|
+
receipt: j.receiptPath,
|
|
871
|
+
state: join(paths.stateDir, "state.json"),
|
|
872
|
+
previous: join(paths.stateDir, "state.previous.json"),
|
|
873
|
+
})
|
|
874
|
+
) report.files[name] = await evidence(path);
|
|
875
|
+
const baseline = await readEnvelope(j.stateBackup);
|
|
876
|
+
if (baseline.revision !== j.stateRevision) {
|
|
877
|
+
fail(
|
|
878
|
+
"RECOVERY_PREVIOUS_MISMATCH",
|
|
879
|
+
"Sync baseline envelope has a different revision.",
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
if (j.approvalStaging && j.approvalPath) {
|
|
883
|
+
report.files.approvalStaging = await evidence(j.approvalStaging);
|
|
884
|
+
report.files.approval = await evidence(j.approvalPath);
|
|
885
|
+
if (!report.files.approvalStaging.exists) {
|
|
886
|
+
fail(
|
|
887
|
+
"RECOVERY_APPROVAL_MISSING",
|
|
888
|
+
"The durable approval stage is missing; preserve the transaction for explicit review.",
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
let envelope: unknown;
|
|
892
|
+
try {
|
|
893
|
+
envelope = JSON.parse(await Deno.readTextFile(j.approvalStaging));
|
|
894
|
+
} catch {
|
|
895
|
+
fail(
|
|
896
|
+
"RECOVERY_APPROVAL_INVALID",
|
|
897
|
+
"Staged synchronization approval is not JSON.",
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
validateSyncApproval(envelope);
|
|
901
|
+
const approval = envelope.approval;
|
|
902
|
+
if (
|
|
903
|
+
approval.docId !== baseline.docId ||
|
|
904
|
+
approval.sourceHash !== j.resultHash ||
|
|
905
|
+
approval.stateRevision !== j.stateRevision + 1 ||
|
|
906
|
+
approval.planHash !== j.planHash ||
|
|
907
|
+
(approval.deletionStateHash !== undefined &&
|
|
908
|
+
approval.deletionStateHash !==
|
|
909
|
+
hashSource(
|
|
910
|
+
canonical({ ...clone(baseline), revision: baseline.revision + 1 }),
|
|
911
|
+
)) ||
|
|
912
|
+
!equal(
|
|
913
|
+
approval.expectedPending.map((p) => p.id),
|
|
914
|
+
j.selectedChangeIds,
|
|
915
|
+
) || approval.expectedPending.some((p) =>
|
|
916
|
+
!equal(
|
|
917
|
+
p,
|
|
918
|
+
baseline.pending.find((original) => original.id === p.id),
|
|
919
|
+
)
|
|
920
|
+
)
|
|
921
|
+
) {
|
|
922
|
+
fail(
|
|
923
|
+
"RECOVERY_APPROVAL_MISMATCH",
|
|
924
|
+
"Staged approval does not match the reviewed source transaction and exact pending baseline.",
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
if (
|
|
928
|
+
report.files.approval.exists &&
|
|
929
|
+
report.files.approval.sha256 !== report.files.approvalStaging.sha256
|
|
930
|
+
) {
|
|
931
|
+
fail(
|
|
932
|
+
"RECOVERY_APPROVAL_MISMATCH",
|
|
933
|
+
"The active project approval differs from this transaction; it will not be overwritten or deleted.",
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
report.preserved.push(j.approvalStaging);
|
|
937
|
+
}
|
|
938
|
+
const state = await new StateStore(paths.stateDir).load();
|
|
939
|
+
if (!state) {
|
|
940
|
+
fail(
|
|
941
|
+
"RECOVERY_STATE_CHANGED",
|
|
942
|
+
"A sync transaction cannot recover a missing project state.",
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
report.stateRevision = state.revision;
|
|
946
|
+
const comparable = clone(state);
|
|
947
|
+
comparable.revision = baseline.revision;
|
|
948
|
+
if (
|
|
949
|
+
!equal(comparable, baseline) ||
|
|
950
|
+
![j.stateRevision, j.stateRevision + 1].includes(state.revision)
|
|
951
|
+
) {
|
|
952
|
+
fail(
|
|
953
|
+
"RECOVERY_STATE_CHANGED",
|
|
954
|
+
"Project changes after synchronization prevent automatic finish or rollback.",
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
report.statePhase = state.revision === j.stateRevision ? "before" : "after";
|
|
958
|
+
if (
|
|
959
|
+
report.files.staging.exists && report.files.staging.sha256 !== j.resultHash
|
|
960
|
+
) {
|
|
961
|
+
fail(
|
|
962
|
+
"RECOVERY_STAGING_CHANGED",
|
|
963
|
+
"Sync staging content differs from the approved source result.",
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
if (report.files.receipt.exists) {
|
|
967
|
+
let receipt;
|
|
968
|
+
try {
|
|
969
|
+
receipt = JSON.parse(await Deno.readTextFile(j.receiptPath));
|
|
970
|
+
} catch {
|
|
971
|
+
fail("RECOVERY_RECEIPT_MISMATCH", "Existing sync receipt is corrupt.");
|
|
972
|
+
}
|
|
973
|
+
if (!equal(receipt, syncReceipt(j))) {
|
|
974
|
+
fail(
|
|
975
|
+
"RECOVERY_RECEIPT_MISMATCH",
|
|
976
|
+
"Existing sync receipt describes a different operation.",
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (report.files.backup.exists) report.preserved.push(j.sourceBackup);
|
|
981
|
+
report.preserved.push(j.stateBackup);
|
|
982
|
+
const current = report.files.source, backup = report.files.backup;
|
|
983
|
+
const knownSource = !current.exists || current.sha256 === j.sourceHash ||
|
|
984
|
+
current.sha256 === j.resultHash ||
|
|
985
|
+
(backup.exists && current.sha256 === backup.sha256);
|
|
986
|
+
const locksSafe = report.locks.every((l) =>
|
|
987
|
+
l.status === "absent" || l.status === "dead"
|
|
988
|
+
);
|
|
989
|
+
if (!knownSource) {
|
|
990
|
+
report.diagnostics.push(
|
|
991
|
+
"Source changed after synchronization. Recovery preserves it and refuses automatic replacement.",
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
if (!locksSafe) {
|
|
995
|
+
report.diagnostics.push("A live or ambiguous lock owner blocks mutation.");
|
|
996
|
+
}
|
|
997
|
+
if (knownSource && locksSafe) {
|
|
998
|
+
if (
|
|
999
|
+
(current.exists && current.sha256 !== j.resultHash) || backup.exists ||
|
|
1000
|
+
j.sourceHash === j.resultHash
|
|
1001
|
+
) report.available.push("rollback");
|
|
1002
|
+
const canBackup = backup.exists
|
|
1003
|
+
? backup.sha256 === j.sourceHash
|
|
1004
|
+
: current.sha256 === j.sourceHash;
|
|
1005
|
+
if (
|
|
1006
|
+
canBackup &&
|
|
1007
|
+
(current.sha256 === j.resultHash || report.files.staging.exists)
|
|
1008
|
+
) report.available.push("finish");
|
|
1009
|
+
}
|
|
1010
|
+
return { paths, report, state, previous: baseline, journalHash: file.sha256 };
|
|
1011
|
+
}
|
|
1012
|
+
/** Caller holds both operation and state-write locks; preserve exact prior envelope. */
|
|
1013
|
+
async function replaceState(
|
|
1014
|
+
snapshot: Snapshot,
|
|
1015
|
+
state: ProjectState,
|
|
1016
|
+
preserved: string[],
|
|
1017
|
+
) {
|
|
1018
|
+
const statePath = join(snapshot.paths.stateDir, "state.json"),
|
|
1019
|
+
history = join(snapshot.paths.stateDir, "history");
|
|
1020
|
+
if (
|
|
1021
|
+
(await evidence(statePath)).sha256 !== snapshot.report.files.state.sha256
|
|
1022
|
+
) fail("RECOVERY_STATE_CHANGED", "State changed during recovery.");
|
|
1023
|
+
const archive = join(
|
|
1024
|
+
history,
|
|
1025
|
+
"state-recovery-" + crypto.randomUUID() + ".json",
|
|
1026
|
+
);
|
|
1027
|
+
await Deno.link(statePath, archive);
|
|
1028
|
+
preserved.push(archive);
|
|
1029
|
+
const bytes = new TextEncoder().encode(
|
|
1030
|
+
JSON.stringify(
|
|
1031
|
+
{
|
|
1032
|
+
format: "qbento-state-envelope/1",
|
|
1033
|
+
sha256: await sha256(canonical(state)),
|
|
1034
|
+
state,
|
|
1035
|
+
},
|
|
1036
|
+
null,
|
|
1037
|
+
2,
|
|
1038
|
+
) + "\n",
|
|
1039
|
+
);
|
|
1040
|
+
const temp = join(history, "state-pending-" + crypto.randomUUID() + ".json");
|
|
1041
|
+
await durable(temp, bytes);
|
|
1042
|
+
await Deno.rename(temp, statePath);
|
|
1043
|
+
}
|
|
1044
|
+
async function recoverSyncTransaction(
|
|
1045
|
+
initial: Snapshot,
|
|
1046
|
+
action: RecoveryAction,
|
|
1047
|
+
): Promise<RecoveryReport> {
|
|
1048
|
+
if (!initial.report.available.includes(action)) {
|
|
1049
|
+
fail(
|
|
1050
|
+
"RECOVERY_BLOCKED",
|
|
1051
|
+
"Requested sync recovery is unsafe for the observed source/state/lock evidence.",
|
|
1052
|
+
initial.report,
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
const history = join(initial.paths.stateDir, "history");
|
|
1056
|
+
await safePath(history, "directory", false);
|
|
1057
|
+
const preserved = [...initial.report.preserved],
|
|
1058
|
+
release = await acquireLock(
|
|
1059
|
+
join(initial.paths.stateDir, "operation.lock"),
|
|
1060
|
+
history,
|
|
1061
|
+
preserved,
|
|
1062
|
+
);
|
|
1063
|
+
try {
|
|
1064
|
+
const releaseWrite = await acquireLock(
|
|
1065
|
+
join(initial.paths.stateDir, "write.lock"),
|
|
1066
|
+
history,
|
|
1067
|
+
preserved,
|
|
1068
|
+
);
|
|
1069
|
+
try {
|
|
1070
|
+
const snapshot = await inspect(initial.paths.source),
|
|
1071
|
+
{ paths, report } = snapshot,
|
|
1072
|
+
j = report.journal as SyncJournal;
|
|
1073
|
+
if (
|
|
1074
|
+
report.kind !== "sync" || snapshot.journalHash !== initial.journalHash
|
|
1075
|
+
) {
|
|
1076
|
+
fail(
|
|
1077
|
+
"RECOVERY_CHANGED",
|
|
1078
|
+
"Sync transaction changed while recovery acquired locks.",
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
for (
|
|
1082
|
+
const name of [
|
|
1083
|
+
"staging",
|
|
1084
|
+
"source",
|
|
1085
|
+
"backup",
|
|
1086
|
+
"stateBackup",
|
|
1087
|
+
"receipt",
|
|
1088
|
+
"state",
|
|
1089
|
+
]
|
|
1090
|
+
) {
|
|
1091
|
+
if (
|
|
1092
|
+
report.files[name].sha256 !== initial.report.files[name].sha256 ||
|
|
1093
|
+
report.files[name].exists !== initial.report.files[name].exists
|
|
1094
|
+
) {
|
|
1095
|
+
fail(
|
|
1096
|
+
"RECOVERY_CHANGED",
|
|
1097
|
+
"Source or sync evidence changed while recovery acquired locks.",
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (j.approvalStaging && j.approvalPath) {
|
|
1102
|
+
for (const name of ["approvalStaging", "approval"]) {
|
|
1103
|
+
if (
|
|
1104
|
+
report.files[name].sha256 !== initial.report.files[name].sha256 ||
|
|
1105
|
+
report.files[name].exists !== initial.report.files[name].exists
|
|
1106
|
+
) {
|
|
1107
|
+
fail(
|
|
1108
|
+
"RECOVERY_CHANGED",
|
|
1109
|
+
"Approval metadata changed while recovery acquired locks.",
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (action === "finish") {
|
|
1115
|
+
if (report.files.source.sha256 !== j.resultHash) {
|
|
1116
|
+
if (report.files.source.exists) {
|
|
1117
|
+
if (!report.files.backup.exists) {
|
|
1118
|
+
await Deno.rename(j.source, j.sourceBackup);
|
|
1119
|
+
preserved.push(j.sourceBackup);
|
|
1120
|
+
if ((await evidence(j.sourceBackup)).sha256 !== j.sourceHash) {
|
|
1121
|
+
try {
|
|
1122
|
+
await publishNoClobber(j.sourceBackup, j.source);
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
if (
|
|
1125
|
+
!(e instanceof StateError &&
|
|
1126
|
+
e.code === "RECOVERY_OUTPUT_CHANGED")
|
|
1127
|
+
) throw e;
|
|
1128
|
+
}
|
|
1129
|
+
fail(
|
|
1130
|
+
"RECOVERY_SOURCE_CHANGED",
|
|
1131
|
+
"Concurrent source edit was preserved; synchronization recovery stopped.",
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
} else {await archiveOutput(
|
|
1135
|
+
j.source,
|
|
1136
|
+
j.sourceHash,
|
|
1137
|
+
history,
|
|
1138
|
+
preserved,
|
|
1139
|
+
);}
|
|
1140
|
+
}
|
|
1141
|
+
await publishNoClobber(j.staging, j.source);
|
|
1142
|
+
}
|
|
1143
|
+
if ((await evidence(j.source)).sha256 !== j.resultHash) {
|
|
1144
|
+
fail(
|
|
1145
|
+
"RECOVERY_SOURCE_CHANGED",
|
|
1146
|
+
"Source changed during sync finish; journal remains.",
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
if (report.statePhase === "before") {
|
|
1150
|
+
const next = clone(snapshot.previous!);
|
|
1151
|
+
next.revision = j.stateRevision + 1;
|
|
1152
|
+
await replaceState(snapshot, next, preserved);
|
|
1153
|
+
}
|
|
1154
|
+
if (j.approvalStaging && j.approvalPath) {
|
|
1155
|
+
if (!report.files.approval.exists) {
|
|
1156
|
+
await publishNoClobber(j.approvalStaging, j.approvalPath);
|
|
1157
|
+
}
|
|
1158
|
+
if (
|
|
1159
|
+
(await evidence(j.approvalPath)).sha256 !==
|
|
1160
|
+
report.files.approvalStaging.sha256
|
|
1161
|
+
) {
|
|
1162
|
+
fail(
|
|
1163
|
+
"RECOVERY_APPROVAL_MISMATCH",
|
|
1164
|
+
"Published approval changed during recovery; preserve the transaction.",
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
preserved.push(j.approvalPath);
|
|
1168
|
+
}
|
|
1169
|
+
if (!report.files.receipt.exists) {
|
|
1170
|
+
await durable(
|
|
1171
|
+
j.receiptPath,
|
|
1172
|
+
new TextEncoder().encode(
|
|
1173
|
+
JSON.stringify(syncReceipt(j), null, 2) + "\n",
|
|
1174
|
+
),
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
} else {
|
|
1178
|
+
if (
|
|
1179
|
+
report.files.source.exists &&
|
|
1180
|
+
report.files.source.sha256 === j.resultHash &&
|
|
1181
|
+
j.resultHash !== j.sourceHash
|
|
1182
|
+
) await archiveOutput(j.source, j.resultHash, history, preserved);
|
|
1183
|
+
if (!(await evidence(j.source)).exists) {
|
|
1184
|
+
if (!report.files.backup.exists) {
|
|
1185
|
+
fail(
|
|
1186
|
+
"RECOVERY_BACKUP_MISSING",
|
|
1187
|
+
"Original source backup is missing.",
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
await publishNoClobber(j.sourceBackup, j.source);
|
|
1191
|
+
}
|
|
1192
|
+
if (report.statePhase === "after") {
|
|
1193
|
+
await replaceState(snapshot, snapshot.previous!, preserved);
|
|
1194
|
+
}
|
|
1195
|
+
if (j.approvalPath && report.files.approval.exists) {
|
|
1196
|
+
await archiveOutput(
|
|
1197
|
+
j.approvalPath,
|
|
1198
|
+
report.files.approvalStaging.sha256!,
|
|
1199
|
+
history,
|
|
1200
|
+
preserved,
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
// A rollback keeps the old success receipt as historical evidence, not an active transaction.
|
|
1204
|
+
}
|
|
1205
|
+
const expectedSource = action === "finish"
|
|
1206
|
+
? j.resultHash
|
|
1207
|
+
: report.files.backup.exists
|
|
1208
|
+
? report.files.backup.sha256
|
|
1209
|
+
: j.sourceHash;
|
|
1210
|
+
if ((await evidence(j.source)).sha256 !== expectedSource) {
|
|
1211
|
+
fail(
|
|
1212
|
+
"RECOVERY_SOURCE_CHANGED",
|
|
1213
|
+
"Source changed before recovery completion; journal remains.",
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
if (report.files.staging.exists) preserved.push(j.staging);
|
|
1217
|
+
if (report.files.receipt.exists || action === "finish") {
|
|
1218
|
+
preserved.push(j.receiptPath);
|
|
1219
|
+
}
|
|
1220
|
+
const archived = join(
|
|
1221
|
+
history,
|
|
1222
|
+
"sync-transaction-" + crypto.randomUUID() + ".json",
|
|
1223
|
+
);
|
|
1224
|
+
await Deno.rename(report.files.journal.path, archived);
|
|
1225
|
+
preserved.push(archived);
|
|
1226
|
+
if (Deno.build.os !== "windows") {
|
|
1227
|
+
for (const dir of [paths.directory, paths.stateDir, history]) {
|
|
1228
|
+
const f = await Deno.open(dir, { read: true });
|
|
1229
|
+
try {
|
|
1230
|
+
await f.sync();
|
|
1231
|
+
} finally {
|
|
1232
|
+
f.close();
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return {
|
|
1237
|
+
...report,
|
|
1238
|
+
action,
|
|
1239
|
+
status: action === "finish" ? "finished" : "rolled-back",
|
|
1240
|
+
available: ["inspect"],
|
|
1241
|
+
preserved,
|
|
1242
|
+
diagnostics: action === "rollback" && report.files.backup.exists &&
|
|
1243
|
+
report.files.backup.sha256 !== j.sourceHash
|
|
1244
|
+
? [
|
|
1245
|
+
"Concurrent source edit restored from backup; review it before synchronization.",
|
|
1246
|
+
]
|
|
1247
|
+
: [],
|
|
1248
|
+
};
|
|
1249
|
+
} finally {
|
|
1250
|
+
await releaseWrite();
|
|
1251
|
+
}
|
|
1252
|
+
} finally {
|
|
1253
|
+
await release();
|
|
1254
|
+
}
|
|
1255
|
+
}
|