@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.
- package/README.md +127 -66
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +632 -81
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +20 -3
- package/dist/src/host.js +92 -6
- package/dist/src/install.js +1 -1
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +461 -137
- package/dist/src/session.js +104 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +3 -1
- package/dist/ui/app.js +1676 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +61 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +955 -90
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -6
package/dist/src/server.js
CHANGED
|
@@ -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 {
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
["
|
|
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
|
-
/**
|
|
125
|
-
export async function
|
|
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
|
|
136
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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 =
|
|
204
|
-
?
|
|
205
|
-
|
|
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
|
-
<
|
|
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
|
-
/**
|
|
223
|
-
function
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(
|
|
244
|
-
await writeAtomic(
|
|
349
|
+
await mkdir(dir, { recursive: true });
|
|
350
|
+
await writeAtomic(file, `${snapshot}\n`);
|
|
245
351
|
});
|
|
246
352
|
return writes;
|
|
247
353
|
};
|
|
248
|
-
const accept = (
|
|
249
|
-
if (
|
|
250
|
-
throw new HttpError(400,
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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: () => ({
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
throw new HttpError(409, "this review is already ending");
|
|
567
|
+
open();
|
|
300
568
|
ending = true;
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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: (
|
|
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
|
|
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
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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><
|
|
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
|
}
|