@erdemtuna/doc-review 0.9.0 → 0.11.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.
Files changed (103) hide show
  1. package/README.md +58 -222
  2. package/{src → lib}/SKILL.md +5 -2
  3. package/lib/anchor-text.js +155 -0
  4. package/lib/atomic-write.js +54 -0
  5. package/lib/changes-controller.js +184 -0
  6. package/lib/chrome-api.js +78 -0
  7. package/lib/chrome-client.js +1700 -0
  8. package/lib/chrome-session.js +73 -0
  9. package/lib/chrome.html +53 -0
  10. package/lib/cli.js +255 -0
  11. package/lib/click-target.js +28 -0
  12. package/lib/comment-anchor.js +23 -0
  13. package/lib/comment-target.js +164 -0
  14. package/lib/comments-controller.js +109 -0
  15. package/lib/contextual-controller.js +33 -0
  16. package/lib/contracts/feedback.js +1 -0
  17. package/lib/contracts/frame.js +7 -0
  18. package/lib/contracts/history.js +1 -0
  19. package/lib/contracts/index.js +2 -0
  20. package/lib/contracts/page.js +23 -0
  21. package/lib/controller-store.js +96 -0
  22. package/lib/document-execution.js +115 -0
  23. package/lib/edit-limits.js +24 -0
  24. package/lib/editing.js +107 -0
  25. package/lib/execution-client.js +19 -0
  26. package/lib/feedback-controller.js +113 -0
  27. package/lib/feedback-panel-controller.js +161 -0
  28. package/lib/frame-channel.js +42 -0
  29. package/lib/frame-controller.js +313 -0
  30. package/lib/frame-host.js +136 -0
  31. package/lib/frame-policy.js +50 -0
  32. package/lib/history-client.js +112 -0
  33. package/lib/history-coordinator.js +217 -0
  34. package/lib/history-policy.js +43 -0
  35. package/lib/history-server.js +464 -0
  36. package/lib/html-transform.js +52 -0
  37. package/lib/icons.js +325 -0
  38. package/{src → lib}/markdown.js +25 -26
  39. package/lib/paths.js +83 -0
  40. package/lib/poll-transport.js +220 -0
  41. package/lib/positioning.js +97 -0
  42. package/lib/recovery-controller.js +123 -0
  43. package/lib/review-controller.js +93 -0
  44. package/lib/review-mode.js +66 -0
  45. package/lib/revision-diff.js +482 -0
  46. package/lib/revision-schema.js +235 -0
  47. package/lib/revision-store.js +190 -0
  48. package/lib/save-controller.js +303 -0
  49. package/lib/sdk.js +2629 -0
  50. package/lib/semantic-snapshot.js +268 -0
  51. package/lib/serialize.js +32 -0
  52. package/lib/server-entry.js +26 -0
  53. package/lib/server-lock.js +109 -0
  54. package/lib/server.js +1501 -0
  55. package/lib/setup-guidance.js +119 -0
  56. package/{src → lib}/setup.js +44 -55
  57. package/lib/state.js +733 -0
  58. package/lib/toolbar-controller.js +32 -0
  59. package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
  60. package/lib/ui/chrome.css +4881 -0
  61. package/lib/ui/chrome.js +52 -0
  62. package/lib/view-identity.js +112 -0
  63. package/package.json +50 -14
  64. package/src/anchor-text.js +0 -160
  65. package/src/atomic-write.js +0 -53
  66. package/src/chrome-client.js +0 -2747
  67. package/src/chrome-session.js +0 -85
  68. package/src/chrome.css +0 -652
  69. package/src/chrome.html +0 -196
  70. package/src/cli.js +0 -271
  71. package/src/click-target.js +0 -26
  72. package/src/comment-anchor.js +0 -22
  73. package/src/comment-target.js +0 -164
  74. package/src/comparison-view.js +0 -337
  75. package/src/document-execution.js +0 -103
  76. package/src/edit-limits.js +0 -23
  77. package/src/editing.js +0 -99
  78. package/src/execution-client.js +0 -63
  79. package/src/frame-channel.js +0 -44
  80. package/src/frame-policy.js +0 -54
  81. package/src/history-client.js +0 -104
  82. package/src/history-coordinator.js +0 -186
  83. package/src/history-policy.js +0 -43
  84. package/src/history-server.js +0 -463
  85. package/src/html-transform.js +0 -62
  86. package/src/icons.js +0 -251
  87. package/src/paths.js +0 -91
  88. package/src/poll-transport.js +0 -222
  89. package/src/positioning.js +0 -107
  90. package/src/review-mode.js +0 -59
  91. package/src/revision-diff.js +0 -440
  92. package/src/revision-schema.js +0 -219
  93. package/src/revision-store.js +0 -177
  94. package/src/sdk.js +0 -2607
  95. package/src/semantic-snapshot.js +0 -230
  96. package/src/serialize.js +0 -32
  97. package/src/server-entry.js +0 -26
  98. package/src/server-lock.js +0 -101
  99. package/src/server.js +0 -1466
  100. package/src/setup-guidance.js +0 -128
  101. package/src/state.js +0 -732
  102. package/src/view-identity.js +0 -99
  103. /package/{src → lib}/document-trust.js +0 -0
