@erdemtuna/doc-review 0.8.1 → 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 +112 -22
- package/package.json +4 -2
- package/src/SKILL.md +28 -4
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +4 -0
- 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/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/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 +217 -49
- package/src/state.js +240 -15
- package/src/view-identity.js +99 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { defaultHistoryRound } from "./history-client.js";
|
|
2
|
+
|
|
3
|
+
const idOf = (round) => round?.roundId || round?.id;
|
|
4
|
+
const retryDelays = [1000, 2000, 4000];
|
|
5
|
+
|
|
6
|
+
export function transientCaptureError(error) {
|
|
7
|
+
return ["CAPTURE_TIMEOUT", "CAPTURE_NOT_READY", "CAPTURE_UNSTABLE", "CAPTURE_BUSY", "VIEW_CHANGED"].includes(error?.code) ||
|
|
8
|
+
[408, 429, 502, 503, 504].includes(error?.status) ||
|
|
9
|
+
(error instanceof TypeError && /fetch|network|load failed/i.test(error.message));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function requireCaptureSuccess(result, key) {
|
|
13
|
+
if (result?.ok === true) return result;
|
|
14
|
+
throw Object.assign(new Error(result?.round?.targets?.find((target) => target.key === key)?.capture?.error ||
|
|
15
|
+
"Content capture is unavailable"), { code: "CAPTURE_UNAVAILABLE" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One capture at a time; delayed/finished attempts never block other rounds. */
|
|
19
|
+
export function createCaptureCoordinator({ candidates, ready, capture, now = Date.now,
|
|
20
|
+
setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
21
|
+
const attempts = new Map();
|
|
22
|
+
let timer = null;
|
|
23
|
+
let active = null;
|
|
24
|
+
let epoch = 0;
|
|
25
|
+
let stopped = false;
|
|
26
|
+
function tick() {
|
|
27
|
+
clearTimer(timer);
|
|
28
|
+
timer = null;
|
|
29
|
+
if (stopped || active || !ready()) return;
|
|
30
|
+
const jobs = candidates();
|
|
31
|
+
const job = jobs.find(({ id }) => {
|
|
32
|
+
const attempt = attempts.get(id);
|
|
33
|
+
return !attempt || (!attempt.done && attempt.due <= now());
|
|
34
|
+
});
|
|
35
|
+
if (!job) {
|
|
36
|
+
const due = jobs.map(({ id }) => attempts.get(id)).filter((entry) => entry && !entry.done)
|
|
37
|
+
.reduce((next, entry) => Math.min(next, entry.due), Infinity);
|
|
38
|
+
if (Number.isFinite(due)) timer = setTimer(tick, Math.max(0, due - now()));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const attempt = attempts.get(job.id) || { count: 0, due: 0, done: false };
|
|
42
|
+
attempts.set(job.id, attempt);
|
|
43
|
+
attempt.count++;
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const started = epoch;
|
|
46
|
+
active = controller;
|
|
47
|
+
Promise.resolve().then(() => {
|
|
48
|
+
if (controller.signal.aborted) return;
|
|
49
|
+
return capture(job, controller.signal);
|
|
50
|
+
}).then(() => { attempt.done = true; }, (error) => {
|
|
51
|
+
if (transientCaptureError(error) && attempt.count <= retryDelays.length) {
|
|
52
|
+
attempt.due = now() + retryDelays[attempt.count - 1];
|
|
53
|
+
} else attempt.done = true;
|
|
54
|
+
}).finally(() => {
|
|
55
|
+
if (started !== epoch) return;
|
|
56
|
+
active = null;
|
|
57
|
+
tick();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
tick,
|
|
62
|
+
reset() {
|
|
63
|
+
epoch++;
|
|
64
|
+
active?.abort();
|
|
65
|
+
active = null;
|
|
66
|
+
clearTimer(timer);
|
|
67
|
+
timer = null;
|
|
68
|
+
attempts.clear();
|
|
69
|
+
},
|
|
70
|
+
stop() {
|
|
71
|
+
this.reset();
|
|
72
|
+
stopped = true;
|
|
73
|
+
},
|
|
74
|
+
get busy() { return !!active; },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function historyPresentation({ loading, error, round, target, comparison = {}, failure }) {
|
|
79
|
+
const modes = ["content", "source"].filter((mode) => comparison[mode]?.available === true);
|
|
80
|
+
if (loading && !round) return { state: "loading", message: "Loading comparison…", modes };
|
|
81
|
+
if (error && !round) return { state: "failed", message: "History could not be loaded. Try again.", modes };
|
|
82
|
+
if (!round) return { state: "empty", message: "No review rounds yet. Send feedback to start.", modes };
|
|
83
|
+
if (modes.length) {
|
|
84
|
+
const partial = modes.length < 2 || !!target?.baselineUnavailable || !!target?.resultUnavailable;
|
|
85
|
+
return { state: partial ? "partial" : "available", modes,
|
|
86
|
+
message: modes.includes("content")
|
|
87
|
+
? partial ? "Content available; some comparison coverage is missing." : "Comparison ready."
|
|
88
|
+
: "Source available. Content is pending or unavailable." };
|
|
89
|
+
}
|
|
90
|
+
if (failure || error || target?.capture?.status === "failed") {
|
|
91
|
+
return { state: "failed", message: "Content capture failed. Feedback is safe; retry when ready.", modes };
|
|
92
|
+
}
|
|
93
|
+
if (target?.capture?.status === "unavailable" || target?.resultRevisionId) {
|
|
94
|
+
return { state: "partial", message: "Comparison unavailable for these snapshots. Feedback is safe.", modes };
|
|
95
|
+
}
|
|
96
|
+
return { state: "waiting", message: round.feedbackStatus === "acknowledged"
|
|
97
|
+
? "Waiting for a stable result snapshot. You can keep reviewing."
|
|
98
|
+
: "Waiting for the agent's result.", modes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fetch into local values, then publish atomically so selectors never relabel stale content. */
|
|
102
|
+
export function createHistoryController({ request, changed = () => {}, refreshed = () => {} }) {
|
|
103
|
+
const state = {
|
|
104
|
+
rounds: [], selectedId: null, round: null, targetKey: null, mode: "content", preferredMode: null,
|
|
105
|
+
index: 0, loading: false, error: null, captureBusy: false, finalizing: false, failures: new Map(),
|
|
106
|
+
};
|
|
107
|
+
let revision = 0;
|
|
108
|
+
const targets = new Map();
|
|
109
|
+
async function refresh() {
|
|
110
|
+
const current = ++revision;
|
|
111
|
+
state.loading = true;
|
|
112
|
+
state.error = null;
|
|
113
|
+
if (state.round && (idOf(state.round) !== state.selectedId ||
|
|
114
|
+
!state.round.targets?.some((target) => target.key === state.targetKey && target.comparison))) state.round = null;
|
|
115
|
+
changed();
|
|
116
|
+
try {
|
|
117
|
+
const collection = await request("");
|
|
118
|
+
if (current !== revision) return false;
|
|
119
|
+
state.rounds = collection.rounds || [];
|
|
120
|
+
if (!state.rounds.some((round) => idOf(round) === state.selectedId)) {
|
|
121
|
+
state.selectedId = idOf(defaultHistoryRound(state.rounds)) || null;
|
|
122
|
+
}
|
|
123
|
+
const selectedId = state.selectedId;
|
|
124
|
+
let round = null;
|
|
125
|
+
let targetKey = state.targetKey || targets.get(selectedId);
|
|
126
|
+
if (selectedId) {
|
|
127
|
+
const detail = await request(`/${selectedId}`);
|
|
128
|
+
if (current !== revision) return false;
|
|
129
|
+
round = detail.round || detail;
|
|
130
|
+
const summary = state.rounds.find((item) => idOf(item) === selectedId);
|
|
131
|
+
for (const target of round.targets || []) {
|
|
132
|
+
target.filename ||= summary?.targets?.find((item) => item.key === target.key)?.filename;
|
|
133
|
+
}
|
|
134
|
+
const target = round.targets?.find((item) => item.key === targetKey) || round.targets?.[0];
|
|
135
|
+
targetKey = target?.key || null;
|
|
136
|
+
if (target) {
|
|
137
|
+
const results = await Promise.allSettled(["content", "source"].map((mode) =>
|
|
138
|
+
request(`/${selectedId}/compare?key=${encodeURIComponent(target.key)}&mode=${mode}`)));
|
|
139
|
+
if (current !== revision) return false;
|
|
140
|
+
target.comparison = Object.fromEntries(["content", "source"].map((mode, index) =>
|
|
141
|
+
[mode, results[index].status === "fulfilled" ? results[index].value
|
|
142
|
+
: { available: false, counts: null, reason: results[index].reason.message }]));
|
|
143
|
+
if (results.every((result) => result.status === "rejected")) state.error = results[0].reason;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
state.round = round;
|
|
147
|
+
state.targetKey = targetKey;
|
|
148
|
+
if (selectedId) targets.set(selectedId, targetKey);
|
|
149
|
+
state.loading = false;
|
|
150
|
+
changed();
|
|
151
|
+
refreshed();
|
|
152
|
+
return true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (current !== revision) return false;
|
|
155
|
+
state.error = error;
|
|
156
|
+
state.loading = false;
|
|
157
|
+
changed();
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
state, refresh,
|
|
163
|
+
selectRound(id) {
|
|
164
|
+
state.selectedId = id;
|
|
165
|
+
state.targetKey = targets.get(id) || null;
|
|
166
|
+
state.index = 0;
|
|
167
|
+
return refresh();
|
|
168
|
+
},
|
|
169
|
+
selectTarget(key) { state.targetKey = key; state.index = 0; return refresh(); },
|
|
170
|
+
cancel() { revision++; state.loading = false; },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Optional capture cannot veto delivery; a failed refresh cannot masquerade as failed Send. */
|
|
175
|
+
export async function deliverFeedback({ save, capture, deliver, committed, refresh }) {
|
|
176
|
+
await save();
|
|
177
|
+
let snapshot = null;
|
|
178
|
+
let captureFailure = null;
|
|
179
|
+
try { snapshot = await capture(); } catch (error) { captureFailure = error; }
|
|
180
|
+
await deliver(snapshot);
|
|
181
|
+
committed();
|
|
182
|
+
let refreshFailure = null;
|
|
183
|
+
try { if (await refresh() === false) refreshFailure = new Error("History could not be refreshed"); }
|
|
184
|
+
catch (error) { refreshFailure = error; }
|
|
185
|
+
return { captureFailure, refreshFailure };
|
|
186
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { COMPLETED_ROUNDS_TO_KEEP, isRevisionId } from "./revision-schema.js";
|
|
2
|
+
|
|
3
|
+
function references(value, out = new Set()) {
|
|
4
|
+
if (isRevisionId(value)) out.add(value);
|
|
5
|
+
else if (Array.isArray(value)) for (const item of value) references(item, out);
|
|
6
|
+
else if (value && typeof value === "object") for (const item of Object.values(value)) references(item, out);
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function unsentRevisionReferences(data) {
|
|
11
|
+
const out = new Set();
|
|
12
|
+
for (const page of Object.values(data.pages || {})) {
|
|
13
|
+
references(page.comments, out);
|
|
14
|
+
references(page.edits, out);
|
|
15
|
+
references(page.revisionRefs, out);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function historyRevisionReferences(data) {
|
|
21
|
+
const out = unsentRevisionReferences(data);
|
|
22
|
+
references(data.histories, out);
|
|
23
|
+
references(data.batches, out);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function retainHistory(data) {
|
|
28
|
+
const pinned = unsentRevisionReferences(data);
|
|
29
|
+
let changed = false;
|
|
30
|
+
for (const history of Object.values(data.histories || {})) {
|
|
31
|
+
const completed = history.rounds
|
|
32
|
+
.filter((round) => round.completedAt)
|
|
33
|
+
.sort((a, b) => b.completedAt - a.completedAt || b.ordinal - a.ordinal);
|
|
34
|
+
const keep = new Set(completed.slice(0, COMPLETED_ROUNDS_TO_KEEP).map((round) => round.roundId));
|
|
35
|
+
const rounds = history.rounds.filter((round) =>
|
|
36
|
+
!round.completedAt || keep.has(round.roundId) || [...references(round.targets)].some((id) => pinned.has(id)));
|
|
37
|
+
if (rounds.length !== history.rounds.length) {
|
|
38
|
+
history.rounds = rounds;
|
|
39
|
+
changed = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return changed;
|
|
43
|
+
}
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import { stripSdk } from "./html-transform.js";
|
|
5
|
+
import { isMarkdown } from "./markdown.js";
|
|
6
|
+
import { compareSemanticSnapshots, compareSources, DIFF_VERSION } from "./revision-diff.js";
|
|
7
|
+
import { REVISION_LIMITS } from "./revision-schema.js";
|
|
8
|
+
import { normalizeView, compareCapturedViews } from "./view-identity.js";
|
|
9
|
+
|
|
10
|
+
const SOURCE_LIMIT = REVISION_LIMITS.sourceBytes;
|
|
11
|
+
const hash = (value) => crypto.createHash("sha1").update(value).digest("hex");
|
|
12
|
+
|
|
13
|
+
export class HistoryRequestError extends Error {
|
|
14
|
+
constructor(message, { status = 400, code = "history_invalid_request", targets = [] } = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.targets = targets;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function historyErrorStatus(error) {
|
|
23
|
+
if (Number.isInteger(error.status) && error.status >= 400 && error.status < 600) return error.status;
|
|
24
|
+
return {
|
|
25
|
+
INVALID_REVISION: 400, SNAPSHOT_TOO_LARGE: 413, CAPTURE_CONFLICT: 409, CAPTURE_FINALIZED: 409,
|
|
26
|
+
}[error.code] ?? 500;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sourceFor(page) {
|
|
30
|
+
if (!page) {
|
|
31
|
+
throw new HistoryRequestError("The reviewed target is no longer available.", {
|
|
32
|
+
status: 409, code: "history_target_unavailable",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (page.kind === "url") return null;
|
|
36
|
+
let stat;
|
|
37
|
+
let content;
|
|
38
|
+
try {
|
|
39
|
+
stat = fs.statSync(page.file);
|
|
40
|
+
if (stat.size <= SOURCE_LIMIT) content = stripSdk(fs.readFileSync(page.file, "utf8"));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!["ENOENT", "EACCES", "EPERM"].includes(error.code)) throw error;
|
|
43
|
+
throw new HistoryRequestError("The reviewed source is missing or cannot be read.", {
|
|
44
|
+
status: 409, code: "history_source_unavailable",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (stat.size > SOURCE_LIMIT) {
|
|
48
|
+
throw new HistoryRequestError(`The source exceeds the ${SOURCE_LIMIT / (1024 * 1024)} MiB history capture limit.`, {
|
|
49
|
+
status: 413, code: "history_source_too_large",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (Buffer.byteLength(content) > SOURCE_LIMIT) {
|
|
53
|
+
throw new HistoryRequestError(`The source exceeds the ${SOURCE_LIMIT / (1024 * 1024)} MiB history capture limit.`, {
|
|
54
|
+
status: 413, code: "history_source_too_large",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
content,
|
|
59
|
+
hash: hash(content),
|
|
60
|
+
mediaType: isMarkdown(page.file) ? "text/markdown" : "text/html",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createHistoryController({
|
|
65
|
+
store, sessions, currentRender, readBody, json, emit, pageState,
|
|
66
|
+
}) {
|
|
67
|
+
const cache = new Map();
|
|
68
|
+
let cachedBytes = 0;
|
|
69
|
+
|
|
70
|
+
const revision = (id) => id ? store.revisions.get(id) : null;
|
|
71
|
+
const saveRevision = (value) => {
|
|
72
|
+
const provenance = {
|
|
73
|
+
pageKey: value.documentId,
|
|
74
|
+
...(value.provenance.sessionId ? { sessionId: value.provenance.sessionId } : {}),
|
|
75
|
+
...(value.provenance.renderGeneration !== undefined ? { generation: value.provenance.renderGeneration } : {}),
|
|
76
|
+
...(value.source?.hash ? { sourceHash: value.source.hash } : {}),
|
|
77
|
+
...(value.provenance.captureId ? { captureId: value.provenance.captureId } : {}),
|
|
78
|
+
feedbackOnlyEdits: value.provenance.feedbackOnly === true,
|
|
79
|
+
trustedInteractive: value.provenance.trustedInteractive === true,
|
|
80
|
+
};
|
|
81
|
+
return store.revisions.put({
|
|
82
|
+
documentId: value.documentId,
|
|
83
|
+
reason: value.provenance.captureReason,
|
|
84
|
+
limitations: value.limitations || [],
|
|
85
|
+
...(value.source ? {
|
|
86
|
+
source: {
|
|
87
|
+
text: value.source.content, mediaType: value.source.mediaType,
|
|
88
|
+
capturedAt: value.capturedAt, provenance,
|
|
89
|
+
},
|
|
90
|
+
} : {}),
|
|
91
|
+
...(value.semantic ? {
|
|
92
|
+
semantic: { snapshot: value.semantic, capturedAt: value.semanticCapturedAt ?? value.capturedAt, provenance, view: value.view },
|
|
93
|
+
} : {}),
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
const roundFor = (session, roundId) => store.getRound(session.entryKey, roundId);
|
|
97
|
+
|
|
98
|
+
function changed(entryKey, roundId) {
|
|
99
|
+
for (const session of sessions.values()) {
|
|
100
|
+
if (session.entryKey === entryKey) emit(session, "history", { roundId });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function currentFrame(session, input, key) {
|
|
105
|
+
const render = typeof input.renderId === "string" ? currentRender(input.renderId) : null;
|
|
106
|
+
if (
|
|
107
|
+
!render || render.sessionId !== session.id || render.pageKey !== key ||
|
|
108
|
+
render.generation !== input.generation || render.documentState !== "served" ||
|
|
109
|
+
session.activeKey !== key
|
|
110
|
+
) {
|
|
111
|
+
throw new HistoryRequestError("The page changed or its current frame is unavailable. Recapture it.", {
|
|
112
|
+
status: 409, code: "history_frame_changed",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return render;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkedSource(page, render, expectedHash) {
|
|
119
|
+
const source = sourceFor(page);
|
|
120
|
+
if (source && (
|
|
121
|
+
typeof expectedHash !== "string" ||
|
|
122
|
+
source.hash !== expectedHash ||
|
|
123
|
+
render.sourceHash !== expectedHash
|
|
124
|
+
)) {
|
|
125
|
+
throw new HistoryRequestError("The source changed since this version was reviewed. Reload and recapture it.", {
|
|
126
|
+
status: 409, code: "history_source_changed",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return source;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function snapshot(session, key, input, reason) {
|
|
133
|
+
const page = store.page(key);
|
|
134
|
+
const render = currentFrame(session, input, key);
|
|
135
|
+
if (!input.semantic || typeof input.semantic !== "object" || Array.isArray(input.semantic)) {
|
|
136
|
+
throw new HistoryRequestError("The current page did not provide a content snapshot.", {
|
|
137
|
+
status: 409, code: "history_semantic_unavailable",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (input.semanticCapturedAt !== undefined && (
|
|
141
|
+
!Number.isSafeInteger(input.semanticCapturedAt) ||
|
|
142
|
+
input.semanticCapturedAt < render.createdAt || input.semanticCapturedAt > Date.now() + 5000
|
|
143
|
+
)) {
|
|
144
|
+
throw new HistoryRequestError("The content capture timestamp does not belong to this rendered version.");
|
|
145
|
+
}
|
|
146
|
+
const source = checkedSource(page, render, input.expectedSourceHash);
|
|
147
|
+
return {
|
|
148
|
+
documentId: key,
|
|
149
|
+
capturedAt: Date.now(),
|
|
150
|
+
semanticCapturedAt: input.semanticCapturedAt,
|
|
151
|
+
source,
|
|
152
|
+
semantic: input.semantic,
|
|
153
|
+
view: normalizeView(input.view),
|
|
154
|
+
provenance: {
|
|
155
|
+
sessionId: session.id,
|
|
156
|
+
renderGeneration: render.generation,
|
|
157
|
+
renderId: render.renderId,
|
|
158
|
+
pageKey: key,
|
|
159
|
+
sourceHashAtRender: render.sourceHash ?? null,
|
|
160
|
+
captureReason: reason,
|
|
161
|
+
feedbackOnly: page.kind === "url" || isMarkdown(page.file) || render.feedbackOnly === true,
|
|
162
|
+
trustedInteractive: render.trustedInteractive === true,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function prepareBaseline(session, pages, input) {
|
|
168
|
+
if (input === undefined) return null;
|
|
169
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
170
|
+
throw new HistoryRequestError("Invalid history submission.");
|
|
171
|
+
}
|
|
172
|
+
if (input.allowUnavailable !== undefined && typeof input.allowUnavailable !== "boolean") {
|
|
173
|
+
throw new HistoryRequestError("Invalid missing-comparison choice.");
|
|
174
|
+
}
|
|
175
|
+
const keys = pages.length ? pages.map((page) => page.key) : [session.entryKey];
|
|
176
|
+
const pending = [];
|
|
177
|
+
const unavailable = [];
|
|
178
|
+
for (const key of keys) {
|
|
179
|
+
const page = store.page(key);
|
|
180
|
+
let value = null;
|
|
181
|
+
let reason = null;
|
|
182
|
+
try {
|
|
183
|
+
value = snapshot(session, key, input, "send");
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!(error instanceof HistoryRequestError) && !["ENOENT", "EACCES", "EPERM"].includes(error.code)) throw error;
|
|
186
|
+
reason = error.message;
|
|
187
|
+
unavailable.push({ key, filename: pageState(key)?.filename || key, reason });
|
|
188
|
+
if (input.allowUnavailable) {
|
|
189
|
+
try {
|
|
190
|
+
const source = sourceFor(page);
|
|
191
|
+
if (source) {
|
|
192
|
+
value = {
|
|
193
|
+
documentId: key, capturedAt: Date.now(), source, semantic: null,
|
|
194
|
+
provenance: { captureReason: "send-source-only", pageKey: key },
|
|
195
|
+
limitations: ["semantic_before_unavailable"],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
} catch (sourceError) {
|
|
199
|
+
if (!(sourceError instanceof HistoryRequestError) && !["ENOENT", "EACCES", "EPERM"].includes(sourceError.code)) {
|
|
200
|
+
throw sourceError;
|
|
201
|
+
}
|
|
202
|
+
reason = sourceError.message;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
pending.push({ key, value, reason });
|
|
207
|
+
}
|
|
208
|
+
if (unavailable.length && !input.allowUnavailable) {
|
|
209
|
+
throw new HistoryRequestError("Some pages need to be recaptured before their comparison can be saved.", {
|
|
210
|
+
status: 409, code: "history_baseline_unavailable", targets: unavailable,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const targets = pending.map(({ key, value, reason }) => ({
|
|
214
|
+
key,
|
|
215
|
+
...(value ? { baselineRevisionId: saveRevision(value).revisionId } : {
|
|
216
|
+
baselineUnavailable: reason || "The before snapshot is unavailable.",
|
|
217
|
+
}),
|
|
218
|
+
ownerSessionId: session.id,
|
|
219
|
+
}));
|
|
220
|
+
return { targets, unavailable };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function presentRound(round) {
|
|
224
|
+
return {
|
|
225
|
+
...round,
|
|
226
|
+
sentAt: round.sentAt ?? round.createdAt,
|
|
227
|
+
targets: round.targets.map((target) => ({
|
|
228
|
+
...target,
|
|
229
|
+
captureStatus: target.capture?.status || (target.resultRevisionId ? "ready" : "pending"),
|
|
230
|
+
filename: pageState(target.key)?.filename || target.key,
|
|
231
|
+
kind: store.page(target.key)?.kind === "url" ? "url" : "file",
|
|
232
|
+
})),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function list(session) {
|
|
237
|
+
return {
|
|
238
|
+
activeKey: session.activeKey,
|
|
239
|
+
rounds: store.listHistory(session.entryKey).map(presentRound),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function captureSources(entryKey, roundId) {
|
|
244
|
+
const rounds = roundId ? [store.getRound(entryKey, roundId)] : store.listHistory(entryKey);
|
|
245
|
+
for (const round of rounds) {
|
|
246
|
+
if (round?.feedbackStatus !== "acknowledged" || round.completedAt) continue;
|
|
247
|
+
for (const target of round.targets) {
|
|
248
|
+
if (
|
|
249
|
+
target.sourceResultRevisionId || target.resultRevisionId ||
|
|
250
|
+
target.capture?.status === "unavailable" || store.page(target.key)?.kind === "url"
|
|
251
|
+
) continue;
|
|
252
|
+
try {
|
|
253
|
+
const saved = saveRevision({
|
|
254
|
+
documentId: target.key, capturedAt: Date.now(),
|
|
255
|
+
source: sourceFor(store.page(target.key)),
|
|
256
|
+
provenance: { captureReason: "acknowledged-source-result" },
|
|
257
|
+
});
|
|
258
|
+
store.recordSourceResult(entryKey, round.roundId, target.key, { revisionId: saved.revisionId });
|
|
259
|
+
console.info("[doc-review]", { event: "history-source-captured", roundId: round.roundId, key: target.key });
|
|
260
|
+
} catch (error) {
|
|
261
|
+
try {
|
|
262
|
+
store.recordSourceResult(entryKey, round.roundId, target.key, { unavailable: String(error.message).slice(0, 500) });
|
|
263
|
+
} catch (persistenceError) {
|
|
264
|
+
console.error("[doc-review]", { event: "history-source-failure-not-persisted", code: persistenceError.code || "history_storage_failed" });
|
|
265
|
+
}
|
|
266
|
+
console.error("[doc-review]", { event: "history-source-unavailable", roundId: round.roundId, key: target.key, code: error.code || "history_storage_failed" });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function comparison(round, key, mode) {
|
|
273
|
+
if (!["content", "source"].includes(mode)) throw new HistoryRequestError("Unknown comparison mode.");
|
|
274
|
+
const target = round.targets.find((item) => item.key === key);
|
|
275
|
+
if (!target) throw new HistoryRequestError("This target does not belong to the selected round.", { status: 404 });
|
|
276
|
+
const before = revision(target.baselineRevisionId);
|
|
277
|
+
const after = revision(mode === "source" ? target.sourceResultRevisionId || target.resultRevisionId : target.resultRevisionId);
|
|
278
|
+
const representation = mode === "content" ? "semantic" : "source";
|
|
279
|
+
if (!before?.[representation] || !after?.[representation]) {
|
|
280
|
+
return {
|
|
281
|
+
available: false,
|
|
282
|
+
mode,
|
|
283
|
+
reason: !before?.[representation]
|
|
284
|
+
? target.baselineUnavailable || `The before ${mode} snapshot is unavailable.`
|
|
285
|
+
: (mode === "source" ? target.sourceResultUnavailable : target.resultUnavailable) || `The result ${mode} snapshot has not been captured.`,
|
|
286
|
+
changes: [],
|
|
287
|
+
limitations: [mode === "source" ? "source_unavailable" : "semantic_unavailable"],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const cacheKey = `${DIFF_VERSION}:${before.revisionId}:${after.revisionId}:${mode}`;
|
|
291
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey).value;
|
|
292
|
+
const started = performance.now();
|
|
293
|
+
const differences = mode === "source"
|
|
294
|
+
? compareSources(store.revisions.readSource(before.revisionId), store.revisions.readSource(after.revisionId))
|
|
295
|
+
: compareSemanticSnapshots(store.revisions.readSemantic(before.revisionId), store.revisions.readSemantic(after.revisionId));
|
|
296
|
+
const result = {
|
|
297
|
+
...differences,
|
|
298
|
+
available: differences.status === "complete",
|
|
299
|
+
...(differences.status !== "complete" ? {
|
|
300
|
+
reason: differences.status === "limited"
|
|
301
|
+
? "This comparison exceeds its processing limits. Its contents have not been partially displayed."
|
|
302
|
+
: "The captured content cannot be compared.",
|
|
303
|
+
} : {}),
|
|
304
|
+
mode,
|
|
305
|
+
baselineRevisionId: before.revisionId,
|
|
306
|
+
resultRevisionId: after.revisionId,
|
|
307
|
+
beforeCapturedAt: before[representation].capturedAt,
|
|
308
|
+
afterCapturedAt: after[representation].capturedAt,
|
|
309
|
+
sourceHash: after.source?.provenance.sourceHash ?? null,
|
|
310
|
+
capturedSessionId: after.semantic?.provenance.sessionId ?? null,
|
|
311
|
+
capturedGeneration: after.semantic?.provenance.generation ?? null,
|
|
312
|
+
...(mode === "content" ? { viewComparison: compareCapturedViews(before.semantic?.view, after.semantic?.view) } : {}),
|
|
313
|
+
};
|
|
314
|
+
const bytes = Buffer.byteLength(JSON.stringify(result));
|
|
315
|
+
while (cache.size && (cache.size >= 16 || cachedBytes + bytes > 8 * 1024 * 1024)) {
|
|
316
|
+
const oldest = cache.keys().next().value;
|
|
317
|
+
cachedBytes -= cache.get(oldest).bytes;
|
|
318
|
+
cache.delete(oldest);
|
|
319
|
+
}
|
|
320
|
+
if (bytes <= 8 * 1024 * 1024) {
|
|
321
|
+
cache.set(cacheKey, { value: result, bytes });
|
|
322
|
+
cachedBytes += bytes;
|
|
323
|
+
}
|
|
324
|
+
console.info("[doc-review]", {
|
|
325
|
+
event: "history-compared", roundId: round.roundId, key, mode,
|
|
326
|
+
durationMs: Math.round(performance.now() - started), changes: result.changes.length,
|
|
327
|
+
});
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function handle(req, res, url) {
|
|
332
|
+
const match = url.pathname.match(/^\/api\/session\/(\w+)\/history(?:\/([\w-]+))?(?:\/(compare|capture))?$/);
|
|
333
|
+
if (!match) return false;
|
|
334
|
+
const session = sessions.get(match[1]);
|
|
335
|
+
if (!session) {
|
|
336
|
+
json(res, 404, { error: "unknown session" });
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
if (!match[2] && req.method === "GET") {
|
|
341
|
+
json(res, 200, list(session));
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
let round = roundFor(session, match[2]);
|
|
345
|
+
if (!round) throw new HistoryRequestError("Unknown round in this review history.", { status: 404 });
|
|
346
|
+
if (!match[3] && req.method === "GET") {
|
|
347
|
+
json(res, 200, { round: presentRound(round) });
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
if (match[3] === "compare" && req.method === "GET") {
|
|
351
|
+
json(res, 200, comparison(round, url.searchParams.get("key") || session.activeKey, url.searchParams.get("mode") || "content"));
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
if (match[3] === "capture" && req.method === "POST") {
|
|
355
|
+
const body = await readBody(req);
|
|
356
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
357
|
+
throw new HistoryRequestError("Invalid capture request.");
|
|
358
|
+
}
|
|
359
|
+
round = roundFor(session, match[2]);
|
|
360
|
+
if (!round) throw new HistoryRequestError("Unknown round in this review history.", { status: 404 });
|
|
361
|
+
const key = typeof body.key === "string" ? body.key : session.activeKey;
|
|
362
|
+
const target = round.targets.find((item) => item.key === key);
|
|
363
|
+
if (!target) throw new HistoryRequestError("This target does not belong to the selected round.", { status: 404 });
|
|
364
|
+
if (round.feedbackStatus !== "acknowledged") {
|
|
365
|
+
throw new HistoryRequestError("The round has not been acknowledged.", { status: 409 });
|
|
366
|
+
}
|
|
367
|
+
if (target.resultRevisionId) {
|
|
368
|
+
json(res, 200, { ok: true, alreadyCaptured: true, round: presentRound(round) });
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
if (body.manual !== undefined && typeof body.manual !== "boolean") {
|
|
372
|
+
throw new HistoryRequestError("Invalid manual capture choice.");
|
|
373
|
+
}
|
|
374
|
+
if (body.finalUnavailable !== undefined && body.finalUnavailable !== true) {
|
|
375
|
+
throw new HistoryRequestError("Invalid final capture choice.");
|
|
376
|
+
}
|
|
377
|
+
if (body.finalUnavailable === true) {
|
|
378
|
+
if (body.manual !== true) throw new HistoryRequestError("Finishing without content capture must be an explicit choice.");
|
|
379
|
+
if (target.capture?.status === "unavailable") {
|
|
380
|
+
json(res, 200, { ok: true, alreadyFinalized: true, round: presentRound(round) });
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
captureSources(session.entryKey, round.roundId);
|
|
384
|
+
const claimed = store.claimCapture(session.entryKey, round.roundId, key, {
|
|
385
|
+
ownerSessionId: session.id, generation: 0,
|
|
386
|
+
});
|
|
387
|
+
const updated = store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
|
|
388
|
+
captureId: claimed.captureId, ownerSessionId: session.id, generation: 0,
|
|
389
|
+
reason: "Content capture was explicitly finished with the available snapshots.", final: true,
|
|
390
|
+
});
|
|
391
|
+
changed(session.entryKey, round.roundId);
|
|
392
|
+
json(res, 200, { ok: true, round: presentRound(updated.round) });
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
const baseline = revision(target.baselineRevisionId);
|
|
396
|
+
const owner = target.ownerSessionId || baseline?.semantic?.provenance.sessionId;
|
|
397
|
+
if (owner && owner !== session.id && body.manual !== true) {
|
|
398
|
+
throw new HistoryRequestError("The review window that sent this round owns automatic capture. Use Capture result to capture here.", {
|
|
399
|
+
status: 409, code: "history_capture_owner",
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
const render = currentFrame(session, body, key);
|
|
403
|
+
if (!target.sourceResultRevisionId) captureSources(session.entryKey, round.roundId);
|
|
404
|
+
const claimed = store.claimCapture(session.entryKey, round.roundId, key, {
|
|
405
|
+
ownerSessionId: session.id, generation: render.generation,
|
|
406
|
+
});
|
|
407
|
+
const ownership = {
|
|
408
|
+
captureId: claimed.captureId, ownerSessionId: session.id, generation: render.generation,
|
|
409
|
+
};
|
|
410
|
+
let updated;
|
|
411
|
+
let saved;
|
|
412
|
+
try {
|
|
413
|
+
if (typeof body.error === "string" && body.error.trim()) {
|
|
414
|
+
if (body.error.length > 500) throw new HistoryRequestError("Capture failure reason is too long.");
|
|
415
|
+
updated = store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
|
|
416
|
+
...ownership, reason: body.error, final: false,
|
|
417
|
+
});
|
|
418
|
+
} else {
|
|
419
|
+
const value = snapshot(session, key, body, body.manual === true ? "manual-result" : "automatic-result");
|
|
420
|
+
const viewComparison = compareCapturedViews(baseline?.semantic?.view, value.view);
|
|
421
|
+
if (viewComparison.status === "mismatch") {
|
|
422
|
+
throw new HistoryRequestError(viewComparison.message, { status: 409, code: "history_view_mismatch" });
|
|
423
|
+
}
|
|
424
|
+
value.provenance.captureId = claimed.captureId;
|
|
425
|
+
saved = saveRevision(value);
|
|
426
|
+
updated = store.recordCaptureResult(session.entryKey, round.roundId, key, {
|
|
427
|
+
...ownership, revisionId: saved.revisionId,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
try {
|
|
432
|
+
store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
|
|
433
|
+
...ownership, reason: String(error.message).slice(0, 500), final: false,
|
|
434
|
+
});
|
|
435
|
+
changed(session.entryKey, round.roundId);
|
|
436
|
+
} catch (persistenceError) {
|
|
437
|
+
console.error("[doc-review]", { event: "history-failure-not-persisted", code: persistenceError.code || "history_failed" });
|
|
438
|
+
}
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
changed(session.entryKey, round.roundId);
|
|
442
|
+
console.info("[doc-review]", {
|
|
443
|
+
event: saved ? "history-captured" : "history-capture-unavailable",
|
|
444
|
+
roundId: round.roundId, key, ...(saved ? { revisionId: saved.revisionId } : {}),
|
|
445
|
+
});
|
|
446
|
+
json(res, 200, { ok: !!saved, round: presentRound(updated.round) });
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
json(res, 405, { error: "method not allowed" });
|
|
450
|
+
} catch (error) {
|
|
451
|
+
const status = historyErrorStatus(error);
|
|
452
|
+
console.error("[doc-review]", { event: "history-failed", status, code: error.code || "history_failed" });
|
|
453
|
+
json(res, status, {
|
|
454
|
+
error: error.message,
|
|
455
|
+
code: error.code || "history_failed",
|
|
456
|
+
...(error.targets?.length ? { targets: error.targets } : {}),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { prepareBaseline, captureSources, list, handle, changed };
|
|
463
|
+
}
|