@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.
- package/README.md +155 -19
- package/package.json +4 -2
- package/src/SKILL.md +56 -7
- package/src/atomic-write.js +53 -0
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +52 -130
- package/src/comment-target.js +87 -0
- package/src/comparison-view.js +337 -0
- package/src/document-execution.js +103 -0
- package/src/document-trust.js +2 -0
- package/src/edit-limits.js +23 -0
- package/src/execution-client.js +63 -0
- package/src/frame-policy.js +38 -0
- package/src/history-client.js +104 -0
- package/src/history-coordinator.js +186 -0
- package/src/history-policy.js +43 -0
- package/src/history-server.js +463 -0
- package/src/paths.js +1 -1
- package/src/poll-transport.js +222 -0
- package/src/review-mode.js +1 -1
- package/src/revision-diff.js +440 -0
- package/src/revision-schema.js +219 -0
- package/src/revision-store.js +177 -0
- package/src/sdk.js +419 -55
- package/src/semantic-snapshot.js +230 -0
- package/src/server.js +240 -53
- package/src/setup-guidance.js +128 -0
- package/src/setup.js +41 -15
- package/src/state.js +257 -35
- package/src/view-identity.js +99 -0
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
70
|
-
|
|
71
|
-
from MDX or Markdown, apply
|
|
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
|
-
|
|
100
|
-
|
|
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 =
|
|
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) => {
|