@erdemtuna/doc-review 0.12.0 → 0.13.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 +23 -25
- package/lib/SKILL.md +65 -202
- package/lib/agent-handoff.js +24 -0
- package/lib/agent-output.js +287 -0
- package/lib/anchor-text.js +31 -19
- package/lib/chrome-api.js +23 -4
- package/lib/chrome.html +0 -25
- package/lib/cli.js +282 -104
- package/lib/comment-target.js +4 -0
- package/lib/contracts/agent.js +122 -0
- package/lib/contracts/feedback.js +369 -1
- package/lib/contracts/frame.js +81 -0
- package/lib/contracts/history.js +189 -1
- package/lib/contracts/index.js +7 -2
- package/lib/contracts/page-boundary.js +187 -0
- package/lib/contracts/validation.js +200 -0
- package/lib/conversation-anchor-controller.js +84 -0
- package/lib/conversation-capture.js +81 -0
- package/lib/conversation-controller.js +798 -0
- package/lib/conversation-save.js +175 -0
- package/lib/conversation-server.js +184 -0
- package/lib/conversation-shell.js +1013 -0
- package/lib/conversation-store.js +809 -0
- package/lib/frame-controller.js +8 -8
- package/lib/frame-policy.js +1 -0
- package/lib/history-policy.js +4 -15
- package/lib/history-server.js +42 -326
- package/lib/html-transform.js +19 -4
- package/lib/icons.js +323 -0
- package/lib/new-message-target.js +27 -0
- package/lib/paths.js +2 -2
- package/lib/poll-transport.js +109 -26
- package/lib/positioning.js +51 -0
- package/lib/references/context-and-recovery.md +130 -0
- package/lib/references/response-contract.md +108 -0
- package/lib/references/source-edits.md +47 -0
- package/lib/revision-store.js +1 -1
- package/lib/save-controller.js +28 -14
- package/lib/sdk.js +357 -35
- package/lib/server.js +73 -595
- package/lib/setup.js +17 -48
- package/lib/state.js +21 -7
- package/lib/thread-anchor-controller.js +56 -0
- package/lib/toolbar-controller.js +3 -3
- package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
- package/lib/ui/chrome.css +1079 -2782
- package/lib/ui/chrome.js +81 -17
- package/package.json +2 -2
- package/lib/chrome-client.js +0 -1706
package/lib/frame-controller.js
CHANGED
|
@@ -338,15 +338,15 @@ export function createFrameController({ sessionId, host, request, suspended, tra
|
|
|
338
338
|
}
|
|
339
339
|
}, 5000);
|
|
340
340
|
}
|
|
341
|
-
function configure(mode, savePolicy, wait = true) {
|
|
341
|
+
function configure(mode, savePolicy, canComment, wait = true) {
|
|
342
342
|
if (loading() || !state.capability || !state.execution)
|
|
343
343
|
return Promise.resolve(false);
|
|
344
344
|
configuration?.settle(false);
|
|
345
|
-
expectedConfiguration = { mode, savePolicy };
|
|
345
|
+
expectedConfiguration = { mode, savePolicy, canComment };
|
|
346
346
|
state.configurationGeneration = null;
|
|
347
347
|
configurationTimeout();
|
|
348
348
|
if (!wait) {
|
|
349
|
-
send({ type: "eh:configureReview", mode, savePolicy });
|
|
349
|
+
send({ type: "eh:configureReview", mode, savePolicy, canComment });
|
|
350
350
|
return Promise.resolve(true);
|
|
351
351
|
}
|
|
352
352
|
return new Promise((resolve) => {
|
|
@@ -358,8 +358,8 @@ export function createFrameController({ sessionId, host, request, suspended, tra
|
|
|
358
358
|
cancelTimer(timer);
|
|
359
359
|
resolve(applied);
|
|
360
360
|
};
|
|
361
|
-
configuration = { mode, savePolicy, settle };
|
|
362
|
-
send({ type: "eh:configureReview", mode, savePolicy });
|
|
361
|
+
configuration = { mode, savePolicy, canComment, settle };
|
|
362
|
+
send({ type: "eh:configureReview", mode, savePolicy, canComment });
|
|
363
363
|
});
|
|
364
364
|
}
|
|
365
365
|
function flush(strict = false) {
|
|
@@ -449,16 +449,16 @@ export function createFrameController({ sessionId, host, request, suspended, tra
|
|
|
449
449
|
isThemePayload(value) && value.theme === currentTheme?.pending?.theme &&
|
|
450
450
|
value.themeRevision === currentTheme.pending.themeRevision));
|
|
451
451
|
},
|
|
452
|
-
configured(mode, savePolicy) {
|
|
452
|
+
configured(mode, savePolicy, canComment) {
|
|
453
453
|
if (!expectedConfiguration || expectedConfiguration.mode !== mode ||
|
|
454
|
-
expectedConfiguration.savePolicy !== savePolicy || loading())
|
|
454
|
+
expectedConfiguration.savePolicy !== savePolicy || expectedConfiguration.canComment !== canComment || loading())
|
|
455
455
|
return false;
|
|
456
456
|
// Confirmation is complete even when background tabs pause the visual handoff.
|
|
457
457
|
cancelTimer(configurationTimer);
|
|
458
458
|
configurationTimer = null;
|
|
459
459
|
state.configurationGeneration = state.generation;
|
|
460
460
|
handoff();
|
|
461
|
-
if (configuration && configuration.mode === mode && configuration.savePolicy === savePolicy) {
|
|
461
|
+
if (configuration && configuration.mode === mode && configuration.savePolicy === savePolicy && configuration.canComment === canComment) {
|
|
462
462
|
configuration.settle(true);
|
|
463
463
|
}
|
|
464
464
|
return true;
|
package/lib/frame-policy.js
CHANGED
|
@@ -8,6 +8,7 @@ export const TRUSTED_SDK_MODULE_PATHS = Object.freeze([
|
|
|
8
8
|
"/editing.js", "/frame-channel.js", "/icons.js", "/review-mode.js",
|
|
9
9
|
"/semantic-snapshot.js", "/serialize.js", "/positioning.js",
|
|
10
10
|
"/revision-schema.js", "/view-identity.js",
|
|
11
|
+
"/thread-anchor-controller.js", "/contracts/frame.js", "/contracts/feedback.js", "/contracts/validation.js",
|
|
11
12
|
]);
|
|
12
13
|
export function interactiveFileCsp(sdkOrigin) {
|
|
13
14
|
const origin = new URL(sdkOrigin);
|
package/lib/history-policy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isRevisionId } from "./revision-schema.js";
|
|
2
2
|
function references(value, out = new Set()) {
|
|
3
3
|
if (isRevisionId(value))
|
|
4
4
|
out.add(value);
|
|
@@ -23,21 +23,10 @@ export function historyRevisionReferences(data) {
|
|
|
23
23
|
const out = unsentRevisionReferences(data);
|
|
24
24
|
references(data.histories, out);
|
|
25
25
|
references(data.batches, out);
|
|
26
|
+
references(data.conversations, out);
|
|
26
27
|
return out;
|
|
27
28
|
}
|
|
28
29
|
export function retainHistory(data) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
for (const history of Object.values(data.histories || {})) {
|
|
32
|
-
const completed = history.rounds
|
|
33
|
-
.filter((round) => round.completedAt)
|
|
34
|
-
.sort((a, b) => b.completedAt - a.completedAt || b.ordinal - a.ordinal);
|
|
35
|
-
const keep = new Set(completed.slice(0, COMPLETED_ROUNDS_TO_KEEP).map((round) => round.roundId));
|
|
36
|
-
const rounds = history.rounds.filter((round) => !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;
|
|
30
|
+
// Referenced comparisons are durable review records, not age/count debris.
|
|
31
|
+
return false;
|
|
43
32
|
}
|
package/lib/history-server.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import { performance } from "node:perf_hooks";
|
|
4
3
|
import { stripSdk } from "./html-transform.js";
|
|
5
4
|
import { isMarkdown } from "./markdown.js";
|
|
6
|
-
import { compareSemanticSnapshots, compareSources
|
|
5
|
+
import { compareSemanticSnapshots, compareSources } from "./revision-diff.js";
|
|
7
6
|
import { REVISION_LIMITS } from "./revision-schema.js";
|
|
8
7
|
import { normalizeView, compareCapturedViews } from "./view-identity.js";
|
|
9
8
|
const SOURCE_LIMIT = REVISION_LIMITS.sourceBytes;
|
|
@@ -61,10 +60,39 @@ function sourceFor(page) {
|
|
|
61
60
|
mediaType: isMarkdown(page.file) ? "text/markdown" : "text/html",
|
|
62
61
|
};
|
|
63
62
|
}
|
|
64
|
-
export function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
63
|
+
export function compareRevisions(revisions, beforeId, afterId, mode, { baselineUnavailable, resultUnavailable } = {}) {
|
|
64
|
+
if (!["content", "source"].includes(mode))
|
|
65
|
+
throw new HistoryRequestError("Unknown comparison mode.");
|
|
66
|
+
const before = beforeId ? revisions.get(beforeId) : null;
|
|
67
|
+
const after = afterId ? revisions.get(afterId) : null;
|
|
68
|
+
const representation = mode === "content" ? "semantic" : "source";
|
|
69
|
+
if (!before?.[representation] || !after?.[representation]) {
|
|
70
|
+
return {
|
|
71
|
+
available: false, mode,
|
|
72
|
+
reason: !before?.[representation] ? baselineUnavailable || `The before ${mode} snapshot is unavailable.`
|
|
73
|
+
: resultUnavailable || `The result ${mode} snapshot has not been captured.`,
|
|
74
|
+
changes: [], limitations: [mode === "source" ? "source_unavailable" : "semantic_unavailable"],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const differences = mode === "source"
|
|
78
|
+
? compareSources(revisions.readSource(before.revisionId), revisions.readSource(after.revisionId))
|
|
79
|
+
: compareSemanticSnapshots(revisions.readSemantic(before.revisionId), revisions.readSemantic(after.revisionId));
|
|
80
|
+
return {
|
|
81
|
+
...differences, available: differences.status === "complete",
|
|
82
|
+
...(differences.status !== "complete" ? {
|
|
83
|
+
reason: differences.status === "limited"
|
|
84
|
+
? "This comparison exceeds its processing limits. Its contents have not been partially displayed."
|
|
85
|
+
: "The captured content cannot be compared.",
|
|
86
|
+
} : {}),
|
|
87
|
+
mode, baselineRevisionId: before.revisionId, resultRevisionId: after.revisionId,
|
|
88
|
+
beforeCapturedAt: before[representation].capturedAt, afterCapturedAt: after[representation].capturedAt,
|
|
89
|
+
sourceHash: after.source?.provenance.sourceHash ?? null,
|
|
90
|
+
capturedSessionId: after.semantic?.provenance.sessionId ?? null,
|
|
91
|
+
capturedGeneration: after.semantic?.provenance.generation ?? null,
|
|
92
|
+
...(mode === "content" ? { viewComparison: compareCapturedViews(before.semantic?.view, after.semantic?.view) } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function createConversationCapture({ store, currentRender }) {
|
|
68
96
|
const saveRevision = (value) => {
|
|
69
97
|
const provenance = {
|
|
70
98
|
pageKey: value.documentId,
|
|
@@ -90,13 +118,6 @@ export function createHistoryController({ store, sessions, currentRender, readBo
|
|
|
90
118
|
} : {}),
|
|
91
119
|
});
|
|
92
120
|
};
|
|
93
|
-
const roundFor = (session, roundId) => store.getRound(session.entryKey, roundId);
|
|
94
|
-
function changed(entryKey, roundId) {
|
|
95
|
-
for (const session of sessions.values()) {
|
|
96
|
-
if (session.entryKey === entryKey)
|
|
97
|
-
emit(session, "history", { roundId });
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
121
|
function currentFrame(session, input, key) {
|
|
101
122
|
const render = typeof input.renderId === "string" ? currentRender(input.renderId) : null;
|
|
102
123
|
if (!render || render.sessionId !== session.id || render.pageKey !== key ||
|
|
@@ -108,11 +129,13 @@ export function createHistoryController({ store, sessions, currentRender, readBo
|
|
|
108
129
|
}
|
|
109
130
|
return render;
|
|
110
131
|
}
|
|
111
|
-
function checkedSource(page, render, expectedHash) {
|
|
132
|
+
function checkedSource(page, render, expectedHash, session) {
|
|
112
133
|
const source = sourceFor(page);
|
|
134
|
+
const write = store.data.conversations.writes[page.key];
|
|
135
|
+
const savedInReview = write?.reviewId === session.reviewId && write?.hash === source?.hash;
|
|
113
136
|
if (source && (typeof expectedHash !== "string" ||
|
|
114
137
|
source.hash !== expectedHash ||
|
|
115
|
-
render.sourceHash !== expectedHash)) {
|
|
138
|
+
(render.sourceHash !== expectedHash && !savedInReview))) {
|
|
116
139
|
throw new HistoryRequestError("The source changed since this version was reviewed. Reload and recapture it.", {
|
|
117
140
|
status: 409, code: "history_source_changed",
|
|
118
141
|
});
|
|
@@ -131,7 +154,7 @@ export function createHistoryController({ store, sessions, currentRender, readBo
|
|
|
131
154
|
input.semanticCapturedAt < render.createdAt || input.semanticCapturedAt > Date.now() + 5000)) {
|
|
132
155
|
throw new HistoryRequestError("The content capture timestamp does not belong to this rendered version.");
|
|
133
156
|
}
|
|
134
|
-
const source = checkedSource(page, render, input.expectedSourceHash);
|
|
157
|
+
const source = checkedSource(page, render, input.expectedSourceHash, session);
|
|
135
158
|
return {
|
|
136
159
|
documentId: key,
|
|
137
160
|
capturedAt: Date.now(),
|
|
@@ -151,314 +174,7 @@ export function createHistoryController({ store, sessions, currentRender, readBo
|
|
|
151
174
|
},
|
|
152
175
|
};
|
|
153
176
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
158
|
-
throw new HistoryRequestError("Invalid history submission.");
|
|
159
|
-
}
|
|
160
|
-
if (input.allowUnavailable !== undefined && typeof input.allowUnavailable !== "boolean") {
|
|
161
|
-
throw new HistoryRequestError("Invalid missing-comparison choice.");
|
|
162
|
-
}
|
|
163
|
-
const keys = pages.length ? pages.map((page) => page.key) : [session.entryKey];
|
|
164
|
-
const pending = [];
|
|
165
|
-
const unavailable = [];
|
|
166
|
-
for (const key of keys) {
|
|
167
|
-
const page = store.page(key);
|
|
168
|
-
let value = null;
|
|
169
|
-
let reason = null;
|
|
170
|
-
try {
|
|
171
|
-
value = snapshot(session, key, input, "send");
|
|
172
|
-
}
|
|
173
|
-
catch (error) {
|
|
174
|
-
if (!(error instanceof HistoryRequestError) && !["ENOENT", "EACCES", "EPERM"].includes(error.code))
|
|
175
|
-
throw error;
|
|
176
|
-
reason = error.message;
|
|
177
|
-
unavailable.push({ key, filename: pageState(key)?.filename || key, reason });
|
|
178
|
-
if (input.allowUnavailable) {
|
|
179
|
-
try {
|
|
180
|
-
const source = sourceFor(page);
|
|
181
|
-
if (source) {
|
|
182
|
-
value = {
|
|
183
|
-
documentId: key, capturedAt: Date.now(), source, semantic: null,
|
|
184
|
-
provenance: { captureReason: "send-source-only", pageKey: key },
|
|
185
|
-
limitations: ["semantic_before_unavailable"],
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
catch (sourceError) {
|
|
190
|
-
if (!(sourceError instanceof HistoryRequestError) && !["ENOENT", "EACCES", "EPERM"].includes(sourceError.code)) {
|
|
191
|
-
throw sourceError;
|
|
192
|
-
}
|
|
193
|
-
reason = sourceError.message;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
pending.push({ key, value, reason });
|
|
198
|
-
}
|
|
199
|
-
if (unavailable.length && !input.allowUnavailable) {
|
|
200
|
-
throw new HistoryRequestError("Some pages need to be recaptured before their comparison can be saved.", {
|
|
201
|
-
status: 409, code: "history_baseline_unavailable", targets: unavailable,
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
const targets = pending.map(({ key, value, reason }) => ({
|
|
205
|
-
key,
|
|
206
|
-
...(value ? { baselineRevisionId: saveRevision(value).revisionId } : {
|
|
207
|
-
baselineUnavailable: reason || "The before snapshot is unavailable.",
|
|
208
|
-
}),
|
|
209
|
-
ownerSessionId: session.id,
|
|
210
|
-
}));
|
|
211
|
-
return { targets, unavailable };
|
|
212
|
-
}
|
|
213
|
-
function presentRound(round) {
|
|
214
|
-
return {
|
|
215
|
-
...round,
|
|
216
|
-
sentAt: round.sentAt ?? round.createdAt,
|
|
217
|
-
targets: round.targets.map((target) => ({
|
|
218
|
-
...target,
|
|
219
|
-
captureStatus: target.capture?.status || (target.resultRevisionId ? "ready" : "pending"),
|
|
220
|
-
filename: pageState(target.key)?.filename || target.key,
|
|
221
|
-
kind: store.page(target.key)?.kind === "url" ? "url" : "file",
|
|
222
|
-
})),
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
function list(session) {
|
|
226
|
-
return {
|
|
227
|
-
activeKey: session.activeKey,
|
|
228
|
-
rounds: store.listHistory(session.entryKey).map(presentRound),
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
function captureSources(entryKey, roundId) {
|
|
232
|
-
const rounds = roundId ? [store.getRound(entryKey, roundId)] : store.listHistory(entryKey);
|
|
233
|
-
for (const round of rounds) {
|
|
234
|
-
if (round?.feedbackStatus !== "acknowledged" || round.completedAt)
|
|
235
|
-
continue;
|
|
236
|
-
for (const target of round.targets) {
|
|
237
|
-
if (target.sourceResultRevisionId || target.resultRevisionId ||
|
|
238
|
-
target.capture?.status === "unavailable" || store.page(target.key)?.kind === "url")
|
|
239
|
-
continue;
|
|
240
|
-
try {
|
|
241
|
-
const saved = saveRevision({
|
|
242
|
-
documentId: target.key, capturedAt: Date.now(),
|
|
243
|
-
source: sourceFor(store.page(target.key)),
|
|
244
|
-
provenance: { captureReason: "acknowledged-source-result" },
|
|
245
|
-
});
|
|
246
|
-
store.recordSourceResult(entryKey, round.roundId, target.key, { revisionId: saved.revisionId });
|
|
247
|
-
console.info("[doc-review]", { event: "history-source-captured", roundId: round.roundId, key: target.key });
|
|
248
|
-
}
|
|
249
|
-
catch (error) {
|
|
250
|
-
try {
|
|
251
|
-
store.recordSourceResult(entryKey, round.roundId, target.key, { unavailable: String(error.message).slice(0, 500) });
|
|
252
|
-
}
|
|
253
|
-
catch (persistenceError) {
|
|
254
|
-
console.error("[doc-review]", { event: "history-source-failure-not-persisted", code: persistenceError.code || "history_storage_failed" });
|
|
255
|
-
}
|
|
256
|
-
console.error("[doc-review]", { event: "history-source-unavailable", roundId: round.roundId, key: target.key, code: error.code || "history_storage_failed" });
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
function comparison(round, key, mode) {
|
|
262
|
-
if (!["content", "source"].includes(mode))
|
|
263
|
-
throw new HistoryRequestError("Unknown comparison mode.");
|
|
264
|
-
const target = round.targets.find((item) => item.key === key);
|
|
265
|
-
if (!target)
|
|
266
|
-
throw new HistoryRequestError("This target does not belong to the selected round.", { status: 404 });
|
|
267
|
-
const before = revision(target.baselineRevisionId);
|
|
268
|
-
const after = revision(mode === "source" ? target.sourceResultRevisionId || target.resultRevisionId : target.resultRevisionId);
|
|
269
|
-
const representation = mode === "content" ? "semantic" : "source";
|
|
270
|
-
if (!before?.[representation] || !after?.[representation]) {
|
|
271
|
-
return {
|
|
272
|
-
available: false,
|
|
273
|
-
mode,
|
|
274
|
-
reason: !before?.[representation]
|
|
275
|
-
? target.baselineUnavailable || `The before ${mode} snapshot is unavailable.`
|
|
276
|
-
: (mode === "source" ? target.sourceResultUnavailable : target.resultUnavailable) || `The result ${mode} snapshot has not been captured.`,
|
|
277
|
-
changes: [],
|
|
278
|
-
limitations: [mode === "source" ? "source_unavailable" : "semantic_unavailable"],
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
const cacheKey = `${DIFF_VERSION}:${before.revisionId}:${after.revisionId}:${mode}`;
|
|
282
|
-
if (cache.has(cacheKey))
|
|
283
|
-
return cache.get(cacheKey).value;
|
|
284
|
-
const started = performance.now();
|
|
285
|
-
const differences = mode === "source"
|
|
286
|
-
? compareSources(store.revisions.readSource(before.revisionId), store.revisions.readSource(after.revisionId))
|
|
287
|
-
: compareSemanticSnapshots(store.revisions.readSemantic(before.revisionId), store.revisions.readSemantic(after.revisionId));
|
|
288
|
-
const result = {
|
|
289
|
-
...differences,
|
|
290
|
-
available: differences.status === "complete",
|
|
291
|
-
...(differences.status !== "complete" ? {
|
|
292
|
-
reason: differences.status === "limited"
|
|
293
|
-
? "This comparison exceeds its processing limits. Its contents have not been partially displayed."
|
|
294
|
-
: "The captured content cannot be compared.",
|
|
295
|
-
} : {}),
|
|
296
|
-
mode,
|
|
297
|
-
baselineRevisionId: before.revisionId,
|
|
298
|
-
resultRevisionId: after.revisionId,
|
|
299
|
-
beforeCapturedAt: before[representation].capturedAt,
|
|
300
|
-
afterCapturedAt: after[representation].capturedAt,
|
|
301
|
-
sourceHash: after.source?.provenance.sourceHash ?? null,
|
|
302
|
-
capturedSessionId: after.semantic?.provenance.sessionId ?? null,
|
|
303
|
-
capturedGeneration: after.semantic?.provenance.generation ?? null,
|
|
304
|
-
...(mode === "content" ? { viewComparison: compareCapturedViews(before.semantic?.view, after.semantic?.view) } : {}),
|
|
305
|
-
};
|
|
306
|
-
const bytes = Buffer.byteLength(JSON.stringify(result));
|
|
307
|
-
while (cache.size && (cache.size >= 16 || cachedBytes + bytes > 8 * 1024 * 1024)) {
|
|
308
|
-
const oldest = cache.keys().next().value;
|
|
309
|
-
cachedBytes -= cache.get(oldest).bytes;
|
|
310
|
-
cache.delete(oldest);
|
|
311
|
-
}
|
|
312
|
-
if (bytes <= 8 * 1024 * 1024) {
|
|
313
|
-
cache.set(cacheKey, { value: result, bytes });
|
|
314
|
-
cachedBytes += bytes;
|
|
315
|
-
}
|
|
316
|
-
console.info("[doc-review]", {
|
|
317
|
-
event: "history-compared", roundId: round.roundId, key, mode,
|
|
318
|
-
durationMs: Math.round(performance.now() - started), changes: result.changes.length,
|
|
319
|
-
});
|
|
320
|
-
return result;
|
|
321
|
-
}
|
|
322
|
-
async function handle(req, res, url) {
|
|
323
|
-
const match = url.pathname.match(/^\/api\/session\/(\w+)\/history(?:\/([\w-]+))?(?:\/(compare|capture))?$/);
|
|
324
|
-
if (!match)
|
|
325
|
-
return false;
|
|
326
|
-
const session = sessions.get(match[1]);
|
|
327
|
-
if (!session) {
|
|
328
|
-
json(res, 404, { error: "unknown session" });
|
|
329
|
-
return true;
|
|
330
|
-
}
|
|
331
|
-
try {
|
|
332
|
-
if (!match[2] && req.method === "GET") {
|
|
333
|
-
json(res, 200, list(session));
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
let round = roundFor(session, match[2]);
|
|
337
|
-
if (!round)
|
|
338
|
-
throw new HistoryRequestError("Unknown round in this review history.", { status: 404 });
|
|
339
|
-
if (!match[3] && req.method === "GET") {
|
|
340
|
-
json(res, 200, { round: presentRound(round) });
|
|
341
|
-
return true;
|
|
342
|
-
}
|
|
343
|
-
if (match[3] === "compare" && req.method === "GET") {
|
|
344
|
-
json(res, 200, comparison(round, url.searchParams.get("key") || session.activeKey, url.searchParams.get("mode") || "content"));
|
|
345
|
-
return true;
|
|
346
|
-
}
|
|
347
|
-
if (match[3] === "capture" && req.method === "POST") {
|
|
348
|
-
const body = await readBody(req);
|
|
349
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
350
|
-
throw new HistoryRequestError("Invalid capture request.");
|
|
351
|
-
}
|
|
352
|
-
round = roundFor(session, match[2]);
|
|
353
|
-
if (!round)
|
|
354
|
-
throw new HistoryRequestError("Unknown round in this review history.", { status: 404 });
|
|
355
|
-
const key = typeof body.key === "string" ? body.key : session.activeKey;
|
|
356
|
-
const target = round.targets.find((item) => item.key === key);
|
|
357
|
-
if (!target)
|
|
358
|
-
throw new HistoryRequestError("This target does not belong to the selected round.", { status: 404 });
|
|
359
|
-
if (round.feedbackStatus !== "acknowledged") {
|
|
360
|
-
throw new HistoryRequestError("The round has not been acknowledged.", { status: 409 });
|
|
361
|
-
}
|
|
362
|
-
if (target.resultRevisionId) {
|
|
363
|
-
json(res, 200, { ok: true, alreadyCaptured: true, round: presentRound(round) });
|
|
364
|
-
return true;
|
|
365
|
-
}
|
|
366
|
-
if (body.manual !== undefined && typeof body.manual !== "boolean") {
|
|
367
|
-
throw new HistoryRequestError("Invalid manual capture choice.");
|
|
368
|
-
}
|
|
369
|
-
if (body.finalUnavailable !== undefined && body.finalUnavailable !== true) {
|
|
370
|
-
throw new HistoryRequestError("Invalid final capture choice.");
|
|
371
|
-
}
|
|
372
|
-
if (body.finalUnavailable === true) {
|
|
373
|
-
if (body.manual !== true)
|
|
374
|
-
throw new HistoryRequestError("Finishing without content capture must be an explicit choice.");
|
|
375
|
-
if (target.capture?.status === "unavailable") {
|
|
376
|
-
json(res, 200, { ok: true, alreadyFinalized: true, round: presentRound(round) });
|
|
377
|
-
return true;
|
|
378
|
-
}
|
|
379
|
-
captureSources(session.entryKey, round.roundId);
|
|
380
|
-
const claimed = store.claimCapture(session.entryKey, round.roundId, key, {
|
|
381
|
-
ownerSessionId: session.id, generation: 0,
|
|
382
|
-
});
|
|
383
|
-
const updated = store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
|
|
384
|
-
captureId: claimed.captureId, ownerSessionId: session.id, generation: 0,
|
|
385
|
-
reason: "Content capture was explicitly finished with the available snapshots.", final: true,
|
|
386
|
-
});
|
|
387
|
-
changed(session.entryKey, round.roundId);
|
|
388
|
-
json(res, 200, { ok: true, round: presentRound(updated.round) });
|
|
389
|
-
return true;
|
|
390
|
-
}
|
|
391
|
-
const baseline = revision(target.baselineRevisionId);
|
|
392
|
-
const owner = target.ownerSessionId || baseline?.semantic?.provenance.sessionId;
|
|
393
|
-
if (owner && owner !== session.id && body.manual !== true) {
|
|
394
|
-
throw new HistoryRequestError("The review window that sent this round owns automatic capture. Use Capture result to capture here.", {
|
|
395
|
-
status: 409, code: "history_capture_owner",
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
const render = currentFrame(session, body, key);
|
|
399
|
-
if (!target.sourceResultRevisionId)
|
|
400
|
-
captureSources(session.entryKey, round.roundId);
|
|
401
|
-
const claimed = store.claimCapture(session.entryKey, round.roundId, key, {
|
|
402
|
-
ownerSessionId: session.id, generation: render.generation,
|
|
403
|
-
});
|
|
404
|
-
const ownership = {
|
|
405
|
-
captureId: claimed.captureId, ownerSessionId: session.id, generation: render.generation,
|
|
406
|
-
};
|
|
407
|
-
let updated;
|
|
408
|
-
let saved;
|
|
409
|
-
try {
|
|
410
|
-
if (typeof body.error === "string" && body.error.trim()) {
|
|
411
|
-
if (body.error.length > 500)
|
|
412
|
-
throw new HistoryRequestError("Capture failure reason is too long.");
|
|
413
|
-
updated = store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
|
|
414
|
-
...ownership, reason: body.error, final: false,
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
else {
|
|
418
|
-
const value = snapshot(session, key, body, body.manual === true ? "manual-result" : "automatic-result");
|
|
419
|
-
const viewComparison = compareCapturedViews(baseline?.semantic?.view, value.view);
|
|
420
|
-
if (viewComparison.status === "mismatch") {
|
|
421
|
-
throw new HistoryRequestError(viewComparison.message, { status: 409, code: "history_view_mismatch" });
|
|
422
|
-
}
|
|
423
|
-
value.provenance.captureId = claimed.captureId;
|
|
424
|
-
saved = saveRevision(value);
|
|
425
|
-
updated = store.recordCaptureResult(session.entryKey, round.roundId, key, {
|
|
426
|
-
...ownership, revisionId: saved.revisionId,
|
|
427
|
-
});
|
|
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
|
-
}
|
|
437
|
-
catch (persistenceError) {
|
|
438
|
-
console.error("[doc-review]", { event: "history-failure-not-persisted", code: persistenceError.code || "history_failed" });
|
|
439
|
-
}
|
|
440
|
-
throw error;
|
|
441
|
-
}
|
|
442
|
-
changed(session.entryKey, round.roundId);
|
|
443
|
-
console.info("[doc-review]", {
|
|
444
|
-
event: saved ? "history-captured" : "history-capture-unavailable",
|
|
445
|
-
roundId: round.roundId, key, ...(saved ? { revisionId: saved.revisionId } : {}),
|
|
446
|
-
});
|
|
447
|
-
json(res, 200, { ok: !!saved, round: presentRound(updated.round) });
|
|
448
|
-
return true;
|
|
449
|
-
}
|
|
450
|
-
json(res, 405, { error: "method not allowed" });
|
|
451
|
-
}
|
|
452
|
-
catch (error) {
|
|
453
|
-
const status = historyErrorStatus(error);
|
|
454
|
-
console.error("[doc-review]", { event: "history-failed", status, code: error.code || "history_failed" });
|
|
455
|
-
json(res, status, {
|
|
456
|
-
error: error.message,
|
|
457
|
-
code: error.code || "history_failed",
|
|
458
|
-
...(error.targets?.length ? { targets: error.targets } : {}),
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
return true;
|
|
462
|
-
}
|
|
463
|
-
return { prepareBaseline, captureSources, list, handle, changed };
|
|
177
|
+
return {
|
|
178
|
+
captureObservation: (session, key, input, reason) => saveRevision(snapshot(session, key, input, reason)),
|
|
179
|
+
};
|
|
464
180
|
}
|
package/lib/html-transform.js
CHANGED
|
@@ -35,11 +35,26 @@ export function injectSdk(html, key, { src = "/sdk.js", baseHref = "", nonce = "
|
|
|
35
35
|
? prepared.replace(/<head(\s[^>]*)?>/i, (head) => `${head}${restoreRoute}`)
|
|
36
36
|
: `${restoreRoute}${prepared}`;
|
|
37
37
|
}
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// Consume only the document's safe opening prefix. Inserting before <html>
|
|
39
|
+
// invents an implicit head and redistributes authored whitespace into it.
|
|
40
|
+
// Never search ahead for <head>: it could be inside unclosed raw text.
|
|
41
41
|
const doctype = /^(\uFEFF?\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>)/i.exec(prepared);
|
|
42
|
-
|
|
42
|
+
let at = doctype ? doctype[0].length : 0;
|
|
43
|
+
const trivia = () => {
|
|
44
|
+
const match = /^(?:[\t\n\f\r ]|<!--[\s\S]*?-->)+/.exec(prepared.slice(at));
|
|
45
|
+
if (match)
|
|
46
|
+
at += match[0].length;
|
|
47
|
+
};
|
|
48
|
+
const opening = (name) => {
|
|
49
|
+
const match = new RegExp(`^<${name}(?=[\\t\\n\\f\\r />])(?:[^"'<>]|"[^"]*"|'[^']*')*>`, "i").exec(prepared.slice(at));
|
|
50
|
+
if (match)
|
|
51
|
+
at += match[0].length;
|
|
52
|
+
return !!match;
|
|
53
|
+
};
|
|
54
|
+
trivia();
|
|
55
|
+
if (opening("html"))
|
|
56
|
+
trivia();
|
|
57
|
+
opening("head");
|
|
43
58
|
return `${prepared.slice(0, at)}${tag}${prepared.slice(at)}`;
|
|
44
59
|
}
|
|
45
60
|
/** Remove any injected tag, so a file saved with one never keeps it. */
|