@shiyuphan/follow-me 0.1.1 → 0.2.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.
@@ -1,9 +1,12 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { createServer } from "node:http";
4
- import { isAbsolute, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { basename, isAbsolute, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
- import { isPlanVersion } from "./plan.js";
7
+ import { isReplyId, parseComment, parseDraft, parseNewDecision, parseNewReply, } from "./comments.js";
8
+ import { isConcernId, isPlanVersion } from "./plan.js";
9
+ import { renderReport } from "./prompt.js";
7
10
  import { isLockToken } from "./session.js";
8
11
  /** The requested port is taken; host.ts decides whether that is our server or a stranger. */
9
12
  export class PortInUseError extends Error {
@@ -21,15 +24,21 @@ export class RegistrationError extends Error {
21
24
  /** GET /api/health answers with this, so a CLI can tell our server from another program. */
22
25
  export const HEALTH_APP = "follow-me";
23
26
  const UI_DIR = new URL("../ui/", import.meta.url);
24
- const STATIC_FILES = new Map([
25
- ["/app.js", { file: "app.js", type: "text/javascript; charset=utf-8" }],
26
- ["/playback.js", { file: "playback.js", type: "text/javascript; charset=utf-8" }],
27
- ["/style.css", { file: "style.css", type: "text/css; charset=utf-8" }],
27
+ /** Every module and the stylesheet in ui/ is served flat, so a new module needs no route. */
28
+ const STATIC_FILE = /^\/([a-z][a-z-]*\.(?:js|css))$/;
29
+ const STATIC_TYPES = new Map([
30
+ ["js", "text/javascript; charset=utf-8"],
31
+ ["css", "text/css; charset=utf-8"],
28
32
  ]);
33
+ /** Mermaid's ESM entry imports its chunks by relative path, so they are served beside it. */
34
+ const MERMAID_ENTRY = new URL(import.meta.resolve("mermaid/dist/mermaid.esm.min.mjs"));
35
+ const MERMAID_CHUNK = /^\/vendor\/chunks\/mermaid\.esm\.min\/([\w-]+\.mjs)$/;
36
+ /** Prism is a plain script per component, loaded one at a time by the page as languages need. */
37
+ const PRISM_CORE = new URL(import.meta.resolve("prismjs/components/prism-core.min.js"));
38
+ const PRISM_PATH = /^\/vendor\/prism\/([a-z][a-z-]*)\.js$/;
29
39
  const MAX_BODY_BYTES = 1_000_000;
30
40
  const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
31
41
  const COMMENT_ID = /^c_[a-z2-7]{6}$/;
32
- const UNIT_ID = /^[hfm]_[0-9a-f]{12}(~\d+)?$/;
33
42
  const SESSION_ID = /^[0-9a-f]{12}$/;
34
43
  function isRecord(value) {
35
44
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -37,15 +46,6 @@ function isRecord(value) {
37
46
  function isCommentId(value) {
38
47
  return typeof value === "string" && COMMENT_ID.test(value);
39
48
  }
40
- function isUnitId(value) {
41
- return typeof value === "string" && UNIT_ID.test(value);
42
- }
43
- function isRepoPath(value) {
44
- return typeof value === "string" && value.length > 0;
45
- }
46
- function isIndex(value) {
47
- return typeof value === "number" && Number.isInteger(value) && value >= 0;
48
- }
49
49
  export function isSessionId(value) {
50
50
  return typeof value === "string" && SESSION_ID.test(value);
51
51
  }
@@ -59,50 +59,6 @@ export function sessionIdOf(dir) {
59
59
  export function sessionPath(id) {
60
60
  return `/s/${id}/`;
61
61
  }
62
- function parseAnchor(raw) {
63
- if (!isRecord(raw))
64
- return null;
65
- const commit = raw["commit"];
66
- switch (raw["kind"]) {
67
- case "plan":
68
- return { kind: "plan" };
69
- case "message":
70
- return isIndex(commit) ? { kind: "message", commit } : null;
71
- case "position":
72
- return isIndex(commit) ? { kind: "position", commit } : null;
73
- case "hunk": {
74
- const unit = raw["unit"];
75
- const changes = raw["changes"];
76
- if (!isIndex(commit) || !isUnitId(unit))
77
- return null;
78
- if (changes === null)
79
- return { kind: "hunk", unit, commit, changes: null };
80
- if (!Array.isArray(changes) || !changes.every(isIndex))
81
- return null;
82
- return { kind: "hunk", unit, commit, changes };
83
- }
84
- default:
85
- return null;
86
- }
87
- }
88
- /** The one place untrusted JSON becomes a Comment. Returns null when any field is wrong. */
89
- export function parseComment(raw) {
90
- if (!isRecord(raw))
91
- return null;
92
- const { id, body, createdAt } = raw;
93
- const context = raw["context"];
94
- const anchor = parseAnchor(raw["anchor"]);
95
- if (!isCommentId(id) || !anchor || !isRecord(context))
96
- return null;
97
- const { commitMessage, path } = context;
98
- if (typeof commitMessage !== "string" || typeof body !== "string")
99
- return null;
100
- if (typeof createdAt !== "string")
101
- return null;
102
- if (path !== null && !isRepoPath(path))
103
- return null;
104
- return { id, anchor, context: { commitMessage, path }, body, createdAt };
105
- }
106
62
  /** The one place untrusted JSON becomes a Registration. */
107
63
  export function parseRegistration(raw) {
108
64
  if (!isRecord(raw))
@@ -121,25 +77,26 @@ export function parseRegistration(raw) {
121
77
  function draftPath(reviewsDir, version) {
122
78
  return join(reviewsDir, `v${version}.draft.json`);
123
79
  }
124
- /** Comments autosaved by an unfinished review of this version, or none. */
125
- export async function loadDraftComments(reviewsDir, version) {
80
+ /** What an unfinished review of this version autosaved, or nothing. */
81
+ export async function loadDraft(reviewsDir, version) {
126
82
  let text;
127
83
  try {
128
84
  text = await readFile(draftPath(reviewsDir, version), "utf8");
129
85
  }
130
86
  catch (error) {
131
- if (isRecord(error) && error["code"] === "ENOENT")
132
- return [];
87
+ if (isRecord(error) && error["code"] === "ENOENT") {
88
+ return { comments: [], concerns: [], resolved: [] };
89
+ }
133
90
  throw error;
134
91
  }
135
- const raw = JSON.parse(text);
136
- const list = isRecord(raw) ? raw["comments"] : null;
137
- if (!Array.isArray(list))
92
+ const draft = parseDraft(JSON.parse(text));
93
+ if (typeof draft === "string")
138
94
  throw new Error(`malformed review draft for v${version}`);
139
- return list.flatMap((item) => {
140
- const comment = parseComment(item);
141
- return comment ? [comment] : [];
142
- });
95
+ return draft;
96
+ }
97
+ /** Comments autosaved by an unfinished review of this version, or none. */
98
+ export async function loadDraftComments(reviewsDir, version) {
99
+ return (await loadDraft(reviewsDir, version)).comments;
143
100
  }
144
101
  async function writeAtomic(path, text) {
145
102
  // The pid lets `follow-me clean` tell an abandoned temp file from one being written.
@@ -184,6 +141,17 @@ function sendJson(res, status, body) {
184
141
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
185
142
  res.end(JSON.stringify(body));
186
143
  }
144
+ /** An integer query parameter within [min, max], else the default when absent; bad is a 400. */
145
+ function queryInt(url, name, min, max, fallback) {
146
+ const raw = url.searchParams.get(name);
147
+ if (raw === null)
148
+ return fallback;
149
+ const n = Number(raw);
150
+ if (raw === "" || !Number.isInteger(n) || n < min) {
151
+ throw new HttpError(400, `${name} must be an integer of at least ${min}`);
152
+ }
153
+ return Math.min(n, max);
154
+ }
187
155
  function sendHtml(res, status, html) {
188
156
  res.writeHead(status, {
189
157
  "content-type": "text/html; charset=utf-8",
@@ -194,15 +162,47 @@ function sendHtml(res, status, html) {
194
162
  function escapeHtml(text) {
195
163
  return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
196
164
  }
197
- function indexPage(sessions) {
198
- const rows = sessions.map((s) => {
199
- const what = s.mode === "walk" ? "code review" : `review of plan v${s.version}`;
165
+ /** "just now", "5 min ago", "2 h ago" or "3 days ago". */
166
+ export function ago(ms) {
167
+ const minutes = Math.floor(ms / 60_000);
168
+ if (minutes < 1)
169
+ return "just now";
170
+ if (minutes < 60)
171
+ return `${minutes} min ago`;
172
+ const hours = Math.floor(minutes / 60);
173
+ if (hours < 24)
174
+ return `${hours} h ago`;
175
+ const days = Math.floor(hours / 24);
176
+ return `${days} day${days === 1 ? "" : "s"} ago`;
177
+ }
178
+ function tildePath(path) {
179
+ const home = homedir();
180
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
181
+ }
182
+ function indexPage(rows, now) {
183
+ const items = rows.map(({ summary: s, openedAt }) => {
184
+ const mode = s.mode === "walk"
185
+ ? '<span class="mode walk">code review</span>'
186
+ : `<span class="mode split">plan review v${s.version}</span>`;
200
187
  const count = `${s.comments} comment${s.comments === 1 ? "" : "s"}`;
201
- return ` <li><a href="${s.url}">${escapeHtml(s.branch)}</a> <span class="muted">${what} · ${count} · ${escapeHtml(s.repo)}</span></li>`;
188
+ const repo = escapeHtml(basename(s.repo));
189
+ return ` <li>
190
+ <a class="session" href="${s.url}"><span class="repo">${repo}</span> <span class="branch">${escapeHtml(s.branch)}</span></a>
191
+ ${mode}
192
+ <span class="muted">${count} · opened ${ago(now - openedAt)}</span>
193
+ <span class="path muted">${escapeHtml(tildePath(s.repo))}</span>
194
+ </li>`;
202
195
  });
203
- const body = rows.length === 0
204
- ? ' <p class="muted">No sessions. Run follow-me review or follow-me walk in a repo.</p>'
205
- : ` <ul class="session-index">\n${rows.join("\n")}\n </ul>`;
196
+ const body = items.length === 0
197
+ ? ` <section class="empty">
198
+ <h1>No sessions yet</h1>
199
+ <p>Run <code>follow-me review</code> to review a plan, or <code>follow-me walk</code> to read a branch someone else wrote. Each one opens here.</p>
200
+ <p><a href="https://github.com/phanshiyu/follow-me#readme">How follow-me works</a></p>
201
+ </section>`
202
+ : ` <h1>Sessions</h1>
203
+ <ul class="session-index">
204
+ ${items.join("\n")}
205
+ </ul>`;
206
206
  return `<!doctype html>
207
207
  <html lang="en">
208
208
  <head>
@@ -213,109 +213,368 @@ function indexPage(sessions) {
213
213
  <link rel="stylesheet" href="/style.css" />
214
214
  </head>
215
215
  <body class="index">
216
- <h1>follow-me sessions</h1>
216
+ <header class="topbar"><strong>follow-me</strong></header>
217
+ <main class="index-main">
217
218
  ${body}
219
+ </main>
218
220
  </body>
219
221
  </html>
220
222
  `;
221
223
  }
222
- /** One hosted review: its comments, its autosave chain, and how it ends. */
223
- function hostSession(id, registration, loaded) {
224
+ /** The file behind a /vendor/ path, or null. Chunk names are checked, so no path escapes. */
225
+ function vendorFile(path) {
226
+ const part = PRISM_PATH.exec(path)?.[1];
227
+ if (part !== undefined) {
228
+ return part === "core" ? PRISM_CORE : new URL(`prism-${part}.min.js`, PRISM_CORE);
229
+ }
230
+ if (path === "/vendor/mermaid.mjs")
231
+ return MERMAID_ENTRY;
232
+ const chunk = MERMAID_CHUNK.exec(path)?.[1];
233
+ return chunk === undefined ? null : new URL(`chunks/mermaid.esm.min/${chunk}`, MERMAID_ENTRY);
234
+ }
235
+ async function readUi(name) {
236
+ try {
237
+ return await readFile(fileURLToPath(new URL(name, UI_DIR)));
238
+ }
239
+ catch (error) {
240
+ if (isRecord(error) && error["code"] === "ENOENT")
241
+ throw new HttpError(404, "not found");
242
+ throw error;
243
+ }
244
+ }
245
+ async function sendVendor(res, file) {
246
+ let bytes;
247
+ try {
248
+ bytes = await readFile(fileURLToPath(file));
249
+ }
250
+ catch (error) {
251
+ if (isRecord(error) && error["code"] === "ENOENT")
252
+ throw new HttpError(404, "not found");
253
+ throw error;
254
+ }
255
+ // The bundle only changes with the installed package, and it is several megabytes.
256
+ res.writeHead(200, {
257
+ "content-type": "text/javascript; charset=utf-8",
258
+ "cache-control": "max-age=86400",
259
+ });
260
+ res.end(bytes);
261
+ }
262
+ function viewOf(registration, loaded, draft) {
224
263
  const { state, reviewsDir } = loaded;
225
- const comments = new Map(state.comments.map((c) => [c.id, c]));
226
- const unitIds = new Set(state.units.map((u) => u.id));
227
- const draftFile = draftPath(reviewsDir, state.version);
228
- const reviewFile = join(reviewsDir, `v${state.version}.json`);
264
+ return {
265
+ registration,
266
+ loaded,
267
+ comments: new Map(state.comments.map((c) => [c.id, c])),
268
+ decisions: new Map(draft.concerns.map((d) => [d.concern, d])),
269
+ resolved: new Set(draft.resolved),
270
+ unitIds: new Set(state.units.map((u) => u.id)),
271
+ concernIds: new Set(state.commits.flatMap((c) => (c.concerns ?? []).map((k) => k.id))),
272
+ draftFile: draftPath(reviewsDir, state.version),
273
+ reviewFile: join(reviewsDir, `v${state.version}.json`),
274
+ };
275
+ }
276
+ function withReplies(comment, replies) {
277
+ const { replies: _dropped, ...rest } = comment;
278
+ return replies === undefined || replies.length === 0 ? rest : { ...rest, replies };
279
+ }
280
+ function mintReplyId(taken) {
281
+ for (;;) {
282
+ let id = "r_";
283
+ for (const byte of randomBytes(6))
284
+ id += BASE32.charAt(byte % 32);
285
+ if (isReplyId(id) && !taken.has(id))
286
+ return id;
287
+ }
288
+ }
289
+ const DEFAULT_POLL_MS = 25_000;
290
+ const MAX_POLL_MS = 60_000;
291
+ const STREAM_PING_MS = 15_000;
292
+ /** One hosted review: its comments, its event log, its autosave chain, and how it ends. */
293
+ function hostSession(id, registration, loaded, draft, load) {
294
+ const openedAt = Date.now();
295
+ let view = viewOf(registration, loaded, draft);
229
296
  let finish = () => { };
230
297
  const done = new Promise((resolve) => {
231
298
  finish = resolve;
232
299
  });
300
+ // The append-only log a watcher long-polls. seq is the position, counted from 1.
301
+ const events = [];
302
+ const waiters = new Set();
303
+ const listeners = new Set();
304
+ const emit = (body) => {
305
+ events.push({ ...body, seq: events.length + 1 });
306
+ for (const wake of [...waiters])
307
+ wake();
308
+ for (const listener of [...listeners])
309
+ listener();
310
+ };
311
+ const waitForEvents = (since, timeoutMs, signal) => new Promise((resolve) => {
312
+ const answer = () => resolve({ seq: events.length, events: events.slice(since) });
313
+ if (events.length > since || timeoutMs === 0 || signal.aborted)
314
+ return answer();
315
+ const wake = () => {
316
+ clearTimeout(timer);
317
+ waiters.delete(wake);
318
+ signal.removeEventListener("abort", wake);
319
+ answer();
320
+ };
321
+ const timer = setTimeout(wake, timeoutMs);
322
+ waiters.add(wake);
323
+ signal.addEventListener("abort", wake);
324
+ });
233
325
  let ending = false;
326
+ let swapping = false;
327
+ const open = () => {
328
+ if (ending)
329
+ throw new HttpError(409, "this review is already ending");
330
+ if (swapping)
331
+ throw new HttpError(409, "the session is switching plan versions; try again");
332
+ };
234
333
  // Writes are chained so two rapid edits never race on the same file.
235
334
  let writes = Promise.resolve();
236
335
  const saveDraft = () => {
336
+ const { state } = view.loaded;
337
+ const file = view.draftFile;
338
+ const concerns = [...view.decisions.values()];
339
+ const resolved = [...view.resolved];
237
340
  const snapshot = JSON.stringify({
238
341
  schema: "follow-me/review-draft@1",
239
342
  planVersion: state.version,
240
- comments: [...comments.values()],
343
+ comments: [...view.comments.values()],
344
+ ...(concerns.length === 0 ? {} : { concerns }),
345
+ ...(resolved.length === 0 ? {} : { resolved }),
241
346
  }, null, 2);
347
+ const dir = view.loaded.reviewsDir;
242
348
  writes = writes.then(async () => {
243
- await mkdir(reviewsDir, { recursive: true });
244
- await writeAtomic(draftFile, `${snapshot}\n`);
349
+ await mkdir(dir, { recursive: true });
350
+ await writeAtomic(file, `${snapshot}\n`);
245
351
  });
246
352
  return writes;
247
353
  };
248
- const accept = (comment) => {
249
- if (!comment)
250
- throw new HttpError(400, "malformed comment");
251
- const { anchor } = comment;
252
- if (anchor.kind !== "plan" && anchor.commit >= state.commits.length) {
354
+ const accept = (parsed) => {
355
+ if (typeof parsed === "string")
356
+ throw new HttpError(400, `malformed comment: ${parsed}`);
357
+ if (!COMMENT_ID.test(parsed.id))
358
+ throw new HttpError(400, "malformed comment: bad id");
359
+ const { anchor } = parsed;
360
+ if (anchor.kind !== "plan" && anchor.commit >= view.loaded.state.commits.length) {
253
361
  throw new HttpError(400, "anchor commit out of range");
254
362
  }
255
- if (anchor.kind === "hunk" && !unitIds.has(anchor.unit)) {
363
+ if (anchor.kind === "hunk" && !view.unitIds.has(anchor.unit)) {
256
364
  throw new HttpError(400, "anchor names an unknown unit");
257
365
  }
258
- return comment;
366
+ return parsed;
259
367
  };
368
+ const review = () => {
369
+ const concerns = [...view.decisions.values()];
370
+ const resolved = [...view.resolved];
371
+ return {
372
+ schema: "follow-me/review@1",
373
+ mode: view.loaded.state.mode,
374
+ planVersion: view.loaded.state.version,
375
+ planHash: view.loaded.state.planHash,
376
+ endedAt: new Date().toISOString(),
377
+ comments: [...view.comments.values()],
378
+ ...(concerns.length === 0 ? {} : { concerns }),
379
+ ...(resolved.length === 0 ? {} : { resolved }),
380
+ };
381
+ };
382
+ const report = (finished) => {
383
+ const { state } = view.loaded;
384
+ const units = new Map(state.units.map((u) => [u.id, u]));
385
+ return renderReport(finished, state.commits, units, {
386
+ branch: state.anchor.branch,
387
+ responses: [],
388
+ });
389
+ };
390
+ /** Writes reviews/vN.json and its report once, and removes the autosave. */
391
+ const writeReview = async (finished) => {
392
+ await writes;
393
+ await mkdir(view.loaded.reviewsDir, { recursive: true });
394
+ await writeAtomic(view.reviewFile, `${JSON.stringify(finished, null, 2)}\n`);
395
+ await writeAtomic(view.reviewFile.replace(/\.json$/, ".md"), report(finished));
396
+ await rm(view.draftFile, { force: true });
397
+ return view.reviewFile;
398
+ };
399
+ const hasWork = () => view.comments.size > 0 || view.decisions.size > 0;
260
400
  return {
261
401
  id,
262
- registration,
263
402
  done,
403
+ openedAt,
404
+ get registration() {
405
+ return view.registration;
406
+ },
264
407
  summary: () => ({
265
408
  id,
266
409
  url: sessionPath(id),
267
- repo: loaded.repo,
268
- branch: state.anchor.branch,
269
- mode: state.mode,
270
- version: state.version,
271
- comments: comments.size,
410
+ repo: view.loaded.repo,
411
+ branch: view.loaded.state.anchor.branch,
412
+ mode: view.loaded.state.mode,
413
+ version: view.loaded.state.version,
414
+ comments: view.comments.size,
272
415
  }),
273
- snapshot: () => ({ ...state, comments: [...comments.values()] }),
416
+ snapshot: () => ({
417
+ ...view.loaded.state,
418
+ comments: [...view.comments.values()],
419
+ concerns: [...view.decisions.values()],
420
+ resolved: [...view.resolved],
421
+ seq: events.length,
422
+ previous: view.loaded.previous,
423
+ }),
424
+ /** Events after `since`, now and as they happen, until unfollowed or the review ends. */
425
+ latest: () => events.length,
426
+ follow: (since, send, stop) => {
427
+ if (since > events.length) {
428
+ throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
429
+ }
430
+ let last = since;
431
+ const flush = () => {
432
+ for (const event of events.slice(last)) {
433
+ send(event);
434
+ last = event.seq;
435
+ if (event.type === "ended") {
436
+ listeners.delete(flush);
437
+ stop();
438
+ return;
439
+ }
440
+ }
441
+ };
442
+ listeners.add(flush);
443
+ flush();
444
+ return () => listeners.delete(flush);
445
+ },
446
+ poll: (since, timeoutMs, signal) => {
447
+ if (since > events.length) {
448
+ throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
449
+ }
450
+ return waitForEvents(since, timeoutMs, signal);
451
+ },
452
+ report: () => report(review()),
274
453
  add: async (raw) => {
454
+ open();
275
455
  if (!isRecord(raw))
276
456
  throw new HttpError(400, "malformed comment");
277
- const commentId = mintCommentId(new Set(comments.keys()));
278
- const comment = accept(parseComment({ ...raw, id: commentId, createdAt: new Date().toISOString() }));
279
- comments.set(commentId, comment);
457
+ const commentId = mintCommentId(new Set(view.comments.keys()));
458
+ const comment = withReplies(accept(parseComment({ ...raw, id: commentId, createdAt: new Date().toISOString() })), undefined);
459
+ view.comments.set(commentId, comment);
460
+ emit({ type: "comment", comment });
280
461
  await saveDraft();
281
462
  return commentId;
282
463
  },
283
464
  put: async (target, raw) => {
284
- const comment = accept(parseComment(raw));
285
- if (comment.id !== target)
465
+ open();
466
+ const parsed = withReplies(accept(parseComment(raw)), undefined);
467
+ if (parsed.id !== target)
286
468
  throw new HttpError(400, "comment id does not match the URL");
287
- comments.set(comment.id, comment);
469
+ // Replies belong to the conversation, not to the page's copy of the comment.
470
+ const comment = withReplies(parsed, view.comments.get(target)?.replies);
471
+ view.comments.set(comment.id, comment);
472
+ emit({ type: "comment-updated", comment });
288
473
  await saveDraft();
289
474
  return comment;
290
475
  },
291
476
  remove: async (target) => {
292
- if (!comments.delete(target))
477
+ open();
478
+ const gone = view.comments.get(target);
479
+ if (gone === undefined)
480
+ throw new HttpError(404, "no such comment");
481
+ view.comments.delete(target);
482
+ emit({ type: "comment-deleted", id: gone.id });
483
+ await saveDraft();
484
+ },
485
+ reply: async (target, raw) => {
486
+ open();
487
+ const comment = view.comments.get(target);
488
+ if (comment === undefined)
293
489
  throw new HttpError(404, "no such comment");
490
+ const parsed = parseNewReply(raw);
491
+ if (typeof parsed === "string")
492
+ throw new HttpError(400, `malformed reply: ${parsed}`);
493
+ const taken = new Set([...view.comments.values()].flatMap((c) => c.replies ?? []).map((r) => r.id));
494
+ const reply = {
495
+ id: mintReplyId(taken),
496
+ ...parsed,
497
+ createdAt: new Date().toISOString(),
498
+ };
499
+ view.comments.set(comment.id, { ...comment, replies: [...(comment.replies ?? []), reply] });
500
+ emit({ type: "reply", commentId: comment.id, reply });
501
+ await saveDraft();
502
+ return reply.id;
503
+ },
504
+ decide: async (target, raw) => {
505
+ open();
506
+ if (!isConcernId(target) || !view.concernIds.has(target)) {
507
+ throw new HttpError(404, "no such concern in this plan");
508
+ }
509
+ const decision = parseNewDecision(target, raw);
510
+ if (typeof decision === "string")
511
+ throw new HttpError(400, `malformed decision: ${decision}`);
512
+ view.decisions.set(target, decision);
513
+ const { concern: concernId, ...rest } = decision;
514
+ emit({ type: "concern", concernId, ...rest });
294
515
  await saveDraft();
516
+ return decision;
295
517
  },
296
- /** Writes reviews/vN.json exactly once and releases the session's lock. */
518
+ resolve: async (raw) => {
519
+ open();
520
+ const body = isRecord(raw) ? raw : {};
521
+ const target = body["comment"];
522
+ const asked = view.loaded.previous?.comments.find((c) => c.id === target);
523
+ if (asked === undefined || typeof body["resolved"] !== "boolean") {
524
+ throw new HttpError(400, "expected { comment: <id of a question of the previous version>, resolved }");
525
+ }
526
+ if (body["resolved"])
527
+ view.resolved.add(asked.id);
528
+ else
529
+ view.resolved.delete(asked.id);
530
+ emit({ type: "resolved", commentId: asked.id, resolved: body["resolved"] });
531
+ await saveDraft();
532
+ return [...view.resolved];
533
+ },
534
+ /**
535
+ * Moves the session to a newer frozen plan under the same id and URL. The new version loads
536
+ * first, so a plan that no longer fits leaves the old one untouched; then the old version's
537
+ * review is finalized if anything was said, and the comment set starts fresh.
538
+ */
539
+ swap: async (version, hash) => {
540
+ open();
541
+ const { state } = view.loaded;
542
+ if (version === state.version) {
543
+ if (hash === state.planHash)
544
+ return;
545
+ throw new HttpError(409, `plan v${version} is already open with another hash`);
546
+ }
547
+ if (version < state.version) {
548
+ throw new HttpError(409, `plan v${version} is older than the open v${state.version}`);
549
+ }
550
+ swapping = true;
551
+ try {
552
+ await writes;
553
+ const next = await load({ ...view.registration, version, hash, lockToken: null }, view.loaded.release);
554
+ if (hasWork())
555
+ await writeReview(review());
556
+ else
557
+ await rm(view.draftFile, { force: true });
558
+ view = viewOf({ ...view.registration, version, hash }, next, await loadDraft(next.reviewsDir, version));
559
+ emit({ type: "version", version, hash });
560
+ }
561
+ finally {
562
+ swapping = false;
563
+ }
564
+ },
565
+ /** Writes reviews/vN.json and vN.md exactly once and releases the session's lock. */
297
566
  end: async () => {
298
- if (ending)
299
- throw new HttpError(409, "this review is already ending");
567
+ open();
300
568
  ending = true;
301
- await writes;
302
- const review = {
303
- schema: "follow-me/review@1",
304
- mode: state.mode,
305
- planVersion: state.version,
306
- planHash: state.planHash,
307
- endedAt: new Date().toISOString(),
308
- comments: [...comments.values()],
309
- };
310
- await mkdir(reviewsDir, { recursive: true });
311
- await writeAtomic(reviewFile, `${JSON.stringify(review, null, 2)}\n`);
312
- await rm(draftFile, { force: true });
313
- loaded.release();
314
- return { review, file: reviewFile };
569
+ const finished = review();
570
+ const file = await writeReview(finished);
571
+ view.loaded.release();
572
+ emit({ type: "ended", review: file });
573
+ return { review: finished, file };
315
574
  },
316
- finish: (review) => finish(review),
575
+ finish: (finished) => finish(finished),
317
576
  abandon: () => {
318
- loaded.release();
577
+ view.loaded.release();
319
578
  finish(null);
320
579
  },
321
580
  };
@@ -380,7 +639,9 @@ export function startServer(opts) {
380
639
  return { summary: existing.summary(), done: existing.done };
381
640
  }
382
641
  clearTimeout(idleTimer);
383
- const loading = opts.load(registration).then((loaded) => hostSession(id, registration, loaded));
642
+ const loading = opts
643
+ .load(registration, null)
644
+ .then(async (loaded) => hostSession(id, registration, loaded, await loadDraft(loaded.reviewsDir, loaded.state.version), opts.load));
384
645
  pending.set(id, loading);
385
646
  try {
386
647
  const session = await loading;
@@ -408,16 +669,26 @@ export function startServer(opts) {
408
669
  if (origin !== undefined && origin !== `http://${host}`)
409
670
  throw new HttpError(403, "bad origin");
410
671
  const method = req.method ?? "GET";
411
- const path = new URL(req.url ?? "/", "http://localhost").pathname;
412
- const asset = method === "GET" ? STATIC_FILES.get(path) : undefined;
413
- if (asset) {
414
- const bytes = await readFile(fileURLToPath(new URL(asset.file, UI_DIR)));
415
- res.writeHead(200, { "content-type": asset.type, "cache-control": "no-store" });
672
+ const url = new URL(req.url ?? "/", "http://localhost");
673
+ const path = url.pathname;
674
+ const asset = method === "GET" ? STATIC_FILE.exec(path)?.[1] : undefined;
675
+ if (asset !== undefined) {
676
+ const bytes = await readUi(asset);
677
+ const type = STATIC_TYPES.get(asset.slice(asset.lastIndexOf(".") + 1));
678
+ res.writeHead(200, { "content-type": type ?? "text/plain", "cache-control": "no-store" });
416
679
  res.end(bytes);
417
680
  return;
418
681
  }
419
- if (method === "GET" && path === "/")
420
- return sendHtml(res, 200, indexPage(sessions()));
682
+ const vendor = method === "GET" ? vendorFile(path) : null;
683
+ if (vendor)
684
+ return sendVendor(res, vendor);
685
+ if (method === "GET" && path === "/") {
686
+ const rows = [...registry.values()].map((h) => ({
687
+ summary: h.summary(),
688
+ openedAt: h.openedAt,
689
+ }));
690
+ return sendHtml(res, 200, indexPage(rows, Date.now()));
691
+ }
421
692
  if (method === "GET" && path === "/api/health") {
422
693
  return sendJson(res, 200, { app: HEALTH_APP, pid: process.pid });
423
694
  }
@@ -442,13 +713,66 @@ export function startServer(opts) {
442
713
  }
443
714
  if (method === "GET" && rest === "/") {
444
715
  if (!isSessionId(rawId) || !registry.has(rawId)) {
445
- return sendHtml(res, 404, '<!doctype html><title>follow-me</title><p>This session ended or never existed. <a href="/">All sessions</a></p>\n');
716
+ return sendHtml(res, 404, '<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="color-scheme" content="light dark" /><title>follow-me</title><link rel="stylesheet" href="/style.css" /></head><body class="index"><header class="topbar"><strong>follow-me</strong></header><main class="index-main"><section class="empty"><h1>This session has ended</h1><p>It finished, or it never existed. <a href="/">All sessions</a></p></section></main></body></html>\n');
446
717
  }
447
718
  return sendHtml(res, 200, await readFile(fileURLToPath(new URL("index.html", UI_DIR)), "utf8"));
448
719
  }
449
720
  const session = hosted(rawId);
450
721
  if (method === "GET" && rest === "/api/state")
451
722
  return sendJson(res, 200, session.snapshot());
723
+ if (method === "GET" && rest === "/api/events/stream") {
724
+ const header = Number(req.headers["last-event-id"]);
725
+ const resume = Number.isInteger(header) && header > 0 ? header : 0;
726
+ const since = Math.max(queryInt(url, "since", 0, Number.MAX_SAFE_INTEGER, 0), resume);
727
+ if (since > session.latest())
728
+ throw new HttpError(409, "since is ahead of this session's log");
729
+ res.writeHead(200, {
730
+ "content-type": "text/event-stream; charset=utf-8",
731
+ "cache-control": "no-cache",
732
+ connection: "keep-alive",
733
+ });
734
+ res.write(": follow-me\n\n");
735
+ const ping = setInterval(() => res.write(": ping\n\n"), STREAM_PING_MS);
736
+ const unfollow = session.follow(since, (event) => res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`), () => res.end());
737
+ res.on("close", () => {
738
+ clearInterval(ping);
739
+ unfollow();
740
+ });
741
+ return;
742
+ }
743
+ if (method === "GET" && rest === "/api/events") {
744
+ const abort = new AbortController();
745
+ res.on("close", () => abort.abort());
746
+ const since = queryInt(url, "since", 0, Number.MAX_SAFE_INTEGER, 0);
747
+ const timeout = queryInt(url, "timeout", 0, MAX_POLL_MS, DEFAULT_POLL_MS);
748
+ return sendJson(res, 200, await session.poll(since, timeout, abort.signal));
749
+ }
750
+ if (method === "GET" && rest === "/api/report") {
751
+ res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
752
+ res.end(session.report());
753
+ return;
754
+ }
755
+ if (method === "POST" && rest === "/api/version") {
756
+ const body = await readJson(req);
757
+ const version = isRecord(body) ? body["version"] : undefined;
758
+ const hash = isRecord(body) ? body["hash"] : undefined;
759
+ if (!isPlanVersion(version) || typeof hash !== "string" || hash === "") {
760
+ throw new HttpError(400, "expected { version, hash }");
761
+ }
762
+ await session.swap(version, hash);
763
+ return sendJson(res, 200, session.summary());
764
+ }
765
+ if (method === "POST" && rest === "/api/resolved") {
766
+ return sendJson(res, 200, { resolved: await session.resolve(await readJson(req)) });
767
+ }
768
+ const replies = /^\/api\/comments\/([^/]+)\/replies$/.exec(rest ?? "")?.[1];
769
+ if (replies !== undefined && method === "POST") {
770
+ return sendJson(res, 201, { id: await session.reply(replies, await readJson(req)) });
771
+ }
772
+ const concern = /^\/api\/concerns\/([^/]+)$/.exec(rest ?? "")?.[1];
773
+ if (concern !== undefined && method === "POST") {
774
+ return sendJson(res, 200, await session.decide(concern, await readJson(req)));
775
+ }
452
776
  if (method === "POST" && rest === "/api/comments") {
453
777
  return sendJson(res, 201, { id: await session.add(await readJson(req)) });
454
778
  }