@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/server.js
CHANGED
|
@@ -4,16 +4,18 @@ import path from "node:path";
|
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { atomicWrite, Store, resolveAsset } from "./state.js";
|
|
7
|
-
import { normalizeCommentAnchor } from "./comment-anchor.js";
|
|
8
7
|
import { injectSdk, stripSdk } from "./html-transform.js";
|
|
9
8
|
import { isMarkdown, renderMarkdownPage } from "./markdown.js";
|
|
10
|
-
import { canonicalTarget, ensureStateDir, localUrl, SERVER_PROTOCOL, serverPath
|
|
9
|
+
import { canonicalTarget, ensureStateDir, localUrl, SERVER_PROTOCOL, serverPath } from "./paths.js";
|
|
11
10
|
import { acquireServerLock, releaseServerLock, removeOwnedServerRecord } from "./server-lock.js";
|
|
12
|
-
import { invocation
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { invocation } from "./setup.js";
|
|
12
|
+
import { agentHandoff } from "./agent-handoff.js";
|
|
13
|
+
import { createConversationCapture, HistoryRequestError, historyErrorStatus } from "./history-server.js";
|
|
15
14
|
import { documentExecutionPolicy, transformInteractiveHtml } from "./document-execution.js";
|
|
16
15
|
import { interactiveFileCsp } from "./frame-policy.js";
|
|
16
|
+
import { createConversationController, conversationFailure } from "./conversation-server.js";
|
|
17
|
+
import { ContractError } from "./contracts/validation.js";
|
|
18
|
+
import { stagedRoot as conversationStagedRoot } from "./conversation-save.js";
|
|
17
19
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
20
|
const MIME = {
|
|
19
21
|
".html": "text/html; charset=utf-8",
|
|
@@ -54,7 +56,6 @@ const MAX_LOCAL_PAGE_BYTES = 24 * 1024 * 1024;
|
|
|
54
56
|
*/
|
|
55
57
|
const fileReviewCsp = (nonce) => `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`;
|
|
56
58
|
const hash = (text) => crypto.createHash("sha1").update(text).digest("hex");
|
|
57
|
-
const uid = (prefix) => `${prefix}_${crypto.randomBytes(6).toString("hex")}`;
|
|
58
59
|
/** Read an HTML response with a hard size cap, since text() is unbounded. */
|
|
59
60
|
async function readCapped(response, url) {
|
|
60
61
|
const reader = response.body.getReader();
|
|
@@ -120,8 +121,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
120
121
|
/** Browser windows. Ephemeral — nothing durable lives here. */
|
|
121
122
|
const sessions = new Map(); // sessionId -> { id, entryKey, activeKey, generation, renderId, visited, clients:Set<res>, lastSeen }
|
|
122
123
|
const renders = new Map(); // renderId -> current artifact/bootstrap record
|
|
123
|
-
/** Agent long-polls, keyed by the entry page they were started on. */
|
|
124
|
-
const pollers = new Map(); // entryKey -> Set<{ res, timer }>
|
|
125
124
|
const sseResponses = new Map(); // res -> heartbeat timer
|
|
126
125
|
const watched = new Map(); // key -> { file }
|
|
127
126
|
const lastWritten = new Map(); // key -> content hash doc-review itself wrote
|
|
@@ -140,9 +139,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
140
139
|
function sessionsForKey(key) {
|
|
141
140
|
return [...sessions.values()].filter((s) => s.activeKey === key);
|
|
142
141
|
}
|
|
143
|
-
function sessionsForEntry(entryKey) {
|
|
144
|
-
return [...sessions.values()].filter((s) => s.entryKey === entryKey);
|
|
145
|
-
}
|
|
146
142
|
function expireRender(renderId) {
|
|
147
143
|
if (!renderId)
|
|
148
144
|
return;
|
|
@@ -182,24 +178,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
182
178
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data || {})}\n\n`);
|
|
183
179
|
}
|
|
184
180
|
}
|
|
185
|
-
/**
|
|
186
|
-
* A pending batch only means "working" once an agent has actually taken it.
|
|
187
|
-
* Feedback sent with nothing listening is "stranded", and the browser says so.
|
|
188
|
-
*/
|
|
189
|
-
function agentState(entryKey) {
|
|
190
|
-
const pending = store.batch(entryKey);
|
|
191
|
-
if (pending?.delivery_state === "delivered")
|
|
192
|
-
return "working";
|
|
193
|
-
const set = pollers.get(entryKey);
|
|
194
|
-
if (set && set.size)
|
|
195
|
-
return "listening";
|
|
196
|
-
return pending ? "stranded" : "idle";
|
|
197
|
-
}
|
|
198
|
-
function broadcastAgent(entryKey) {
|
|
199
|
-
const state = agentState(entryKey);
|
|
200
|
-
for (const session of sessionsForEntry(entryKey))
|
|
201
|
-
emit(session, "agent", { state });
|
|
202
|
-
}
|
|
203
181
|
// ------------------------------------------------------------- file watch
|
|
204
182
|
function watchPage(key) {
|
|
205
183
|
if (watched.has(key))
|
|
@@ -229,238 +207,13 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
229
207
|
}
|
|
230
208
|
lastWritten.set(key, current);
|
|
231
209
|
for (const session of sessionsForKey(key)) {
|
|
210
|
+
if (session.renderId && currentRender(session.renderId)?.sourceHash === current)
|
|
211
|
+
continue;
|
|
232
212
|
invalidateSessionRender(session);
|
|
233
213
|
emit(session, "reload", { key });
|
|
234
214
|
}
|
|
235
215
|
});
|
|
236
216
|
}
|
|
237
|
-
function writePage(key, html) {
|
|
238
|
-
const page = store.page(key);
|
|
239
|
-
if (!page)
|
|
240
|
-
throw new Error("unknown page");
|
|
241
|
-
if (page.kind === "url")
|
|
242
|
-
throw new Error("localhost pages are applied through their source files");
|
|
243
|
-
const clean = stripSdk(html);
|
|
244
|
-
atomicWrite(page.file, clean);
|
|
245
|
-
lastWritten.set(key, hash(clean));
|
|
246
|
-
return clean;
|
|
247
|
-
}
|
|
248
|
-
// ------------------------------------------------------------------ batch
|
|
249
|
-
function deliver(entryKey) {
|
|
250
|
-
const set = pollers.get(entryKey);
|
|
251
|
-
if (!set || set.size === 0)
|
|
252
|
-
return false;
|
|
253
|
-
const pending = store.markBatchDelivered(entryKey);
|
|
254
|
-
if (!pending)
|
|
255
|
-
return false;
|
|
256
|
-
for (const poller of [...set]) {
|
|
257
|
-
clearInterval(poller.timer);
|
|
258
|
-
set.delete(poller);
|
|
259
|
-
poller.res.end(JSON.stringify(pending.batch));
|
|
260
|
-
}
|
|
261
|
-
pollers.delete(entryKey);
|
|
262
|
-
return true;
|
|
263
|
-
}
|
|
264
|
-
/** Every page you left feedback on ships in one batch, grouped by target. */
|
|
265
|
-
function collectPages(session) {
|
|
266
|
-
const out = [];
|
|
267
|
-
for (const key of session.visited) {
|
|
268
|
-
const page = store.page(key);
|
|
269
|
-
if (!page)
|
|
270
|
-
continue;
|
|
271
|
-
if (!page.comments.length && !page.edits.length)
|
|
272
|
-
continue;
|
|
273
|
-
out.push({
|
|
274
|
-
key,
|
|
275
|
-
kind: page.kind === "url" ? "url" : "file",
|
|
276
|
-
file: page.kind === "url" ? page.url : page.file,
|
|
277
|
-
url: page.kind === "url" ? page.url : undefined,
|
|
278
|
-
comments: page.comments.map((c) => ({
|
|
279
|
-
id: c.id,
|
|
280
|
-
kind: c.kind,
|
|
281
|
-
quote: c.quote,
|
|
282
|
-
anchor: c.anchor == null ? c.anchor : normalizeCommentAnchor(c.kind, c.anchor),
|
|
283
|
-
feedback: c.feedback,
|
|
284
|
-
...(c.correction ? { correction: true, correction_of: c.correctionOf } : {}),
|
|
285
|
-
})),
|
|
286
|
-
edits: page.edits.map((e) => ({
|
|
287
|
-
label: e.label,
|
|
288
|
-
kind: e.kind,
|
|
289
|
-
before: e.before,
|
|
290
|
-
after: e.after,
|
|
291
|
-
...(e.feedback_only ? { feedback_only: true } : {}),
|
|
292
|
-
...(e.truncated ? { truncated: true, truncated_fields: e.truncated_fields } : {}),
|
|
293
|
-
...(e.before_html !== undefined && e.before_html !== e.before ? { before_html: e.before_html } : {}),
|
|
294
|
-
...(e.after_html !== undefined && e.after_html !== e.after ? { after_html: e.after_html } : {}),
|
|
295
|
-
...(Array.isArray(e.staged_assets) && e.staged_assets.length ? { staged_assets: e.staged_assets } : {}),
|
|
296
|
-
})),
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
return out;
|
|
300
|
-
}
|
|
301
|
-
/** Pages with feedback that are not the one on screen. */
|
|
302
|
-
function otherPages(session) {
|
|
303
|
-
return collectPages(session)
|
|
304
|
-
.filter((p) => p.key !== session.activeKey)
|
|
305
|
-
.map((p) => ({
|
|
306
|
-
key: p.key,
|
|
307
|
-
filename: p.kind === "url" ? new URL(p.url).pathname || p.url : path.basename(p.file),
|
|
308
|
-
count: p.comments.length + p.edits.length,
|
|
309
|
-
}));
|
|
310
|
-
}
|
|
311
|
-
function sendBatch(sessionId, note, historyInput) {
|
|
312
|
-
const session = sessions.get(sessionId);
|
|
313
|
-
if (!session)
|
|
314
|
-
return { error: "unknown session" };
|
|
315
|
-
const pages = collectPages(session);
|
|
316
|
-
if (!pages.length && !note)
|
|
317
|
-
return { error: "nothing to send" };
|
|
318
|
-
const historyContext = history.prepareBaseline(session, pages, historyInput);
|
|
319
|
-
const hasMarkdown = pages.some((p) => p.kind === "file" && isMarkdown(p.file));
|
|
320
|
-
const hasUrl = pages.some((p) => p.kind === "url");
|
|
321
|
-
const hasTrustedEdits = pages.some((p) => p.edits.some((edit) => edit.feedback_only));
|
|
322
|
-
const hasCorrections = pages.some((p) => p.comments.some((c) => c.correction));
|
|
323
|
-
const hasTruncation = pages.some((p) => p.edits.some((e) => e.truncated));
|
|
324
|
-
const id = `b_${crypto.randomBytes(12).toString("hex")}`;
|
|
325
|
-
const entry = store.page(session.entryKey);
|
|
326
|
-
const pollTarget = entry?.kind === "url" ? entry.url : entry?.file;
|
|
327
|
-
const ackCommand = `${cliInvocation} poll ${shellQuote(pollTarget)} --ack ${id} --timeout 600`;
|
|
328
|
-
const batch = {
|
|
329
|
-
batch_id: id,
|
|
330
|
-
status: "feedback",
|
|
331
|
-
pages: pages.map(({ kind, file, url, comments, edits }) => ({ kind, file, ...(url ? { url } : {}), comments, edits })),
|
|
332
|
-
overall_note: note || "",
|
|
333
|
-
sent_at: new Date().toISOString(),
|
|
334
|
-
next_step: "Apply this feedback. Each entry in `pages` names the reviewed file or localhost URL. Items under `edits` are " +
|
|
335
|
-
"changes the human already made: unless marked `truncated`, `after` is their exact new wording, so carry it across verbatim, and " +
|
|
336
|
-
"never revert it. When an edit carries `after_html`, the human changed formatting (bold, italic, links) — " +
|
|
337
|
-
"use the HTML version, translated into the source's own syntax. " +
|
|
338
|
-
(hasTruncation
|
|
339
|
-
? "Some edits are marked `truncated`; `truncated_fields` lists incomplete fields. Never apply incomplete text or HTML " +
|
|
340
|
-
"as a complete replacement or invent missing content. Recover the full edit only from an authoritative source, " +
|
|
341
|
-
"or ask the user for it. Do not acknowledge this batch until all feedback is handled. "
|
|
342
|
-
: "") +
|
|
343
|
-
(hasMarkdown
|
|
344
|
-
? "Markdown pages were reviewed rendered, so quotes and `after` wording use the rendered text — apply " +
|
|
345
|
-
"the change to the Markdown source, keeping its formatting syntax. "
|
|
346
|
-
: "") +
|
|
347
|
-
(hasUrl
|
|
348
|
-
? "Localhost pages were edited directly in the review UI. Find the matching project source (such as MDX or TSX) " +
|
|
349
|
-
"and apply every exact edit or deletion there; never try to write the rendered HTML response back to the app. " +
|
|
350
|
-
"When an edit includes `staged_assets`, copy each local image into the app's appropriate asset folder, replace its " +
|
|
351
|
-
"temporary preview URL in `after_html`, and preserve the image at the user's insertion point. "
|
|
352
|
-
: "") +
|
|
353
|
-
(hasTrustedEdits
|
|
354
|
-
? "Edits marked `feedback_only` came from feedback-only documents. Apply that wording or formatting to the original source; " +
|
|
355
|
-
"it has not been autosaved. Never replace the source with script-generated runtime markup. " +
|
|
356
|
-
"Copy any `staged_assets` into the document's asset folder and replace their temporary references. "
|
|
357
|
-
: "") +
|
|
358
|
-
(hasCorrections
|
|
359
|
-
? "Comments marked `correction` replace their `correction_of` instruction; follow the correction and do not apply the older wording. "
|
|
360
|
-
: "") +
|
|
361
|
-
`When every page is updated, acknowledge only this batch and wait for more by running: ${ackCommand}`,
|
|
362
|
-
};
|
|
363
|
-
const record = {
|
|
364
|
-
batch,
|
|
365
|
-
cleanup: pages.map((p) => ({
|
|
366
|
-
key: p.key,
|
|
367
|
-
ids: p.comments.map((c) => c.id),
|
|
368
|
-
staged: p.edits.flatMap((edit) => (edit.staged_assets || []).map((asset) => asset.path)),
|
|
369
|
-
sentAt: Date.now(),
|
|
370
|
-
})),
|
|
371
|
-
};
|
|
372
|
-
store.setBatch(session.entryKey, { ...record, ...(historyContext ? { history: historyContext } : {}) });
|
|
373
|
-
deliver(session.entryKey);
|
|
374
|
-
broadcastAgent(session.entryKey);
|
|
375
|
-
const round = historyContext
|
|
376
|
-
? store.listHistory(session.entryKey).find((item) => item.batchId === id)
|
|
377
|
-
: null;
|
|
378
|
-
if (round)
|
|
379
|
-
history.changed(session.entryKey, round.roundId);
|
|
380
|
-
return {
|
|
381
|
-
ok: true,
|
|
382
|
-
...(round ? { roundId: round.roundId } : {}),
|
|
383
|
-
...(historyContext?.unavailable.length ? { historyUnavailable: historyContext.unavailable } : {}),
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
function deleteStagedAsset(file) {
|
|
387
|
-
const stagedRoot = path.join(stateDir(), "pasted");
|
|
388
|
-
const resolved = path.resolve(file);
|
|
389
|
-
const relative = path.relative(stagedRoot, resolved);
|
|
390
|
-
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
391
|
-
return;
|
|
392
|
-
try {
|
|
393
|
-
fs.unlinkSync(resolved);
|
|
394
|
-
}
|
|
395
|
-
catch (err) {
|
|
396
|
-
if (err.code !== "ENOENT")
|
|
397
|
-
console.error(`Could not remove acknowledged staged asset ${resolved}: ${err.message}`);
|
|
398
|
-
}
|
|
399
|
-
try {
|
|
400
|
-
fs.rmdirSync(path.dirname(resolved));
|
|
401
|
-
}
|
|
402
|
-
catch (err) {
|
|
403
|
-
if (err.code !== "ENOENT" && err.code !== "ENOTEMPTY") {
|
|
404
|
-
console.error(`Could not remove acknowledged staged asset directory ${path.dirname(resolved)}: ${err.message}`);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
function ack(entryKey, id) {
|
|
409
|
-
const result = store.acknowledgeBatch(entryKey, id);
|
|
410
|
-
if (!result.acknowledged)
|
|
411
|
-
return false;
|
|
412
|
-
if (result.roundId)
|
|
413
|
-
history.captureSources(entryKey, result.roundId);
|
|
414
|
-
const acknowledgedRound = result.roundId ? store.getRound(entryKey, result.roundId) : null;
|
|
415
|
-
// The JSON transition is already durable. Files are cleanup only and must
|
|
416
|
-
// never disappear before the receipt and page cleanup commit succeeds.
|
|
417
|
-
for (const file of result.staged)
|
|
418
|
-
deleteStagedAsset(file);
|
|
419
|
-
for (const session of sessionsForEntry(entryKey))
|
|
420
|
-
emit(session, "refresh", {});
|
|
421
|
-
// File targets reload through fs.watch. URL targets have no source file to
|
|
422
|
-
// watch, so acknowledgement is the signal to fetch the rebuilt route.
|
|
423
|
-
for (const key of new Set([...result.keys, ...(acknowledgedRound?.targets.map((target) => target.key) || [])])) {
|
|
424
|
-
if (store.page(key)?.kind === "url") {
|
|
425
|
-
for (const session of sessionsForKey(key)) {
|
|
426
|
-
invalidateSessionRender(session);
|
|
427
|
-
emit(session, "reload", { key });
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
broadcastAgent(entryKey);
|
|
432
|
-
history.changed(entryKey, result.roundId);
|
|
433
|
-
return true;
|
|
434
|
-
}
|
|
435
|
-
/**
|
|
436
|
-
* A deliberate stop, not a tab close: the browser forgets the session and
|
|
437
|
-
* any waiting agent is released with a clear "stop polling" answer instead
|
|
438
|
-
* of being left to burn its timeout. Unsent feedback stays in the store.
|
|
439
|
-
*/
|
|
440
|
-
function endSession(session) {
|
|
441
|
-
invalidateSessionRender(session);
|
|
442
|
-
sessions.delete(session.id);
|
|
443
|
-
for (const res of session.clients) {
|
|
444
|
-
res.write(`event: ended\ndata: {}\n\n`);
|
|
445
|
-
res.end();
|
|
446
|
-
}
|
|
447
|
-
session.clients.clear();
|
|
448
|
-
// Another window on the same target keeps its agent connection alive.
|
|
449
|
-
if (sessionsForEntry(session.entryKey).length > 0)
|
|
450
|
-
return;
|
|
451
|
-
const set = pollers.get(session.entryKey);
|
|
452
|
-
if (!set)
|
|
453
|
-
return;
|
|
454
|
-
for (const poller of [...set]) {
|
|
455
|
-
clearInterval(poller.timer);
|
|
456
|
-
set.delete(poller);
|
|
457
|
-
poller.res.end(JSON.stringify({
|
|
458
|
-
status: "closed",
|
|
459
|
-
next_step: "The user ended this review session. Stop polling — do not run the poll command again. " +
|
|
460
|
-
"Any unsent feedback is kept and will ship the next time this target is reviewed.",
|
|
461
|
-
}));
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
217
|
// ----------------------------------------------------------------- routes
|
|
465
218
|
function readBody(req) {
|
|
466
219
|
return new Promise((resolve, reject) => {
|
|
@@ -534,10 +287,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
534
287
|
const page = store.page(key);
|
|
535
288
|
if (!page)
|
|
536
289
|
return null;
|
|
537
|
-
// The entry target is what the agent polls, even after navigating elsewhere.
|
|
538
|
-
const entry = session ? store.page(session.entryKey) : null;
|
|
539
290
|
const currentTarget = page.kind === "url" ? page.url : page.file;
|
|
540
|
-
const pollTarget = entry ? (entry.kind === "url" ? entry.url : entry.file) : currentTarget;
|
|
541
291
|
const policy = sourcePolicy(page, undefined, session);
|
|
542
292
|
return {
|
|
543
293
|
key: page.key,
|
|
@@ -549,10 +299,12 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
549
299
|
...policy,
|
|
550
300
|
...(page.kind !== "url" && !isMarkdown(page.file)
|
|
551
301
|
? { executionPreference: session?.executionPreferences?.get(key) || "auto" } : {}),
|
|
552
|
-
comments:
|
|
553
|
-
edits:
|
|
302
|
+
comments: [],
|
|
303
|
+
edits: [],
|
|
554
304
|
canRevert: policy.savePolicy === "writable" && typeof page.pristine === "string" && page.pristine.length > 0,
|
|
555
|
-
pollCommand:
|
|
305
|
+
pollCommand: session?.reviewId
|
|
306
|
+
? agentHandoff({ reviewId: session.reviewId, entryKey: session.entryKey }, null, cliInvocation).pollCommand
|
|
307
|
+
: "",
|
|
556
308
|
historySupported: true,
|
|
557
309
|
};
|
|
558
310
|
}
|
|
@@ -561,59 +313,13 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
561
313
|
const source = page.kind === "url" || markdown ? undefined : bytes ?? fs.readFileSync(page.file);
|
|
562
314
|
return documentExecutionPolicy({ ...page, markdown }, source, session?.executionPreferences?.get(page.key) || "auto");
|
|
563
315
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
return !render || render.documentState !== "served" || render.pageKey !== key ||
|
|
572
|
-
render.sessionId !== identity.sessionId || render.generation !== identity.generation ||
|
|
573
|
-
render.savePolicy !== "writable";
|
|
574
|
-
}
|
|
575
|
-
return false;
|
|
576
|
-
}
|
|
577
|
-
function enforceFileWrite(key, body) {
|
|
578
|
-
if (!body || typeof body !== "object" || Array.isArray(body) ||
|
|
579
|
-
typeof body.baseHash !== "string" || !/^[a-f0-9]{40}$/.test(body.baseHash) ||
|
|
580
|
-
typeof body.sessionId !== "string" || !body.sessionId ||
|
|
581
|
-
typeof body.renderId !== "string" || !body.renderId ||
|
|
582
|
-
!Number.isSafeInteger(body.generation) || body.generation < 1) {
|
|
583
|
-
throw new HistoryRequestError("A source hash and complete frame identity are required.", {
|
|
584
|
-
status: 400, code: "invalid_file_write",
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
const render = currentRender(body.renderId);
|
|
588
|
-
if (!render || render.pageKey !== key || render.sessionId !== body.sessionId ||
|
|
589
|
-
render.generation !== body.generation || render.documentState !== "served") {
|
|
590
|
-
throw new HistoryRequestError("Reload the document before writing; this request has no current frame.", {
|
|
591
|
-
status: 409, code: "stale_file_write",
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
const page = store.page(key);
|
|
595
|
-
const current = fs.readFileSync(page.file);
|
|
596
|
-
if (render.savePolicy !== "writable" || sourcePolicy(page, current).savePolicy !== "writable") {
|
|
597
|
-
throw new HistoryRequestError("This document is feedback-only. Apply edits to the original source through the agent.", {
|
|
598
|
-
status: 409, code: "file_feedback_only",
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
if (hash(stripSdk(current.toString("utf8"))) !== body.baseHash || render.sourceHash !== body.baseHash) {
|
|
602
|
-
throw new HistoryRequestError("The file changed on disk since this edit began.", { status: 409, code: "stale_file_write" });
|
|
603
|
-
}
|
|
604
|
-
return render;
|
|
605
|
-
}
|
|
606
|
-
async function readFileWriteBody(req) {
|
|
607
|
-
try {
|
|
608
|
-
return await readBody(req);
|
|
609
|
-
}
|
|
610
|
-
catch {
|
|
611
|
-
throw new HistoryRequestError("Invalid file write request.", { status: 400, code: "invalid_file_write" });
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
const history = createHistoryController({ store, sessions, currentRender, readBody, json, emit, pageState });
|
|
615
|
-
for (const entryKey of Object.keys(store.data.histories || {}))
|
|
616
|
-
history.captureSources(entryKey);
|
|
316
|
+
const history = createConversationCapture({ store, currentRender });
|
|
317
|
+
const conversations = createConversationController({
|
|
318
|
+
store, sessions, watchPage, json, emit, currentRender, captureObservation: history.captureObservation, cliInvocation,
|
|
319
|
+
sourceWritten(key) {
|
|
320
|
+
lastWritten.set(key, store.data.conversations.writes[key]?.hash ?? null);
|
|
321
|
+
},
|
|
322
|
+
});
|
|
617
323
|
const server = http.createServer(async (req, res) => {
|
|
618
324
|
touch();
|
|
619
325
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
@@ -640,10 +346,21 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
640
346
|
const expected = Buffer.from(token);
|
|
641
347
|
const ok = provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
|
|
642
348
|
if (!ok)
|
|
643
|
-
return json(res, 401,
|
|
349
|
+
return json(res, 401, route.startsWith("/api/conversation")
|
|
350
|
+
? conversationFailure(new ContractError("UNAUTHORIZED", "Missing or invalid token."))
|
|
351
|
+
: { error: "missing or invalid token" });
|
|
644
352
|
}
|
|
645
|
-
if (await
|
|
353
|
+
if (await conversations.handle(req, res, url))
|
|
646
354
|
return undefined;
|
|
355
|
+
if (route === "/api/session" || route === "/api/poll" || route === "/api/status" ||
|
|
356
|
+
/^\/api\/page\/[^/]+\/(comment|edit|asset|save|revert|send)(\/|$)/.test(route) ||
|
|
357
|
+
/^\/api\/session\/[^/]+\/(end|navigate|history)(\/|$)/.test(route)) {
|
|
358
|
+
return json(res, 410, conversationFailure(new ContractError("WORKFLOW_REMOVED", "This workflow has been removed. Open a durable /r/ review with the current doc-review CLI; use /api/conversation and a complete response, never acknowledgement.")));
|
|
359
|
+
}
|
|
360
|
+
if (route.startsWith("/s/")) {
|
|
361
|
+
res.writeHead(410, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
362
|
+
return res.end("This temporary session link is obsolete. Open the target again with the current doc-review CLI.");
|
|
363
|
+
}
|
|
647
364
|
// --- static chrome assets
|
|
648
365
|
if (route === "/chrome.css")
|
|
649
366
|
return serveFile(res, path.join(here, "ui", "chrome.css"));
|
|
@@ -689,6 +406,11 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
689
406
|
return serveFile(res, path.join(here, "serialize.js"), opaqueModuleCors(req));
|
|
690
407
|
if (route === "/frame-channel.js")
|
|
691
408
|
return serveFile(res, path.join(here, "frame-channel.js"), opaqueModuleCors(req));
|
|
409
|
+
if (route === "/thread-anchor-controller.js")
|
|
410
|
+
return serveFile(res, path.join(here, "thread-anchor-controller.js"), opaqueModuleCors(req));
|
|
411
|
+
if (["/contracts/frame.js", "/contracts/feedback.js", "/contracts/validation.js"].includes(route)) {
|
|
412
|
+
return serveFile(res, path.join(here, ...route.slice(1).split("/")), opaqueModuleCors(req));
|
|
413
|
+
}
|
|
692
414
|
if (route === "/semantic-snapshot.js")
|
|
693
415
|
return serveFile(res, path.join(here, "semantic-snapshot.js"), opaqueModuleCors(req));
|
|
694
416
|
if (route === "/revision-schema.js")
|
|
@@ -741,41 +463,15 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
741
463
|
}
|
|
742
464
|
return json(res, 200, { ok: true, page: pageState(page.key, session), reloadRequired });
|
|
743
465
|
}
|
|
744
|
-
// --- open a browser session for a file or localhost URL
|
|
745
|
-
if (route === "/api/session" && req.method === "POST") {
|
|
746
|
-
const body = await readBody(req);
|
|
747
|
-
const target = canonicalTarget(body.target || body.file || "");
|
|
748
|
-
let page;
|
|
749
|
-
if (target.kind === "url") {
|
|
750
|
-
// Fail during open with a useful message rather than opening a blank review.
|
|
751
|
-
await fetchLocalPage(target.value);
|
|
752
|
-
page = store.openUrl(target.value);
|
|
753
|
-
}
|
|
754
|
-
else {
|
|
755
|
-
if (!fs.existsSync(target.value))
|
|
756
|
-
return json(res, 404, { error: `File not found: ${target.value}` });
|
|
757
|
-
const html = fs.readFileSync(target.value, "utf8");
|
|
758
|
-
page = store.openPage(target.value, stripSdk(html));
|
|
759
|
-
lastWritten.set(page.key, hash(stripSdk(html)));
|
|
760
|
-
}
|
|
761
|
-
watchPage(page.key);
|
|
762
|
-
const id = uid("s");
|
|
763
|
-
sessions.set(id, {
|
|
764
|
-
id,
|
|
765
|
-
entryKey: page.key,
|
|
766
|
-
activeKey: page.key,
|
|
767
|
-
generation: 0,
|
|
768
|
-
renderId: null,
|
|
769
|
-
executionPreferences: new Map(),
|
|
770
|
-
visited: new Set([page.key]),
|
|
771
|
-
clients: new Set(),
|
|
772
|
-
lastSeen: Date.now(),
|
|
773
|
-
});
|
|
774
|
-
return json(res, 200, { sessionId: id, key: page.key, path: `/s/${id}` });
|
|
775
|
-
}
|
|
776
466
|
// --- the chrome page
|
|
777
|
-
if (route.startsWith("/
|
|
778
|
-
|
|
467
|
+
if (route.startsWith("/r/")) {
|
|
468
|
+
let id = route.slice(3);
|
|
469
|
+
if (route.startsWith("/r/")) {
|
|
470
|
+
const record = Object.hasOwn(store.data.conversations.reviews, id) ? store.data.conversations.reviews[id] : null;
|
|
471
|
+
if (!record)
|
|
472
|
+
return json(res, 404, conversationFailure(new ContractError("NOT_FOUND", "Unknown durable review link.")));
|
|
473
|
+
id = conversations.attach({ reviewId: id, entryKey: record.review.entryKey }).sessionId;
|
|
474
|
+
}
|
|
779
475
|
if (!sessions.has(id)) {
|
|
780
476
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
781
477
|
return res.end("This review session has ended. Run doc-review <target> again.");
|
|
@@ -783,7 +479,8 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
783
479
|
seen(sessions.get(id));
|
|
784
480
|
const shell = fs.readFileSync(path.join(here, "chrome.html"), "utf8");
|
|
785
481
|
res.writeHead(200, { "content-type": MIME[".html"], "cache-control": "no-store" });
|
|
786
|
-
|
|
482
|
+
const session = sessions.get(id);
|
|
483
|
+
return res.end(shell.replace("__SESSION_ID__", id).replace("__TOKEN__", token).replace("<body", session.reviewId ? `<body data-review="${encodeURIComponent(session.reviewId)}" data-entry="${encodeURIComponent(session.entryKey)}"` : "<body"));
|
|
787
484
|
}
|
|
788
485
|
const renderMatch = route.match(/^\/api\/session\/(\w+)\/render$/);
|
|
789
486
|
if (renderMatch && req.method === "POST") {
|
|
@@ -952,7 +649,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
952
649
|
return res.end("Forbidden");
|
|
953
650
|
}
|
|
954
651
|
// Keep staged previews reachable across source-policy changes.
|
|
955
|
-
return serveFile(res, path.join(
|
|
652
|
+
return serveFile(res, path.join(conversationStagedRoot(render.pageKey), name));
|
|
956
653
|
}
|
|
957
654
|
const target = resolveAsset(page.file, asset.split("?")[0]);
|
|
958
655
|
if (!target) {
|
|
@@ -961,36 +658,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
961
658
|
}
|
|
962
659
|
return serveFile(res, target);
|
|
963
660
|
}
|
|
964
|
-
// --- agent status probe: is feedback waiting? is anyone listening?
|
|
965
|
-
if (route === "/api/status" && req.method === "GET") {
|
|
966
|
-
const entryKey = targetKey(url.searchParams.get("target") || url.searchParams.get("file") || "");
|
|
967
|
-
const pending = store.batch(entryKey);
|
|
968
|
-
const listening = (pollers.get(entryKey) || new Set()).size > 0;
|
|
969
|
-
// Unsent feedback lives on every page reachable from this entry.
|
|
970
|
-
const keys = new Set([entryKey]);
|
|
971
|
-
for (const session of sessions.values()) {
|
|
972
|
-
if (session.entryKey !== entryKey)
|
|
973
|
-
continue;
|
|
974
|
-
for (const k of session.visited)
|
|
975
|
-
keys.add(k);
|
|
976
|
-
}
|
|
977
|
-
let comments = 0;
|
|
978
|
-
let edits = 0;
|
|
979
|
-
for (const k of keys) {
|
|
980
|
-
const page = store.page(k);
|
|
981
|
-
if (!page)
|
|
982
|
-
continue;
|
|
983
|
-
comments += page.comments.length;
|
|
984
|
-
edits += page.edits.length;
|
|
985
|
-
}
|
|
986
|
-
return json(res, 200, {
|
|
987
|
-
status: pending ? "feedback-waiting" : "idle",
|
|
988
|
-
feedback_waiting: !!pending,
|
|
989
|
-
agent_listening: listening,
|
|
990
|
-
server_running: true,
|
|
991
|
-
unsent: { comments, edits },
|
|
992
|
-
});
|
|
993
|
-
}
|
|
994
661
|
// --- page data
|
|
995
662
|
const pageMatch = route.match(/^\/api\/page\/([a-f0-9]+)(?:\/(\w+))?(?:\/(.+))?$/);
|
|
996
663
|
if (pageMatch) {
|
|
@@ -1003,7 +670,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1003
670
|
seen(session);
|
|
1004
671
|
const body = pageState(key, session);
|
|
1005
672
|
if (session)
|
|
1006
|
-
body.others =
|
|
673
|
+
body.others = [];
|
|
1007
674
|
return json(res, 200, body);
|
|
1008
675
|
}
|
|
1009
676
|
// The file as it sits on disk, so the SDK can tell whether the page's
|
|
@@ -1024,175 +691,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1024
691
|
// version it was based on, or it loses to a concurrent rewrite.
|
|
1025
692
|
return json(res, 200, { html: clean, hash: hash(clean) });
|
|
1026
693
|
}
|
|
1027
|
-
if (action === "comment" && req.method === "POST") {
|
|
1028
|
-
const body = await readBody(req);
|
|
1029
|
-
const kind = body.kind === "element" ? "element" : "selection";
|
|
1030
|
-
const feedback = String(body.feedback || "").trim();
|
|
1031
|
-
const comment = {
|
|
1032
|
-
id: uid("c"),
|
|
1033
|
-
kind,
|
|
1034
|
-
quote: String(body.quote || ""),
|
|
1035
|
-
anchor: normalizeCommentAnchor(kind, body.anchor || (kind === "selection" ? { quote: String(body.quote || "") } : null)),
|
|
1036
|
-
feedback,
|
|
1037
|
-
createdAt: Date.now(),
|
|
1038
|
-
};
|
|
1039
|
-
if (!comment.feedback)
|
|
1040
|
-
return json(res, 400, { error: "empty feedback" });
|
|
1041
|
-
if (!comment.anchor)
|
|
1042
|
-
return json(res, 400, { error: "invalid comment anchor" });
|
|
1043
|
-
store.addComment(key, comment);
|
|
1044
|
-
return json(res, 200, { comment, page: pageState(key) });
|
|
1045
|
-
}
|
|
1046
|
-
if (action === "comment" && req.method === "DELETE") {
|
|
1047
|
-
store.removeComment(key, tail);
|
|
1048
|
-
return json(res, 200, { page: pageState(key) });
|
|
1049
|
-
}
|
|
1050
|
-
if (action === "comment" && req.method === "PATCH" && tail) {
|
|
1051
|
-
const body = await readBody(req);
|
|
1052
|
-
const feedback = String(body.feedback || "").trim();
|
|
1053
|
-
if (!feedback)
|
|
1054
|
-
return json(res, 400, { error: "empty feedback" });
|
|
1055
|
-
const existing = store.page(key).comments.find((comment) => comment.id === tail);
|
|
1056
|
-
if (!existing)
|
|
1057
|
-
return json(res, 404, { error: "unknown comment" });
|
|
1058
|
-
const revised = store.reviseComment(key, tail, feedback, { replacementId: uid("c") });
|
|
1059
|
-
return json(res, 200, { delivery: revised.delivery, page: pageState(key) });
|
|
1060
|
-
}
|
|
1061
|
-
if (action === "edit" && req.method === "POST") {
|
|
1062
|
-
const body = await readBody(req);
|
|
1063
|
-
const label = String(body.label || "Document");
|
|
1064
|
-
const kind = body.kind === "deleted" ? "deleted" : body.kind === "moved" ? "moved" : "edited";
|
|
1065
|
-
const limited = limitEditFields({
|
|
1066
|
-
before: body.before, after: body.after, before_html: body.before_html, after_html: body.after_html,
|
|
1067
|
-
...(kind === "moved" ? { moved_after: body.moved_after, moved_before: body.moved_before } : {}),
|
|
1068
|
-
});
|
|
1069
|
-
const fields = limited.fields;
|
|
1070
|
-
const stagedRoot = path.join(stateDir(), "pasted", key);
|
|
1071
|
-
const stagedAssets = Array.isArray(body.staged_assets)
|
|
1072
|
-
? body.staged_assets
|
|
1073
|
-
.slice(0, 20)
|
|
1074
|
-
.map((asset) => {
|
|
1075
|
-
const id = String(asset?.id || "");
|
|
1076
|
-
return {
|
|
1077
|
-
id,
|
|
1078
|
-
path: path.join(stagedRoot, id),
|
|
1079
|
-
preview_src: String(asset?.preview_src || ""),
|
|
1080
|
-
};
|
|
1081
|
-
})
|
|
1082
|
-
.filter((asset) => {
|
|
1083
|
-
const relative = path.relative(stagedRoot, path.resolve(asset.path));
|
|
1084
|
-
return asset.id && path.basename(asset.id) === asset.id && !relative.startsWith("..") && !path.isAbsolute(relative) && fs.existsSync(asset.path);
|
|
1085
|
-
})
|
|
1086
|
-
.map(({ path: assetPath, preview_src }) => ({ path: assetPath, preview_src }))
|
|
1087
|
-
: [];
|
|
1088
|
-
const extra = {
|
|
1089
|
-
truncated: limited.truncated,
|
|
1090
|
-
truncated_fields: limited.truncated_fields,
|
|
1091
|
-
...(kind === "moved" ? { moved_after: fields.moved_after || "", moved_before: fields.moved_before || "" } : {}),
|
|
1092
|
-
...(stagedAssets.length ? { staged_assets: stagedAssets } : {}),
|
|
1093
|
-
...(feedbackOnly(key, body.savePolicy, body) ? { feedback_only: true } : {}),
|
|
1094
|
-
};
|
|
1095
|
-
store.addEdit(key, label, kind, fields.before, fields.after, fields.before_html, fields.after_html, extra);
|
|
1096
|
-
return json(res, 200, { page: pageState(key, sessions.get(body.sessionId)) });
|
|
1097
|
-
}
|
|
1098
|
-
// File reviews keep pasted images beside the document. Localhost
|
|
1099
|
-
// reviews stage them privately until the agent moves them into source.
|
|
1100
|
-
if (action === "asset" && req.method === "POST") {
|
|
1101
|
-
const page = store.page(key);
|
|
1102
|
-
const type = String(url.searchParams.get("type") || "");
|
|
1103
|
-
const ext = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }[type];
|
|
1104
|
-
if (!ext)
|
|
1105
|
-
return json(res, 400, { error: `unsupported image type: ${type || "unknown"}` });
|
|
1106
|
-
const bytes = await readRawBody(req);
|
|
1107
|
-
if (!bytes.length)
|
|
1108
|
-
return json(res, 400, { error: "empty image" });
|
|
1109
|
-
const staged = feedbackOnly(key, url.searchParams.get("savePolicy"), {
|
|
1110
|
-
sessionId: url.searchParams.get("sessionId"),
|
|
1111
|
-
renderId: url.searchParams.get("renderId"),
|
|
1112
|
-
generation: Number(url.searchParams.get("generation")),
|
|
1113
|
-
});
|
|
1114
|
-
const dir = staged ? path.join(stateDir(), "pasted", key) : path.join(path.dirname(page.file), "assets");
|
|
1115
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1116
|
-
const base = staged
|
|
1117
|
-
? "localhost"
|
|
1118
|
-
: path
|
|
1119
|
-
.basename(page.file)
|
|
1120
|
-
.replace(/\.[^.]+$/, "")
|
|
1121
|
-
.replace(/[^\w-]+/g, "-");
|
|
1122
|
-
let name = "";
|
|
1123
|
-
for (let n = 1;; n += 1) {
|
|
1124
|
-
name = `${base}-paste-${n}.${ext}`;
|
|
1125
|
-
if (!fs.existsSync(path.join(dir, name)))
|
|
1126
|
-
break;
|
|
1127
|
-
}
|
|
1128
|
-
const saved = path.join(dir, name);
|
|
1129
|
-
fs.writeFileSync(saved, bytes);
|
|
1130
|
-
return json(res, 200, {
|
|
1131
|
-
src: staged ? `__doc_review_paste__/${name}` : `assets/${name}`,
|
|
1132
|
-
...(staged ? { stagedId: name } : {}),
|
|
1133
|
-
});
|
|
1134
|
-
}
|
|
1135
|
-
if (action === "save" && req.method === "POST") {
|
|
1136
|
-
const page = store.page(key);
|
|
1137
|
-
// Rendered sources must never be overwritten with serialized browser HTML.
|
|
1138
|
-
if (page.kind === "url" || isMarkdown(page.file)) {
|
|
1139
|
-
return json(res, 400, { error: page.kind === "url" ? "localhost edits must be applied to app source" : "markdown pages are feedback-only" });
|
|
1140
|
-
}
|
|
1141
|
-
const body = await readFileWriteBody(req);
|
|
1142
|
-
if (typeof body?.html !== "string" || !body.html.trim()) {
|
|
1143
|
-
return json(res, 400, { error: "empty html" });
|
|
1144
|
-
}
|
|
1145
|
-
const savingRender = enforceFileWrite(key, body);
|
|
1146
|
-
const editedPolicy = sourcePolicy(page, stripSdk(body.html));
|
|
1147
|
-
try {
|
|
1148
|
-
const clean = writePage(key, body.html);
|
|
1149
|
-
savingRender.sourceHash = hash(clean);
|
|
1150
|
-
savingRender.sourceCapturedAt = new Date().toISOString();
|
|
1151
|
-
if (editedPolicy.savePolicy === "feedback-only") {
|
|
1152
|
-
for (const session of sessionsForKey(key)) {
|
|
1153
|
-
invalidateSessionRender(session);
|
|
1154
|
-
emit(session, "reload", { key, reason: "source-execution-changed" });
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
return json(res, 200, { savedAt: Date.now(), hash: hash(clean) });
|
|
1158
|
-
}
|
|
1159
|
-
catch (err) {
|
|
1160
|
-
return json(res, 500, { error: String(err.message || err) });
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
if (action === "revert" && req.method === "POST") {
|
|
1164
|
-
const page = store.page(key);
|
|
1165
|
-
if (page.kind === "url" || isMarkdown(page.file))
|
|
1166
|
-
return json(res, 400, { error: "feedback-only pages have no directly writable HTML to revert" });
|
|
1167
|
-
const body = await readFileWriteBody(req);
|
|
1168
|
-
enforceFileWrite(key, body);
|
|
1169
|
-
if (!page.pristine)
|
|
1170
|
-
return json(res, 400, { error: "nothing to revert to" });
|
|
1171
|
-
sourcePolicy(page, stripSdk(page.pristine));
|
|
1172
|
-
writePage(key, page.pristine);
|
|
1173
|
-
store.clearEdits(key);
|
|
1174
|
-
for (const session of sessionsForKey(key)) {
|
|
1175
|
-
invalidateSessionRender(session);
|
|
1176
|
-
emit(session, "reload", { key });
|
|
1177
|
-
}
|
|
1178
|
-
return json(res, 200, { page: pageState(key, sessions.get(body.sessionId)) });
|
|
1179
|
-
}
|
|
1180
|
-
if (action === "send" && req.method === "POST") {
|
|
1181
|
-
const body = await readBody(req);
|
|
1182
|
-
const result = sendBatch(body.sessionId, body.note, body.history);
|
|
1183
|
-
if (result.error)
|
|
1184
|
-
return json(res, 400, result);
|
|
1185
|
-
return json(res, 200, { ...result, page: pageState(key, sessions.get(body.sessionId)) });
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
// --- the user is done: stop the review, release the agent
|
|
1189
|
-
const endMatch = route.match(/^\/api\/session\/(\w+)\/end$/);
|
|
1190
|
-
if (endMatch && req.method === "POST") {
|
|
1191
|
-
const session = sessions.get(endMatch[1]);
|
|
1192
|
-
if (!session)
|
|
1193
|
-
return json(res, 404, { error: "unknown session" });
|
|
1194
|
-
endSession(session);
|
|
1195
|
-
return json(res, 200, { ok: true });
|
|
1196
694
|
}
|
|
1197
695
|
// --- which page a window is currently showing
|
|
1198
696
|
const bootMatch = route.match(/^\/api\/session\/(\w+)\/page$/);
|
|
@@ -1205,7 +703,10 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1205
703
|
key: session.activeKey,
|
|
1206
704
|
generation: session.generation,
|
|
1207
705
|
page: pageState(session.activeKey, session),
|
|
1208
|
-
others:
|
|
706
|
+
others: [],
|
|
707
|
+
...(session.reviewId ? { review: store.conversations.read({
|
|
708
|
+
operation: "read-review", reviewId: session.reviewId, entryKey: session.entryKey,
|
|
709
|
+
}) } : {}),
|
|
1209
710
|
});
|
|
1210
711
|
}
|
|
1211
712
|
// --- jump straight to a page already in this window
|
|
@@ -1216,6 +717,9 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1216
717
|
return json(res, 404, { error: "unknown session" });
|
|
1217
718
|
seen(session);
|
|
1218
719
|
const body = await readBody(req);
|
|
720
|
+
if (session.reviewId && !Object.hasOwn(store.data.conversations.reviews[session.reviewId].pages, body.key)) {
|
|
721
|
+
throw new ContractError("SCOPE_MISMATCH", "Join the page through the versioned review before navigating.");
|
|
722
|
+
}
|
|
1219
723
|
if (!store.page(body.key))
|
|
1220
724
|
return json(res, 404, { error: "unknown page" });
|
|
1221
725
|
invalidateSessionRender(session);
|
|
@@ -1224,13 +728,19 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1224
728
|
return json(res, 200, { key: body.key });
|
|
1225
729
|
}
|
|
1226
730
|
// --- navigation between local files or localhost routes in one window
|
|
1227
|
-
const navMatch = route.match(/^\/api\/session\/(\w+)\/navigate$/);
|
|
731
|
+
const navMatch = route.match(/^\/api\/session\/(\w+)\/(navigate|resolve-target)$/);
|
|
1228
732
|
if (navMatch && req.method === "POST") {
|
|
1229
733
|
const session = sessions.get(navMatch[1]);
|
|
1230
734
|
if (!session)
|
|
1231
735
|
return json(res, 404, { error: "unknown session" });
|
|
736
|
+
const resolveOnly = navMatch[2] === "resolve-target";
|
|
737
|
+
if (session.reviewId && !resolveOnly)
|
|
738
|
+
throw new ContractError("INVALID_INPUT", "Use versioned join-page followed by goto.");
|
|
1232
739
|
seen(session);
|
|
1233
740
|
const body = await readBody(req);
|
|
741
|
+
if (resolveOnly && (typeof body.href !== "string" || Object.keys(body).some((key) => key !== "href"))) {
|
|
742
|
+
throw new ContractError("INVALID_INPUT", "Navigation resolution requires only href.");
|
|
743
|
+
}
|
|
1234
744
|
const from = store.page(session.activeKey);
|
|
1235
745
|
if (!from)
|
|
1236
746
|
return json(res, 404, { error: "unknown page" });
|
|
@@ -1239,6 +749,8 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1239
749
|
const target = canonicalTarget(nextUrl);
|
|
1240
750
|
if (target.kind !== "url")
|
|
1241
751
|
return json(res, 400, { error: "not a localhost route" });
|
|
752
|
+
if (resolveOnly)
|
|
753
|
+
return json(res, 200, { target: target.value });
|
|
1242
754
|
await fetchLocalPage(target.value);
|
|
1243
755
|
const page = store.openUrl(target.value);
|
|
1244
756
|
invalidateSessionRender(session);
|
|
@@ -1250,6 +762,8 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1250
762
|
if (!targetFile || !fs.existsSync(targetFile) || !/\.(x?html?|md|markdown)$/i.test(targetFile)) {
|
|
1251
763
|
return json(res, 400, { error: "not a local html or markdown page" });
|
|
1252
764
|
}
|
|
765
|
+
if (resolveOnly)
|
|
766
|
+
return json(res, 200, { target: targetFile });
|
|
1253
767
|
const html = fs.readFileSync(targetFile, "utf8");
|
|
1254
768
|
const page = store.openPage(targetFile, stripSdk(html));
|
|
1255
769
|
lastWritten.set(page.key, hash(stripSdk(html)));
|
|
@@ -1274,7 +788,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1274
788
|
res.write(": open\n\n");
|
|
1275
789
|
session.clients.add(res);
|
|
1276
790
|
seen(session);
|
|
1277
|
-
emit(session, "
|
|
791
|
+
emit(session, "invalidate", { reviewId: session.reviewId });
|
|
1278
792
|
const beat = setInterval(() => res.write(": beat\n\n"), POLL_HEARTBEAT_MS);
|
|
1279
793
|
sseResponses.set(res, beat);
|
|
1280
794
|
req.on("close", () => {
|
|
@@ -1285,40 +799,12 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1285
799
|
});
|
|
1286
800
|
return undefined;
|
|
1287
801
|
}
|
|
1288
|
-
// --- the agent long-poll
|
|
1289
|
-
if (route === "/api/poll") {
|
|
1290
|
-
const target = url.searchParams.get("target") || url.searchParams.get("file") || "";
|
|
1291
|
-
const entryKey = targetKey(target);
|
|
1292
|
-
const ackId = url.searchParams.get("ack");
|
|
1293
|
-
if (ackId !== null)
|
|
1294
|
-
ack(entryKey, ackId);
|
|
1295
|
-
const pending = store.batch(entryKey);
|
|
1296
|
-
if (pending) {
|
|
1297
|
-
const delivered = store.markBatchDelivered(entryKey);
|
|
1298
|
-
broadcastAgent(entryKey);
|
|
1299
|
-
return json(res, 200, delivered.batch);
|
|
1300
|
-
}
|
|
1301
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1302
|
-
res.write(" ");
|
|
1303
|
-
const set = pollers.get(entryKey) || new Set();
|
|
1304
|
-
pollers.set(entryKey, set);
|
|
1305
|
-
const poller = {
|
|
1306
|
-
res,
|
|
1307
|
-
timer: setInterval(() => res.write(" "), POLL_HEARTBEAT_MS),
|
|
1308
|
-
};
|
|
1309
|
-
set.add(poller);
|
|
1310
|
-
broadcastAgent(entryKey);
|
|
1311
|
-
req.on("close", () => {
|
|
1312
|
-
clearInterval(poller.timer);
|
|
1313
|
-
set.delete(poller);
|
|
1314
|
-
broadcastAgent(entryKey);
|
|
1315
|
-
});
|
|
1316
|
-
return undefined;
|
|
1317
|
-
}
|
|
1318
802
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
1319
803
|
return res.end("Not found");
|
|
1320
804
|
}
|
|
1321
805
|
catch (err) {
|
|
806
|
+
if (err instanceof ContractError)
|
|
807
|
+
return json(res, err.status, conversationFailure(err));
|
|
1322
808
|
if (err instanceof HistoryRequestError) {
|
|
1323
809
|
return json(res, err.status, { error: err.message, code: err.code, targets: err.targets });
|
|
1324
810
|
}
|
|
@@ -1376,7 +862,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1376
862
|
}
|
|
1377
863
|
// Busy means a connected browser or a listening agent — a session record
|
|
1378
864
|
// alone must not keep the process alive forever.
|
|
1379
|
-
const busy = [...sessions.values()].some((s) => s.clients.size > 0)
|
|
865
|
+
const busy = [...sessions.values()].some((s) => s.clients.size > 0);
|
|
1380
866
|
if (!busy && now - lastActivity > IDLE_SHUTDOWN_MS) {
|
|
1381
867
|
void dispose().catch((err) => {
|
|
1382
868
|
console.error(`doc-review server shutdown failed: ${err.message}`);
|
|
@@ -1395,14 +881,6 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
|
|
|
1395
881
|
fs.unwatchFile(entry.file);
|
|
1396
882
|
watched.clear();
|
|
1397
883
|
lastWritten.clear();
|
|
1398
|
-
for (const set of pollers.values()) {
|
|
1399
|
-
for (const poller of set) {
|
|
1400
|
-
clearInterval(poller.timer);
|
|
1401
|
-
if (!poller.res.writableEnded)
|
|
1402
|
-
poller.res.end();
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
pollers.clear();
|
|
1406
884
|
for (const [res, timer] of sseResponses) {
|
|
1407
885
|
clearInterval(timer);
|
|
1408
886
|
if (!res.writableEnded)
|