@erdemtuna/doc-review 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ export const GUIDANCE_BEGIN = "<!-- BEGIN doc-review -->";
2
+ export const GUIDANCE_END = "<!-- END doc-review -->";
3
+
4
+ // Historical generated text is deliberately independent of the current guidance.
5
+ const LEGACY_TAIL = `
6
+ \`npx -y @erdemtuna/doc-review <file.html>\`. For a locally running web page, open the real
7
+ route with \`npx -y @erdemtuna/doc-review http://localhost:3000/path\` instead of recreating
8
+ it as a static file. Then block on
9
+ \`npx -y @erdemtuna/doc-review poll <target> --timeout 600\` until they send feedback.
10
+ If it prints \`{"status":"timeout"}\`, no feedback arrived yet — run the same
11
+ poll command again to keep waiting. When a \`{"status":"feedback"}\` batch
12
+ arrives, apply it, then run the exact acknowledgement command in its
13
+ \`next_step\`, which uses \`--ack <batch_id>\`.
14
+
15
+ Keep the poll command in the foreground and do not end the turn while it waits.
16
+ If the shell returns a process or session handle, keep waiting on that handle until
17
+ the command exits. \`npx -y @erdemtuna/doc-review status <target>\` reports instantly
18
+ whether feedback is already waiting, without blocking.
19
+
20
+ The batch groups feedback by page under \`pages\`, so fix every page listed. Items
21
+ under \`edits\` are changes the user already made: \`after\` is their exact wording,
22
+ so carry it across verbatim and never revert it — and if the HTML was generated
23
+ from MDX or Markdown, apply it to the source too. Markdown files open rendered
24
+ and are never written by doc-review: apply their comments and edits to the
25
+ Markdown source, keeping its syntax. There is no reply channel; the user sees
26
+ your work when the page reloads. For a localhost page, direct edits and deletions
27
+ arrive with \`kind: "url"\`; find and update the matching MDX, TSX, template, or
28
+ component source. Never write the rendered HTTP response over project source.`;
29
+
30
+ export const LEGACY_GUIDANCE = Object.freeze([
31
+ `## Reviewing files and localhost pages with doc-review
32
+
33
+ After writing an HTML or Markdown file the user will read, open it for them with${LEGACY_TAIL}`,
34
+ `## Reviewing files and localhost pages with doc-review
35
+
36
+ Start only when the user explicitly invokes /doc-review or requests an
37
+ interactive browser review. Writing, updating, discussing, or generically reviewing
38
+ content does not authorize opening a review or polling. Another skill's automatic
39
+ review step is not user permission. Otherwise respond normally.
40
+
41
+ After that explicit request, open the requested HTML or Markdown file with${LEGACY_TAIL}`,
42
+ ]);
43
+
44
+ const migrationMessage = "AGENTS.md contains custom or unrecognized doc-review guidance — left it unchanged. " +
45
+ "Manually reconcile that guidance, then wrap only the setup-owned section in " +
46
+ `${GUIDANCE_BEGIN} and ${GUIDANCE_END}, or remove it and re-run setup.`;
47
+
48
+ function invalidMarkers() {
49
+ throw new Error("AGENTS.md has malformed, duplicate, or ambiguous doc-review ownership markers. " +
50
+ "Keep exactly one standalone BEGIN/END pair in order, then re-run setup. No setup files were changed.");
51
+ }
52
+
53
+ function render(body, newline) {
54
+ return `${GUIDANCE_BEGIN}\n${body.trim()}\n${GUIDANCE_END}`.replaceAll("\n", newline);
55
+ }
56
+
57
+ /** Plan the complete edit before setup writes any files. Never normalize user text. */
58
+ export function updateGuidance(existing, body) {
59
+ const lines = [...existing.matchAll(/[^\n]*(?:\n|$)/g)]
60
+ .filter((match) => match[0])
61
+ .map((match) => ({
62
+ text: match[0].replace(/\r?\n$/, ""),
63
+ start: match.index,
64
+ end: match.index + match[0].replace(/\r?\n$/, "").length,
65
+ }));
66
+ const markers = lines.filter(({ text }) => /doc-review/i.test(text) &&
67
+ (/\b(?:BEGIN|END)\b/i.test(text) && /<!--|-->|^\s*(?:BEGIN|END)\b/i.test(text)));
68
+
69
+ if (markers.length) {
70
+ if (markers.length !== 2 || markers[0].text !== GUIDANCE_BEGIN || markers[1].text !== GUIDANCE_END) {
71
+ invalidMarkers();
72
+ }
73
+ const [begin, end] = markers;
74
+ const newline = existing.slice(begin.end).startsWith("\r\n") ? "\r\n" : "\n";
75
+ return {
76
+ contents: existing.slice(0, begin.start) + render(body, newline) + existing.slice(end.end),
77
+ message: "Updated setup-owned AGENTS.md guidance (Codex)",
78
+ };
79
+ }
80
+
81
+ const candidates = [];
82
+ for (const legacy of LEGACY_GUIDANCE) {
83
+ for (const command of ["npx -y @erdemtuna/doc-review", "doc-review"]) {
84
+ for (const newline of ["\n", "\r\n"]) {
85
+ const text = legacy.replaceAll("npx -y @erdemtuna/doc-review", command).replaceAll("\n", newline);
86
+ let start = existing.indexOf(text);
87
+ while (start !== -1) {
88
+ const end = start + text.length;
89
+ const before = existing.slice(0, start);
90
+ const after = existing.slice(end);
91
+ // A complete generated section, not a substring of customized prose.
92
+ if ((start === 0 || before.endsWith("\n") || before === "\uFEFF") &&
93
+ /^(?:\r?\n|$)/.test(after) &&
94
+ /^(?:\s*$|(?:\r?\n)+(?=#{1,2} ))/.test(after)) {
95
+ candidates.push({ start, end, newline });
96
+ }
97
+ start = existing.indexOf(text, start + text.length);
98
+ }
99
+ }
100
+ }
101
+ }
102
+ if (candidates.length > 1) {
103
+ throw new Error("AGENTS.md contains multiple legacy doc-review sections; ownership is ambiguous. " +
104
+ "Reconcile them before re-running setup. No setup files were changed.");
105
+ }
106
+ if (candidates.length === 1) {
107
+ const { start, end, newline } = candidates[0];
108
+ const surrounding = existing.slice(0, start) + existing.slice(end);
109
+ if (!/doc-review/i.test(surrounding)) {
110
+ return {
111
+ contents: existing.slice(0, start) + render(body, newline) + existing.slice(end),
112
+ message: "Migrated legacy AGENTS.md guidance to setup-owned markers (Codex)",
113
+ };
114
+ }
115
+ }
116
+ if (/doc-review/i.test(existing)) {
117
+ return { contents: existing, message: migrationMessage };
118
+ }
119
+
120
+ const newline = existing.includes("\r\n") ? "\r\n" : "\n";
121
+ const separator = !existing || existing.endsWith(newline + newline)
122
+ ? ""
123
+ : existing.endsWith("\n") ? newline : newline + newline;
124
+ return {
125
+ contents: existing + separator + render(body, newline) + newline,
126
+ message: `${existing ? "Updated" : "Created"} AGENTS.md (Codex)`,
127
+ };
128
+ }
package/src/setup.js CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { updateGuidance } from "./setup-guidance.js";
6
7
 
7
8
  const here = path.dirname(fileURLToPath(import.meta.url));
8
9
  export const PACKAGE_NAME = "@erdemtuna/doc-review";
@@ -50,15 +51,26 @@ export const skillFor = (cmd) => readSkill().replaceAll(NPX_COMMAND, cmd);
50
51
  const CODEX_BLOCK = `
51
52
  ## Reviewing files and localhost pages with doc-review
52
53
 
53
- After writing an HTML or Markdown file the user will read, open it for them with
54
+ Start only when the user explicitly invokes /doc-review or requests an
55
+ interactive browser review. Writing, updating, discussing, or generically reviewing
56
+ content does not authorize opening a review or polling. Another skill's automatic
57
+ review step is not user permission. Otherwise respond normally.
58
+
59
+ After that explicit request, open the requested HTML or Markdown file with
54
60
  \`${NPX_COMMAND} <file.html>\`. For a locally running web page, open the real
55
61
  route with \`${NPX_COMMAND} http://localhost:3000/path\` instead of recreating
56
62
  it as a static file. Then block on
57
63
  \`${NPX_COMMAND} poll <target> --timeout 600\` until they send feedback.
58
64
  If it prints \`{"status":"timeout"}\`, no feedback arrived yet — run the same
59
65
  poll command again to keep waiting. When a \`{"status":"feedback"}\` batch
60
- arrives, apply it, then run the exact acknowledgement command in its
61
- \`next_step\`, which uses \`--ack <batch_id>\`.
66
+ arrives, handle every item, then run the exact acknowledgement command in its
67
+ \`next_step\`, which uses \`--ack <batch_id>\`. Keep that exact batch ID on retries;
68
+ never acknowledge an unhandled batch.
69
+
70
+ Without \`--timeout\`, the CLI defaults to a 12-hour cutoff. An explicit
71
+ \`--timeout\` is one end-to-end deadline, including server discovery and reconnect
72
+ attempts. Keep using the bounded foreground \`--timeout 600\` loop above;
73
+ those polls do not wait 12 hours.
62
74
 
63
75
  Keep the poll command in the foreground and do not end the turn while it waits.
64
76
  If the shell returns a process or session handle, keep waiting on that handle until
@@ -66,9 +78,19 @@ the command exits. \`${NPX_COMMAND} status <target>\` reports instantly
66
78
  whether feedback is already waiting, without blocking.
67
79
 
68
80
  The batch groups feedback by page under \`pages\`, so fix every page listed. Items
69
- under \`edits\` are changes the user already made: \`after\` is their exact wording,
70
- so carry it across verbatim and never revert it — and if the HTML was generated
71
- from MDX or Markdown, apply it to the source too. Markdown files open rendered
81
+ under \`edits\` are changes the user already made. For non-truncated edits,
82
+ \`after\` is their exact wording: carry it across verbatim and never revert it.
83
+ If the HTML was generated from MDX or Markdown, apply complete edits to the
84
+ source too.
85
+
86
+ Edit fields are limited to 200,000 Unicode code points each. An edit with
87
+ \`truncated: true\` identifies clipped fields in the \`truncated_fields\` array.
88
+ Never apply incomplete text or HTML as a complete replacement or invent missing
89
+ text. Recover the full edit only from an authoritative source; otherwise ask the
90
+ user for the complete edit. Do not acknowledge the batch until every item,
91
+ including truncated edits, has been handled.
92
+
93
+ Markdown files open rendered
72
94
  and are never written by doc-review: apply their comments and edits to the
73
95
  Markdown source, keeping its syntax. There is no reply channel; the user sees
74
96
  your work when the page reloads. For a localhost page, direct edits and deletions
@@ -79,6 +101,17 @@ component source. Never write the rendered HTTP response over project source.
79
101
  export function installSkills(cwd, { global: isGlobal = false, home = os.homedir(), command } = {}) {
80
102
  const done = [];
81
103
  const cmd = command || invocation();
104
+ const agents = path.join(cwd, "AGENTS.md");
105
+ let guidance;
106
+ let existing;
107
+ if (!isGlobal) {
108
+ const bytes = fs.existsSync(agents) ? fs.readFileSync(agents) : Buffer.alloc(0);
109
+ existing = bytes.toString("utf8");
110
+ if (!Buffer.from(existing, "utf8").equals(bytes)) {
111
+ throw new Error("AGENTS.md is not valid UTF-8; convert it before re-running setup. No setup files were changed.");
112
+ }
113
+ guidance = updateGuidance(existing, CODEX_BLOCK.replaceAll(NPX_COMMAND, cmd));
114
+ }
82
115
 
83
116
  const skillRoots = isGlobal
84
117
  ? [
@@ -96,15 +129,8 @@ export function installSkills(cwd, { global: isGlobal = false, home = os.homedir
96
129
  }
97
130
 
98
131
  if (!isGlobal) {
99
- const agents = path.join(cwd, "AGENTS.md");
100
- const existing = fs.existsSync(agents) ? fs.readFileSync(agents, "utf8") : "";
101
- if (existing.includes("doc-review")) {
102
- done.push("AGENTS.md already mentions doc-review — left it alone");
103
- } else {
104
- const block = CODEX_BLOCK.replaceAll(NPX_COMMAND, cmd);
105
- fs.writeFileSync(agents, existing ? `${existing.trimEnd()}\n${block}` : block.trimStart());
106
- done.push(`${existing ? "Updated" : "Created"} AGENTS.md (Codex)`);
107
- }
132
+ if (guidance.contents !== existing) fs.writeFileSync(agents, guidance.contents);
133
+ done.push(guidance.message);
108
134
  }
109
135
 
110
136
  done.push("", `Agents will be told to run: ${cmd}`);
package/src/state.js CHANGED
@@ -3,6 +3,11 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { normalizeCommentAnchor } from "./comment-anchor.js";
5
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";
6
11
 
7
12
  /** Anything untouched this long is review debris, not work in progress. */
8
13
  const PRUNE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -10,11 +15,50 @@ const DELIVERY_STATES = new Set(["queued", "possibly_delivered", "delivered"]);
10
15
 
11
16
  const fresh = (entry, now) => !!entry && now - (entry.updatedAt || 0) < PRUNE_AGE_MS;
12
17
  const batchId = () => `b_${crypto.randomBytes(12).toString("hex")}`;
13
- const emptyState = () => ({ pages: {}, batches: {}, receipts: {} });
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
+ }
14
53
 
15
54
  function pruneData(data, now = Date.now()) {
16
- let changed = false;
55
+ let changed = retainHistory(data);
17
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;
18
62
  const missingFile = page.kind !== "url" && !fs.existsSync(page.file);
19
63
  if (!fresh(page, now) || missingFile) {
20
64
  delete data.pages[key];
@@ -22,13 +66,8 @@ function pruneData(data, now = Date.now()) {
22
66
  changed = true;
23
67
  }
24
68
  }
25
- for (const [key, batch] of Object.entries(data.batches)) {
26
- if (!fresh(batch, now)) {
27
- delete data.batches[key];
28
- changed = true;
29
- }
30
- }
31
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;
32
71
  if (!fresh(receipt, now)) {
33
72
  delete data.receipts[id];
34
73
  changed = true;
@@ -45,8 +84,25 @@ function normalizeState(parsed, makeBatchId) {
45
84
  pages: parsed.pages,
46
85
  batches: parsed.batches && typeof parsed.batches === "object" ? parsed.batches : {},
47
86
  receipts: parsed.receipts && typeof parsed.receipts === "object" ? parsed.receipts : {},
87
+ histories: parsed.histories && typeof parsed.histories === "object" ? parsed.histories : {},
48
88
  };
49
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
+ }
50
106
  const normalizeAnchor = (comment) => {
51
107
  if (!comment || comment.anchor == null) return;
52
108
  const normalized = normalizeCommentAnchor(comment.kind === "element" ? "element" : "selection", comment.anchor);
@@ -92,24 +148,6 @@ function normalizeState(parsed, makeBatchId) {
92
148
  return { data, changed };
93
149
  }
94
150
 
95
- /**
96
- * Atomic write via a unique sibling tmp file. The name is unguessable and the
97
- * create is exclusive, so a pre-planted symlink can never redirect the write,
98
- * and a failed rename never leaves a predictable orphan behind.
99
- */
100
- export function atomicWrite(file, data) {
101
- const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.doc-review.tmp`;
102
- fs.writeFileSync(tmp, data, { flag: "wx" });
103
- try {
104
- fs.renameSync(tmp, file);
105
- } catch (err) {
106
- try {
107
- fs.unlinkSync(tmp);
108
- } catch {}
109
- throw err;
110
- }
111
- }
112
-
113
151
  /**
114
152
  * All durable state lives in one JSON file. No database, no network.
115
153
  *
@@ -125,10 +163,11 @@ export function atomicWrite(file, data) {
125
163
  * means "your feedback is safe" stays true across server restarts.
126
164
  */
127
165
  export class Store {
128
- constructor({ write = atomicWrite, makeBatchId = batchId } = {}) {
166
+ constructor({ write = atomicWrite, makeBatchId = batchId, revisions = new RevisionStore() } = {}) {
129
167
  this.data = emptyState();
130
168
  this.write = write;
131
169
  this.makeBatchId = makeBatchId;
170
+ this.revisions = revisions;
132
171
  this.load();
133
172
  }
134
173
 
@@ -343,6 +382,19 @@ export class Store {
343
382
  if (afterHtml !== undefined) row.after_html = afterHtml;
344
383
  // A re-move of the same block replaces its landing spot.
345
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
+ }
346
398
  if (extra.staged_assets) {
347
399
  const assets = [...(row.staged_assets || []), ...extra.staged_assets];
348
400
  extra = { ...extra, staged_assets: [...new Map(assets.map((asset) => [asset.path, asset])).values()] };
@@ -363,10 +415,10 @@ export class Store {
363
415
  }
364
416
 
365
417
  /** After the agent writes, its version becomes the new revert target. */
366
- setPristine(key, html) {
418
+ setPristine(key, html, { keepEdits = false } = {}) {
367
419
  return this.update(key, (page) => {
368
420
  page.pristine = html;
369
- page.edits = [];
421
+ if (!keepEdits) page.edits = [];
370
422
  });
371
423
  }
372
424
 
@@ -395,27 +447,61 @@ export class Store {
395
447
  return this.data.batches;
396
448
  }
397
449
 
398
- setBatch(entryKey, { batch, cleanup, deliveryState = "queued" }) {
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;
399
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
+ }
400
463
  return this.transaction((draft) => {
401
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
+ }
402
471
  if (existing && existing.delivery_state !== "queued") {
403
472
  draft.receipts[existing.batch_id] = {
404
473
  cleanup: existing.cleanup,
405
474
  delivery_state: existing.delivery_state,
475
+ batch: structuredClone(existing.batch),
406
476
  updatedAt: Date.now(),
407
477
  };
408
478
  }
409
479
  const id = batch.batch_id || this.makeBatchId();
410
- const storedBatch = { ...batch, batch_id: id };
480
+ const storedBatch = { ...structuredClone(batch), batch_id: id };
411
481
  const record = {
412
482
  batch_id: id,
413
483
  batch: storedBatch,
414
- cleanup,
484
+ cleanup: structuredClone(cleanup),
415
485
  delivery_state: deliveryState,
416
486
  updatedAt: Date.now(),
417
487
  };
418
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
+ }
419
505
  return record;
420
506
  });
421
507
  }
@@ -426,6 +512,12 @@ export class Store {
426
512
  const record = draft.batches[entryKey];
427
513
  record.delivery_state = "delivered";
428
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
+ }
429
521
  return record;
430
522
  });
