@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,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
|
+
}
|
package/src/paths.js
CHANGED
|
@@ -5,7 +5,7 @@ import fs from "node:fs";
|
|
|
5
5
|
|
|
6
6
|
// Bump this when the CLI and detached server no longer share the same request
|
|
7
7
|
// contract. A new CLI must not silently reuse an older background server.
|
|
8
|
-
export const SERVER_PROTOCOL =
|
|
8
|
+
export const SERVER_PROTOCOL = 17;
|
|
9
9
|
|
|
10
10
|
export function serverProtocolMatches(protocol) {
|
|
11
11
|
return Number(protocol) === SERVER_PROTOCOL;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_POLL_SECONDS = 12 * 60 * 60;
|
|
5
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
6
|
+
const clock = {
|
|
7
|
+
now: () => performance.now(),
|
|
8
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
9
|
+
clearTimeout: (timer) => clearTimeout(timer),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function codedError(message, code) {
|
|
13
|
+
return Object.assign(new Error(message), { code });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createDeadline(seconds = DEFAULT_POLL_SECONDS, time = clock) {
|
|
17
|
+
const end = time.now() + seconds * 1000;
|
|
18
|
+
const remaining = () => Math.max(0, end - time.now());
|
|
19
|
+
const check = () => {
|
|
20
|
+
if (remaining() <= 0) throw codedError("Polling deadline reached.", "POLL_DEADLINE");
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
seconds,
|
|
24
|
+
time,
|
|
25
|
+
remaining,
|
|
26
|
+
check,
|
|
27
|
+
async sleep(ms) {
|
|
28
|
+
check();
|
|
29
|
+
await new Promise((resolve) => time.setTimeout(resolve, Math.min(ms, remaining(), MAX_TIMER_MS)));
|
|
30
|
+
check();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isRecoverableTransportError(err) {
|
|
36
|
+
return new Set([
|
|
37
|
+
"ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EHOSTUNREACH",
|
|
38
|
+
"ENETUNREACH", "ENETDOWN", "ECONNABORTED", "ERR_STREAM_PREMATURE_CLOSE",
|
|
39
|
+
"SERVER_START_PENDING",
|
|
40
|
+
]).has(err?.code);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* An absolute response deadline, not a socket-idle timeout: heartbeats and
|
|
45
|
+
* partially received JSON must not extend the caller's waiting budget.
|
|
46
|
+
*/
|
|
47
|
+
export function requestRaw(server, options, body, {
|
|
48
|
+
timeoutMs = options.timeout,
|
|
49
|
+
time = clock,
|
|
50
|
+
signal,
|
|
51
|
+
timeoutCode = "ETIMEDOUT",
|
|
52
|
+
makeRequest = http.request,
|
|
53
|
+
} = {}) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
let req;
|
|
56
|
+
let res;
|
|
57
|
+
let timer;
|
|
58
|
+
let done = false;
|
|
59
|
+
let ended = false;
|
|
60
|
+
let raw = "";
|
|
61
|
+
const expires = timeoutMs == null ? null : time.now() + timeoutMs;
|
|
62
|
+
const premature = () => codedError("The server closed an incomplete response.", "ERR_STREAM_PREMATURE_CLOSE");
|
|
63
|
+
const cleanupResponse = () => {
|
|
64
|
+
res?.removeListener("data", onData);
|
|
65
|
+
res?.removeListener("end", onEnd);
|
|
66
|
+
res?.removeListener("aborted", onAborted);
|
|
67
|
+
};
|
|
68
|
+
const settle = (err, value) => {
|
|
69
|
+
if (done) return;
|
|
70
|
+
done = true;
|
|
71
|
+
time.clearTimeout(timer);
|
|
72
|
+
signal?.removeEventListener("abort", onAbort);
|
|
73
|
+
cleanupResponse();
|
|
74
|
+
// Keep error handlers until close: destroying a socket may emit one last
|
|
75
|
+
// error asynchronously. Close removes the remaining owned listeners.
|
|
76
|
+
res?.destroy();
|
|
77
|
+
req?.destroy();
|
|
78
|
+
if (err) reject(err);
|
|
79
|
+
else resolve(value);
|
|
80
|
+
};
|
|
81
|
+
const onError = (err) => settle(err);
|
|
82
|
+
const onData = (chunk) => { raw += chunk; };
|
|
83
|
+
const onEnd = () => {
|
|
84
|
+
ended = true;
|
|
85
|
+
if (!res.complete) return settle(premature());
|
|
86
|
+
settle(null, { status: res.statusCode, raw });
|
|
87
|
+
};
|
|
88
|
+
const onAborted = () => settle(premature());
|
|
89
|
+
const onResponseClose = () => {
|
|
90
|
+
if (!ended) settle(premature());
|
|
91
|
+
cleanupResponse();
|
|
92
|
+
res.removeListener("error", onError);
|
|
93
|
+
res.removeListener("close", onResponseClose);
|
|
94
|
+
};
|
|
95
|
+
const onRequestClose = () => {
|
|
96
|
+
if (!done) settle(premature());
|
|
97
|
+
req.removeListener("error", onError);
|
|
98
|
+
req.removeListener("close", onRequestClose);
|
|
99
|
+
req.removeListener("response", onResponse);
|
|
100
|
+
};
|
|
101
|
+
const onAbort = () => settle(codedError("Polling cancelled.", "ABORT_ERR"));
|
|
102
|
+
const armTimer = () => {
|
|
103
|
+
const left = expires - time.now();
|
|
104
|
+
if (left <= 0) return settle(codedError("Polling request timed out.", timeoutCode));
|
|
105
|
+
timer = time.setTimeout(armTimer, Math.min(left, MAX_TIMER_MS));
|
|
106
|
+
};
|
|
107
|
+
const onResponse = (response) => {
|
|
108
|
+
res = response;
|
|
109
|
+
if (done) {
|
|
110
|
+
res.destroy();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
res.setEncoding("utf8");
|
|
114
|
+
res.on("data", onData);
|
|
115
|
+
res.on("end", onEnd);
|
|
116
|
+
res.on("error", onError);
|
|
117
|
+
res.on("aborted", onAborted);
|
|
118
|
+
res.on("close", onResponseClose);
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
if (signal?.aborted) return onAbort();
|
|
122
|
+
const port = typeof server === "number" ? server : server.port;
|
|
123
|
+
const token = typeof server === "number" ? "" : server.token || "";
|
|
124
|
+
const { timeout: _timeout, ...requestOptions } = options;
|
|
125
|
+
req = makeRequest({
|
|
126
|
+
host: "127.0.0.1",
|
|
127
|
+
port,
|
|
128
|
+
...requestOptions,
|
|
129
|
+
headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
|
|
130
|
+
});
|
|
131
|
+
req.on("response", onResponse);
|
|
132
|
+
req.on("error", onError);
|
|
133
|
+
req.on("close", onRequestClose);
|
|
134
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
if (expires !== null) armTimer();
|
|
136
|
+
if (done) return;
|
|
137
|
+
if (body) req.write(JSON.stringify(body));
|
|
138
|
+
req.end();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
settle(err);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parseServerResponse(response) {
|
|
146
|
+
if (response.status !== 200) {
|
|
147
|
+
let detail = "";
|
|
148
|
+
try { detail = JSON.parse(response.raw).error || ""; } catch { /* HTTP status is sufficient. */ }
|
|
149
|
+
throw codedError(
|
|
150
|
+
`Doc-review server returned HTTP ${response.status}${detail ? `: ${detail}` : "."}`,
|
|
151
|
+
"SERVER_RESPONSE_ERROR",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(response.raw);
|
|
156
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
157
|
+
return parsed;
|
|
158
|
+
} catch {
|
|
159
|
+
throw codedError("Malformed response from the doc-review server. End the review and restart the server.", "SERVER_RESPONSE_INVALID");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function pollOnce(server, target, ackId, deadline) {
|
|
164
|
+
deadline.check();
|
|
165
|
+
const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
|
|
166
|
+
const response = await requestRaw(server, {
|
|
167
|
+
method: "GET",
|
|
168
|
+
path: `/api/poll?${query}`,
|
|
169
|
+
}, undefined, {
|
|
170
|
+
timeoutMs: deadline.remaining(),
|
|
171
|
+
time: deadline.time,
|
|
172
|
+
timeoutCode: "POLL_DEADLINE",
|
|
173
|
+
});
|
|
174
|
+
// Graceful server disposal ends the space heartbeats without a JSON payload.
|
|
175
|
+
// HTTP is complete, but this poll was interrupted just like a dropped socket.
|
|
176
|
+
if (response.status === 200 && /^ +$/.test(response.raw)) {
|
|
177
|
+
throw codedError("The server ended the poll before sending feedback.", "ERR_STREAM_PREMATURE_CLOSE");
|
|
178
|
+
}
|
|
179
|
+
const batch = parseServerResponse(response);
|
|
180
|
+
if (!["feedback", "closed", "timeout"].includes(batch.status) ||
|
|
181
|
+
(batch.status === "feedback" && (typeof batch.batch_id !== "string" || !batch.batch_id || !Array.isArray(batch.pages)))) {
|
|
182
|
+
throw codedError("Malformed polling response from the doc-review server.", "SERVER_RESPONSE_INVALID");
|
|
183
|
+
}
|
|
184
|
+
return batch;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function pollUntilDeadline({
|
|
188
|
+
target,
|
|
189
|
+
ackId = "",
|
|
190
|
+
deadline = createDeadline(),
|
|
191
|
+
discover,
|
|
192
|
+
poll = pollOnce,
|
|
193
|
+
diagnostic = () => {},
|
|
194
|
+
}) {
|
|
195
|
+
let failures = 0;
|
|
196
|
+
for (;;) {
|
|
197
|
+
try {
|
|
198
|
+
deadline.check();
|
|
199
|
+
const server = await discover(deadline);
|
|
200
|
+
deadline.check();
|
|
201
|
+
// An uncertain send must repeat the supplied receipt, never a newer ID.
|
|
202
|
+
const batch = await poll(server, target, ackId, deadline);
|
|
203
|
+
deadline.check();
|
|
204
|
+
return batch;
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err.code === "POLL_DEADLINE" || deadline.remaining() <= 0) {
|
|
207
|
+
return {
|
|
208
|
+
status: "timeout",
|
|
209
|
+
waited_seconds: deadline.seconds,
|
|
210
|
+
next_step: "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (!isRecoverableTransportError(err)) throw err;
|
|
214
|
+
diagnostic(`Lost the connection (${err.message}); retrying.\n`);
|
|
215
|
+
try {
|
|
216
|
+
await deadline.sleep(Math.min(250 * 2 ** Math.min(failures++, 5), 5000));
|
|
217
|
+
} catch (sleepError) {
|
|
218
|
+
if (sleepError.code !== "POLL_DEADLINE") throw sleepError;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|