package/src/state.js DELETED
@@ -1,732 +0,0 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { normalizeCommentAnchor } from "./comment-anchor.js";
5
- import { canonicalTarget, ensureStateDir, pageKey, realFile, statePath, targetKey } from "./paths.js";
6
- export { atomicWrite } from "./atomic-write.js";
7
- import { atomicWrite } from "./atomic-write.js";
8
- import { RevisionStore } from "./revision-store.js";
9
- import { CAPTURE_LEASE_MS, HISTORY_SCHEMA_VERSION, normalizeHistoryTargets, revisionError } from "./revision-schema.js";
10
- import { historyRevisionReferences, retainHistory } from "./history-policy.js";
11
-
12
- /** Anything untouched this long is review debris, not work in progress. */
13
- const PRUNE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
14
- const DELIVERY_STATES = new Set(["queued", "possibly_delivered", "delivered"]);
15
-
16
- const fresh = (entry, now) => !!entry && now - (entry.updatedAt || 0) < PRUNE_AGE_MS;
17
- const batchId = () => `b_${crypto.randomBytes(12).toString("hex")}`;
18
- const emptyState = () => ({ pages: {}, batches: {}, receipts: {}, histories: {} });
19
- const historyId = (prefix) => `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
20
-
21
- function historyRound(data, entryKey, roundId) {
22
- return data.histories[entryKey]?.rounds.find((round) => round.roundId === roundId) || null;
23
- }
24
-
25
- function batchRound(data, entryKey, id) {
26
- return data.histories[entryKey]?.rounds.find((round) => round.batchId === id) || null;
27
- }
28
-
29
- function publicRound(round) {
30
- if (!round) return null;
31
- const copy = structuredClone(round);
32
- copy.sentAt ||= new Date(copy.createdAt).toISOString();
33
- for (const target of copy.targets) {
34
- target.captureStatus = target.capture?.status || "pending";
35
- target.ownerSessionId = target.capture?.ownerSessionId || target.ownerSessionId || null;
36
- }
37
- return copy;
38
- }
39
-
40
- function finishCapture(round) {
41
- const statuses = round.targets.map((target) => target.capture?.status || "pending");
42
- if (statuses.every((status) => status === "ready" || status === "unavailable")) {
43
- const fullContent = round.targets.every((target) => target.capture.status === "ready" &&
44
- target.baselineCoverage?.semantic && target.resultCoverage?.semantic);
45
- const anyResult = statuses.some((status) => status === "ready") ||
46
- round.targets.some((target) => target.sourceResultRevisionId);
47
- round.captureStatus = fullContent ? "ready" : anyResult ? "partial" : "failed";
48
- round.completedAt ||= Date.now();
49
- } else {
50
- round.captureStatus = statuses.some((status) => status === "failed") ? "failed" : "pending";
51
- }
52
- }
53
-
54
- function pruneData(data, now = Date.now()) {
55
- let changed = retainHistory(data);
56
- for (const [key, page] of Object.entries(data.pages)) {
57
- const protectedPage = page.comments?.length || page.edits?.length || page.revisionRefs?.length ||
58
- data.batches[key] || Object.values(data.batches).some((record) => record.cleanup.some((item) => item.key === key)) ||
59
- Object.values(data.histories).some((history) => history.rounds.some((round) =>
60
- round.targets.some((target) => target.key === key)));
61
- if (protectedPage) continue;
62
- const missingFile = page.kind !== "url" && !fs.existsSync(page.file);
63
- if (!fresh(page, now) || missingFile) {
64
- delete data.pages[key];
65
- delete data.batches[key];
66
- changed = true;
67
- }
68
- }
69
- for (const [id, receipt] of Object.entries(data.receipts)) {
70
- if (Object.values(data.histories).some((history) => history.rounds.some((round) => round.batchId === id))) continue;
71
- if (!fresh(receipt, now)) {
72
- delete data.receipts[id];
73
- changed = true;
74
- }
75
- }
76
- return changed;
77
- }
78
-
79
- function normalizeState(parsed, makeBatchId) {
80
- if (!parsed || typeof parsed !== "object" || !parsed.pages || typeof parsed.pages !== "object") {
81
- throw new Error("Invalid doc-review state: expected a pages object.");
82
- }
83
- const data = {
84
- pages: parsed.pages,
85
- batches: parsed.batches && typeof parsed.batches === "object" ? parsed.batches : {},
86
- receipts: parsed.receipts && typeof parsed.receipts === "object" ? parsed.receipts : {},
87
- histories: parsed.histories && typeof parsed.histories === "object" ? parsed.histories : {},
88
- };
89
- let changed = !parsed.batches || !parsed.receipts;
90
- for (const history of Object.values(data.histories)) {
91
- if (history?.version !== HISTORY_SCHEMA_VERSION || !Array.isArray(history.rounds) ||
92
- !Number.isSafeInteger(history.nextOrdinal)) throw new Error("Invalid doc-review history.");
93
- for (const round of history.rounds) {
94
- if (!round?.roundId || !round.batchId || !Array.isArray(round.targets)) throw new Error("Invalid doc-review round.");
95
- for (const target of round.targets) {
96
- if (target.capture?.status === "running") {
97
- target.capture = {
98
- ...target.capture, captureId: historyId("cap"), status: "pending",
99
- ownerSessionId: null, generation: null, leaseExpiresAt: 0, error: "context_lost",
100
- };
101
- changed = true;
102
- }
103
- }
104
- }
105
- }
106
- const normalizeAnchor = (comment) => {
107
- if (!comment || comment.anchor == null) return;
108
- const normalized = normalizeCommentAnchor(comment.kind === "element" ? "element" : "selection", comment.anchor);
109
- if (JSON.stringify(normalized) !== JSON.stringify(comment.anchor)) {
110
- comment.anchor = normalized;
111
- changed = true;
112
- }
113
- };
114
- for (const page of Object.values(data.pages)) {
115
- for (const comment of page?.comments || []) normalizeAnchor(comment);
116
- }
117
- for (const record of Object.values(data.batches)) {
118
- if (!record || typeof record !== "object" || !record.batch || !Array.isArray(record.cleanup)) {
119
- throw new Error("Invalid doc-review state: malformed feedback batch.");
120
- }
121
- const existingId = record.batch_id || record.batch.batch_id;
122
- if (record.batch_id && record.batch.batch_id && record.batch_id !== record.batch.batch_id) {
123
- throw new Error("Invalid doc-review state: feedback batch IDs disagree.");
124
- }
125
- if (!existingId) {
126
- record.batch_id = makeBatchId();
127
- record.batch.batch_id = record.batch_id;
128
- record.delivery_state = "possibly_delivered";
129
- changed = true;
130
- } else {
131
- record.batch_id = existingId;
132
- if (record.batch.batch_id !== existingId) {
133
- record.batch.batch_id = existingId;
134
- changed = true;
135
- }
136
- if (!record.delivery_state) {
137
- record.delivery_state = "possibly_delivered";
138
- changed = true;
139
- }
140
- }
141
- if (!DELIVERY_STATES.has(record.delivery_state)) {
142
- throw new Error(`Invalid doc-review state: unknown delivery state ${record.delivery_state}.`);
143
- }
144
- for (const page of record.batch.pages || []) {
145
- for (const comment of page.comments || []) normalizeAnchor(comment);
146
- }
147
- }
148
- return { data, changed };
149
- }
150
-
151
- /**
152
- * All durable state lives in one JSON file. No database, no network.
153
- *
154
- * Shape:
155
- * {
156
- * pages: { <key>: { key, file, pristine, comments[], edits[], updatedAt } },
157
- * batches: { <entryKey>: { batch_id, batch, cleanup, delivery_state, updatedAt } },
158
- * receipts:{ <batchId>: { cleanup, delivery_state, updatedAt } },
159
- * }
160
- *
161
- * Pages are fully independent: no page ever references another. Batches are
162
- * feedback the user sent that no agent has acknowledged yet; persisting them
163
- * means "your feedback is safe" stays true across server restarts.
164
- */
165
- export class Store {
166
- constructor({ write = atomicWrite, makeBatchId = batchId, revisions = new RevisionStore() } = {}) {
167
- this.data = emptyState();
168
- this.write = write;
169
- this.makeBatchId = makeBatchId;
170
- this.revisions = revisions;
171
- this.load();
172
- }
173
-
174
- load() {
175
- let parsed;
176
- try {
177
- const raw = fs.readFileSync(statePath(), "utf8");
178
- parsed = JSON.parse(raw);
179
- } catch (err) {
180
- if (err.code === "ENOENT") return this.data;
181
- throw err;
182
- }
183
- const normalized = normalizeState(parsed, this.makeBatchId);
184
- this.data = normalized.data;
185
- const changed = pruneData(this.data);
186
- if (normalized.changed || changed) this.persist(this.data);
187
- return this.data;
188
- }
189
-
190
- persist(data) {
191
- ensureStateDir();
192
- this.write(statePath(), JSON.stringify(data, null, 2));
193
- }
194
-
195
- /** Replace durable state once, then publish the committed draft in memory. */
196
- transaction(mutator) {
197
- const draft = structuredClone(this.data);
198
- const result = mutator(draft);
199
- if (result && typeof result.then === "function") {
200
- throw new Error("Store.transaction mutators must be synchronous.");
201
- }
202
- pruneData(draft);
203
- this.persist(draft);
204
- this.data = draft;
205
- return result;
206
- }
207
-
208
- /** Persist deliberate direct changes used by maintenance and tests. */
209
- save() {
210
- const draft = structuredClone(this.data);
211
- pruneData(draft);
212
- this.persist(draft);
213
- this.data = draft;
214
- return this.data;
215
- }
216
-
217
- /** Register a file as a reviewable page, capturing the agent's version. */
218
- openPage(file, pristine) {
219
- const key = pageKey(file);
220
- return this.transaction((draft) => {
221
- const existing = draft.pages[key];
222
- const page = existing || {
223
- key,
224
- kind: "file",
225
- file: realFile(file),
226
- pristine: "",
227
- comments: [],
228
- edits: [],
229
- updatedAt: 0,
230
- };
231
- page.kind = "file";
232
- page.file = realFile(file);
233
- delete page.url;
234
- if (!existing || typeof pristine === "string") {
235
- page.pristine = typeof pristine === "string" ? pristine : page.pristine;
236
- }
237
- page.updatedAt = Date.now();
238
- draft.pages[key] = page;
239
- return page;
240
- });
241
- }
242
-
243
- /** Register a rendered localhost route. Browser edits are never written to it. */
244
- openUrl(url) {
245
- const target = canonicalTarget(url);
246
- if (target.kind !== "url") throw new Error("Expected a localhost URL.");
247
- const key = targetKey(target.value);
248
- return this.transaction((draft) => {
249
- const existing = draft.pages[key];
250
- const page = existing || {
251
- key,
252
- kind: "url",
253
- url: target.value,
254
- pristine: "",
255
- comments: [],
256
- edits: [],
257
- updatedAt: 0,
258
- };
259
- page.kind = "url";
260
- page.url = target.value;
261
- delete page.file;
262
- page.updatedAt = Date.now();
263
- draft.pages[key] = page;
264
- return page;
265
- });
266
- }
267
-
268
- page(key) {
269
- return this.data.pages[key] || null;
270
- }
271
-
272
- pageForFile(file) {
273
- return this.page(pageKey(file));
274
- }
275
-
276
- pageForTarget(target) {
277
- return this.page(targetKey(target));
278
- }
279
-
280
- update(key, mutate) {
281
- if (!this.page(key)) return null;
282
- return this.transaction((draft) => {
283
- const page = draft.pages[key];
284
- mutate(page);
285
- page.updatedAt = Date.now();
286
- return page;
287
- });
288
- }
289
-
290
- addComment(key, comment) {
291
- const anchor = comment.anchor == null
292
- ? comment.anchor
293
- : normalizeCommentAnchor(comment.kind === "element" ? "element" : "selection", comment.anchor);
294
- if (comment.anchor != null && !anchor) throw new Error("Invalid comment anchor.");
295
- return this.update(key, (page) => {
296
- page.comments.push({ ...comment, ...(comment.anchor !== undefined ? { anchor } : {}) });
297
- });
298
- }
299
-
300
- removeComment(key, id) {
301
- return this.update(key, (page) => {
302
- page.comments = page.comments.filter((c) => c.id !== id);
303
- });
304
- }
305
-
306
- /** Reword feedback, optionally turning a delivered instruction into a correction. */
307
- updateComment(key, id, feedback, { replacementId = "", correctionOf = "" } = {}) {
308
- let found = false;
309
- const page = this.update(key, (p) => {
310
- const index = p.comments.findIndex((c) => c.id === id);
311
- const comment = p.comments[index];
312
- if (comment) {
313
- const updated = {
314
- ...comment,
315
- ...(replacementId ? { id: replacementId } : {}),
316
- feedback,
317
- updatedAt: Date.now(),
318
- ...(correctionOf ? { correction: true, correctionOf } : {}),
319
- };
320
- p.comments[index] = updated;
321
- found = true;
322
- }
323
- });
324
- return found ? page : null;
325
- }
326
-
327
- /**
328
- * Reword a comment and every queued copy in one commit. Any evidence that
329
- * the old ID may have shipped turns the edit into a replacement correction.
330
- */
331
- reviseComment(key, id, feedback, { replacementId } = {}) {
332
- if (!this.page(key)?.comments.some((comment) => comment.id === id)) return null;
333
- return this.transaction((draft) => {
334
- const page = draft.pages[key];
335
- const index = page.comments.findIndex((comment) => comment.id === id);
336
- const existing = page.comments[index];
337
- const matching = (record) => record.cleanup.some((item) => item.key === key && item.ids.includes(id));
338
- const mayHaveShipped =
339
- Object.values(draft.batches).some((record) => record.delivery_state !== "queued" && matching(record)) ||
340
- Object.values(draft.receipts).some((record) => matching(record));
341
-
342
- if (mayHaveShipped) {
343
- page.comments[index] = {
344
- ...existing,
345
- id: replacementId || this.makeBatchId().replace(/^b_/, "c_"),
346
- feedback,
347
- updatedAt: Date.now(),
348
- correction: true,
349
- correctionOf: existing.feedback,
350
- };
351
- page.updatedAt = Date.now();
352
- return { delivery: "correction", page };
353
- }
354
-
355
- page.comments[index] = { ...existing, feedback, updatedAt: Date.now() };
356
- page.updatedAt = Date.now();
357
- let updatedPending = false;
358
- for (const record of Object.values(draft.batches)) {
359
- if (record.delivery_state !== "queued" || !matching(record)) continue;
360
- for (const batchPage of record.batch.pages || []) {
361
- const comment = (batchPage.comments || []).find((item) => item.id === id);
362
- if (comment) {
363
- comment.feedback = feedback;
364
- updatedPending = true;
365
- }
366
- }
367
- record.updatedAt = Date.now();
368
- }
369
- return { delivery: updatedPending ? "updated-pending" : "unsent", page };
370
- });
371
- }
372
-
373
- /**
374
- * Edits are deduped by label+kind so retyping one block stays one row, but
375
- * the text is refreshed every time so `after` is always the latest wording.
376
- */
377
- addEdit(key, label, kind, before, after, beforeHtml, afterHtml, extra) {
378
- return this.update(key, (page) => {
379
- const row = page.edits.find((e) => e.label === label && e.kind === kind);
380
- if (row) {
381
- if (after !== undefined) row.after = after;
382
- if (afterHtml !== undefined) row.after_html = afterHtml;
383
- // A re-move of the same block replaces its landing spot.
384
- if (extra) {
385
- if (Array.isArray(extra.truncated_fields)) {
386
- const replaced = new Set([
387
- ...(after !== undefined ? ["after"] : []),
388
- ...(afterHtml !== undefined ? ["after_html"] : []),
389
- ...["moved_after", "moved_before"].filter((field) => extra[field] !== undefined),
390
- ]);
391
- // Original before text is retained across edits, including its truncation.
392
- const truncatedFields = [...new Set([
393
- ...(row.truncated_fields || []).filter((field) => !replaced.has(field)),
394
- ...extra.truncated_fields.filter((field) => replaced.has(field)),
395
- ])];
396
- extra = { ...extra, truncated: truncatedFields.length > 0, truncated_fields: truncatedFields };
397
- }
398
- if (extra.staged_assets) {
399
- const assets = [...(row.staged_assets || []), ...extra.staged_assets];
400
- extra = { ...extra, staged_assets: [...new Map(assets.map((asset) => [asset.path, asset])).values()] };
401
- }
402
- Object.assign(row, extra);
403
- }
404
- row.updatedAt = Date.now();
405
- return;
406
- }
407
- page.edits.push({ label, kind, before, after, before_html: beforeHtml, after_html: afterHtml, ...(extra || {}), at: Date.now(), updatedAt: Date.now() });
408
- });
409
- }
410
-
411
- clearEdits(key) {
412
- return this.update(key, (page) => {
413
- page.edits = [];
414
- });
415
- }
416
-
417
- /** After the agent writes, its version becomes the new revert target. */
418
- setPristine(key, html, { keepEdits = false } = {}) {
419
- return this.update(key, (page) => {
420
- page.pristine = html;
421
- if (!keepEdits) page.edits = [];
422
- });
423
- }
424
-
425
- /**
426
- * Drop exactly what the acknowledged batch carried. Comments made after
427
- * Send have unknown ids; edits made (or retyped) after Send have a newer
428
- * timestamp than the batch. Both must survive for the next batch.
429
- */
430
- clearSent(key, ids, sentAt) {
431
- return this.update(key, (page) => {
432
- const drop = new Set(ids);
433
- page.comments = page.comments.filter((c) => !drop.has(c.id));
434
- // >= not >: an edit stamped the same millisecond as the send may not
435
- // have shipped — resending it is harmless, dropping it loses work.
436
- page.edits = typeof sentAt === "number" ? page.edits.filter((e) => (e.updatedAt || e.at || 0) >= sentAt) : [];
437
- });
438
- }
439
-
440
- // Sent-but-unacked feedback, keyed by the entry page the agent polls.
441
-
442
- batch(entryKey) {
443
- return this.data.batches[entryKey] || null;
444
- }
445
-
446
- allBatches() {
447
- return this.data.batches;
448
- }
449
-
450
- setBatch(entryKey, { batch, cleanup, deliveryState = "queued", history } = {}, options = {}) {
451
- const optionHistory = options.history || (options.targets ? options : undefined);
452
- if (history && optionHistory) throw revisionError("Specify history context only once.");
453
- history ||= optionHistory;
454
- if (!DELIVERY_STATES.has(deliveryState)) throw new Error(`Unknown delivery state: ${deliveryState}`);
455
- const targets = history ? normalizeHistoryTargets(history.targets) : null;
456
- for (const target of targets || []) {
457
- if (!this.page(target.key)) throw revisionError("Unknown history document.");
458
- if (target.baselineRevisionId) {
459
- const manifest = this.revisions.verify(target.baselineRevisionId, target.key);
460
- target.baselineCoverage = { source: !!manifest.source, semantic: !!manifest.semantic };
461
- }
462
- }
463
- return this.transaction((draft) => {
464
- const existing = draft.batches[entryKey];
465
- const superseded = existing ? batchRound(draft, entryKey, existing.batch_id) : null;
466
- if (superseded) {
467
- superseded.feedbackStatus = "superseded";
468
- superseded.supersededAt = Date.now();
469
- superseded.captureStatus = "cancelled";
470
- }
471
- if (existing && existing.delivery_state !== "queued") {
472
- draft.receipts[existing.batch_id] = {
473
- cleanup: existing.cleanup,
474
- delivery_state: existing.delivery_state,
475
- batch: structuredClone(existing.batch),
476
- updatedAt: Date.now(),
477
- };
478
- }
479
- const id = batch.batch_id || this.makeBatchId();
480
- const storedBatch = { ...structuredClone(batch), batch_id: id };
481
- const record = {
482
- batch_id: id,
483
- batch: storedBatch,
484
- cleanup: structuredClone(cleanup),
485
- delivery_state: deliveryState,
486
- updatedAt: Date.now(),
487
- };
488
- draft.batches[entryKey] = record;
489
- if (targets) {
490
- const historyState = draft.histories[entryKey] ||= {
491
- version: HISTORY_SCHEMA_VERSION, entryKey, nextOrdinal: 1, rounds: [],
492
- };
493
- const round = {
494
- roundId: historyId("round"), entryKey, ordinal: historyState.nextOrdinal++,
495
- batchId: id, createdAt: Date.now(),
496
- sentAt: new Date().toISOString(),
497
- feedbackStatus: deliveryState === "delivered" ? "delivered" : "queued",
498
- captureStatus: "pending",
499
- targets: targets.map((target) => ({ ...target, resultRevisionId: null, capture: null })),
500
- };
501
- if (deliveryState === "delivered") round.deliveredFeedback = structuredClone(storedBatch);
502
- historyState.rounds.push(round);
503
- record.round_id = round.roundId;
504
- }
505
- return record;
506
- });
507
- }
508
-
509
- markBatchDelivered(entryKey) {
510
- if (!this.batch(entryKey)) return null;
511
- return this.transaction((draft) => {
512
- const record = draft.batches[entryKey];
513
- record.delivery_state = "delivered";
514
- record.updatedAt = Date.now();
515
- const round = batchRound(draft, entryKey, record.batch_id);
516
- if (round) {
517
- round.feedbackStatus = "delivered";
518
- round.deliveredAt ||= Date.now();
519
- round.deliveredFeedback ||= structuredClone(record.batch);
520
- }
521
- return record;
522
- });
523
- }
524
-
525
- /**
526
- * Clear exactly one delivered receipt and its shipped page contents.
527
- * Stale, duplicate, queued, and legacy possibly-delivered IDs are no-ops.
528
- */
529
- acknowledgeBatch(entryKey, id) {
530
- const current = this.batch(entryKey);
531
- if (!current || current.batch_id !== id || current.delivery_state !== "delivered") {
532
- return { acknowledged: false, staged: [], keys: [] };
533
- }
534
- return this.transaction((draft) => {
535
- const record = draft.batches[entryKey];
536
- delete draft.batches[entryKey];
537
- draft.receipts[id] = {
538
- cleanup: record.cleanup,
539
- delivery_state: "acknowledged",
540
- batch: structuredClone(record.batch),
541
- updatedAt: Date.now(),
542
- };
543
- const round = batchRound(draft, entryKey, id);
544
- if (round) {
545
- round.feedbackStatus = "acknowledged";
546
- round.acknowledgedAt = Date.now();
547
- round.deliveredFeedback ||= structuredClone(record.batch);
548
- for (const target of round.targets) {
549
- target.capture = {
550
- captureId: historyId("cap"), status: "pending", ownerSessionId: null,
551
- generation: null, attempt: 0, leaseExpiresAt: 0,
552
- };
553
- }
554
- }
555
- const staged = [];
556
- const keys = round ? round.targets.map((target) => target.key) : [];
557
- for (const { key, ids, staged: assets = [], sentAt } of record.cleanup) {
558
- staged.push(...assets);
559
- if (!keys.includes(key)) keys.push(key);
560
- const page = draft.pages[key];
561
- if (!page) continue;
562
- const drop = new Set(ids);
563
- page.comments = page.comments.filter((comment) => !drop.has(comment.id));
564
- page.edits =
565
- typeof sentAt === "number"
566
- ? page.edits.filter((edit) => (edit.updatedAt || edit.at || 0) >= sentAt)
567
- : [];
568
- page.updatedAt = Date.now();
569
- }
570
- return { acknowledged: true, staged, keys, ...(round ? { roundId: round.roundId } : {}) };
571
- });
572
- }
573
-
574
- listHistory(entryKey) {
575
- return (this.data.histories[entryKey]?.rounds || []).map(publicRound).sort((a, b) => b.ordinal - a.ordinal);
576
- }
577
-
578
- getRound(entryKey, roundId) {
579
- return publicRound(historyRound(this.data, entryKey, roundId));
580
- }
581
-
582
- recordSourceResult(entryKey, roundId, key, { revisionId, unavailable } = {}) {
583
- if (revisionId && unavailable) throw revisionError("Source result availability is ambiguous.");
584
- if (revisionId) {
585
- const manifest = this.revisions.verify(revisionId, key);
586
- if (!manifest.source) throw revisionError("Source result must contain a source snapshot.");
587
- } else if (typeof unavailable !== "string" || !unavailable || unavailable.length > 500) {
588
- throw revisionError("Source result needs a revision or unavailable reason.");
589
- }
590
- const existing = historyRound(this.data, entryKey, roundId);
591
- const existingTarget = existing?.targets.find((target) => target.key === key);
592
- if (existing?.feedbackStatus !== "acknowledged" || !existingTarget) throw revisionError("Source capture is not pending.");
593
- if (existingTarget.sourceResultRevisionId) {
594
- if (existingTarget.sourceResultRevisionId === revisionId) return { accepted: false, duplicate: true, round: publicRound(existing) };
595
- throw revisionError("The source result is already frozen.", "CAPTURE_FINALIZED");
596
- }
597
- if (existing.completedAt || existingTarget.resultRevisionId || existingTarget.capture?.status === "unavailable") {
598
- throw revisionError("The target capture is already finalized.", "CAPTURE_FINALIZED");
599
- }
600
- const result = this.transaction((draft) => {
601
- const round = historyRound(draft, entryKey, roundId);
602
- const target = round.targets.find((item) => item.key === key);
603
- if (revisionId) {
604
- target.sourceResultRevisionId = revisionId;
605
- delete target.sourceResultUnavailable;
606
- } else target.sourceResultUnavailable = unavailable;
607
- return round;
608
- });
609
- return { accepted: true, round: publicRound(result) };
610
- }
611
-
612
- claimCapture(entryKey, roundId, key, { ownerSessionId, generation, leaseMs = CAPTURE_LEASE_MS } = {}) {
613
- if (typeof ownerSessionId !== "string" || !ownerSessionId || ownerSessionId.length > 200 ||
614
- !Number.isSafeInteger(generation) || generation < 0 ||
615
- !Number.isSafeInteger(leaseMs) || leaseMs <= 0 || leaseMs > 5 * CAPTURE_LEASE_MS) {
616
- throw revisionError("Invalid capture ownership.");
617
- }
618
- const result = this.transaction((draft) => {
619
- const round = historyRound(draft, entryKey, roundId);
620
- const target = round?.targets.find((item) => item.key === key);
621
- if (round?.feedbackStatus !== "acknowledged" || !target?.capture) throw revisionError("Capture is not pending.");
622
- if (target.capture.status === "ready" || target.capture.status === "unavailable") throw revisionError("Capture is already finalized.", "CAPTURE_FINALIZED");
623
- if (target.capture.status === "running" && target.capture.leaseExpiresAt > Date.now()) {
624
- if (target.capture.ownerSessionId === ownerSessionId && target.capture.generation === generation) return target.capture;
625
- throw revisionError("Another frame owns this capture.", "CAPTURE_CONFLICT");
626
- }
627
- target.capture = {
628
- captureId: historyId("cap"), status: "running", ownerSessionId, generation,
629
- attempt: target.capture.attempt + 1, leaseExpiresAt: Date.now() + leaseMs,
630
- };
631
- round.captureStatus = "pending";
632
- return target.capture;
633
- });
634
- return structuredClone(result);
635
- }
636
-
637
- recordCaptureResult(entryKey, roundId, key, { captureId, ownerSessionId, generation, revisionId } = {}) {
638
- const manifest = this.revisions.verify(revisionId, key);
639
- const result = this.transaction((draft) => {
640
- const round = historyRound(draft, entryKey, roundId);
641
- const target = round?.targets.find((item) => item.key === key);
642
- const capture = target?.capture;
643
- if (capture?.status === "ready" && capture.captureId === captureId &&
644
- capture.ownerSessionId === ownerSessionId && capture.generation === generation &&
645
- target.resultRevisionId === revisionId) return { accepted: false, duplicate: true, round };
646
- this.assertCapture(round, capture, { captureId, ownerSessionId, generation });
647
- target.resultRevisionId = revisionId;
648
- target.resultCoverage = { source: !!manifest.source, semantic: !!manifest.semantic };
649
- capture.status = "ready";
650
- capture.capturedAt = Date.now();
651
- capture.leaseExpiresAt = 0;
652
- finishCapture(round);
653
- return { accepted: true, round };
654
- });
655
- return structuredClone(result);
656
- }
657
-
658
- markCaptureUnavailable(entryKey, roundId, key, { captureId, ownerSessionId, generation, reason, final = false } = {}) {
659
- if (typeof reason !== "string" || !reason || reason.length > 500) throw revisionError("Invalid capture failure reason.");
660
- const result = this.transaction((draft) => {
661
- const round = historyRound(draft, entryKey, roundId);
662
- const target = round?.targets.find((item) => item.key === key);
663
- const capture = target?.capture;
664
- const unowned = round?.feedbackStatus === "acknowledged" &&
665
- (capture?.status === "pending" || capture?.status === "failed") &&
666
- !capture.ownerSessionId && capture.captureId === captureId &&
667
- !ownerSessionId && generation == null;
668
- if (!unowned) this.assertCapture(round, capture, { captureId, ownerSessionId, generation });
669
- capture.status = final ? "unavailable" : "failed";
670
- capture.error = reason;
671
- capture.leaseExpiresAt = 0;
672
- if (final) target.resultUnavailable = reason;
673
- finishCapture(round);
674
- return { accepted: true, round };
675
- });
676
- return structuredClone(result);
677
- }
678
-
679
- assertCapture(round, capture, { captureId, ownerSessionId, generation }) {
680
- if (round?.feedbackStatus !== "acknowledged" || capture?.status !== "running" ||
681
- capture.captureId !== captureId || capture.ownerSessionId !== ownerSessionId ||
682
- capture.generation !== generation || capture.leaseExpiresAt <= Date.now()) {
683
- throw revisionError("Capture response is stale or belongs to another frame.", "CAPTURE_CONFLICT");
684
- }
685
- }
686
-
687
- collectHistoryGarbage(options) {
688
- return this.revisions.collectGarbage(historyRevisionReferences(this.data), options);
689
- }
690
-
691
- clearBatch(entryKey) {
692
- if (!this.batch(entryKey)) return null;
693
- return this.transaction((draft) => {
694
- const record = draft.batches[entryKey];
695
- delete draft.batches[entryKey];
696
- return record;
697
- });
698
- }
699
- }
700
-
701
- /** Resolve a sibling asset request without escaping the artifact's directory. */
702
- export function resolveAsset(pageFile, relative) {
703
- let decoded;
704
- try {
705
- decoded = decodeURIComponent(relative);
706
- } catch {
707
- return null;
708
- }
709
- const base = path.dirname(pageFile);
710
- const target = path.resolve(base, decoded);
711
- const contained = (candidate, root) => {
712
- const rel = path.relative(root, candidate);
713
- return !rel.startsWith("..") && !path.isAbsolute(rel);
714
- };
715
- if (!contained(target, base)) return null;
716
- // The lexical check alone would follow a symlink out of the directory, so
717
- // the resolved filesystem path must land inside it too.
718
- let real;
719
- try {
720
- real = fs.realpathSync(target);
721
- } catch {
722
- // Nothing readable at that path — anything a symlink could point to would
723
- // have resolved. The caller's read fails with a plain 404.
724
- return target;
725
- }
726
- let realBase = base;
727
- try {
728
- realBase = fs.realpathSync(base);
729
- } catch {}
730
- if (!contained(real, realBase)) return null;
731
- return real;
732
- }