431
523
  }
@@ -445,13 +537,26 @@ export class Store {
445
537
  draft.receipts[id] = {
446
538
  cleanup: record.cleanup,
447
539
  delivery_state: "acknowledged",
540
+ batch: structuredClone(record.batch),
448
541
  updatedAt: Date.now(),
449
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
+ }
450
555
  const staged = [];
451
- const keys = [];
556
+ const keys = round ? round.targets.map((target) => target.key) : [];
452
557
  for (const { key, ids, staged: assets = [], sentAt } of record.cleanup) {
453
558
  staged.push(...assets);
454
- keys.push(key);
559
+ if (!keys.includes(key)) keys.push(key);
455
560
  const page = draft.pages[key];
456
561
  if (!page) continue;
457
562
  const drop = new Set(ids);
@@ -462,10 +567,127 @@ export class Store {
462
567
  : [];
463
568
  page.updatedAt = Date.now();
464
569
  }
465
- return { acknowledged: true, staged, keys };
570
+ return { acknowledged: true, staged, keys, ...(round ? { roundId: round.roundId } : {}) };
466
571
  });
467
572
  }
468
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
+
469
691
  clearBatch(entryKey) {
470
692
  if (!this.batch(entryKey)) return null;
471
693
  return this.transaction((draft) => {