@shiyuphan/follow-me 0.1.1 → 0.3.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 +119 -69
- package/dist/src/cli.js +1069 -140
- package/dist/src/comments.js +361 -0
- package/dist/src/git.js +32 -3
- package/dist/src/host.js +106 -9
- package/dist/src/install.js +22 -1
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +282 -26
- package/dist/src/prompt.js +711 -98
- package/dist/src/server.js +573 -140
- package/dist/src/session.js +106 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +3 -1
- package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-DcmzRkVZ.js +1 -0
- package/dist/ui/dist/assets/arc-T5QB0T1C.js +1 -0
- package/dist/ui/dist/assets/architecture-WOLXFQ4H-DUIkQTeE.js +1 -0
- package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-BUYi4-bA.js +36 -0
- package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-dyJxtT9e.js +140 -0
- package/dist/ui/dist/assets/c4Diagram-XDPPPE54-CrAgiaZR.js +38 -0
- package/dist/ui/dist/assets/channel-wW7ebm0i.js +1 -0
- package/dist/ui/dist/assets/chunk-2E7WHVMD-C6fAf4Vj.js +88 -0
- package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/dist/assets/chunk-4EMNKR2T-BLZjwZ4Q.js +1 -0
- package/dist/ui/dist/assets/chunk-55XCU76K-DGLK2Q-L.js +1 -0
- package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/dist/assets/chunk-7S6XSTFO-BablszTX.js +2 -0
- package/dist/ui/dist/assets/chunk-AQDTSKZB-Cuk3B_vb.js +72 -0
- package/dist/ui/dist/assets/chunk-DSNLSHJZ-DiC78RqE.js +127 -0
- package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/ui/dist/assets/chunk-HAGFOTED-X_RbvixX.js +62 -0
- package/dist/ui/dist/assets/chunk-HNE4Q36P-C14_x5ck.js +1 -0
- package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/ui/dist/assets/chunk-NQOKRVIF-Dxd0dtYR.js +213 -0
- package/dist/ui/dist/assets/chunk-UHW7PFBP-BIxePNUS.js +1 -0
- package/dist/ui/dist/assets/chunk-VQ5W62QI-CKSKLbj4.js +1 -0
- package/dist/ui/dist/assets/chunk-WQQL4CDK-CWNmZGlR.js +10 -0
- package/dist/ui/dist/assets/chunk-XXDRQBXY-D-I5QHgG.js +1 -0
- package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/dist/assets/chunk-YZO67HFG-DFP68CFd.js +1 -0
- package/dist/ui/dist/assets/chunk-ZAUX2LTX-DY90d4QG.js +1 -0
- package/dist/ui/dist/assets/chunk-ZCHMQSOR-Dlpr-5UC.js +2 -0
- package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-CH9uhoLJ.js +217 -0
- package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-DtYHrp8D.js +1 -0
- package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtslbtrY.js +1 -0
- package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUU1Jcc7.js +62 -0
- package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/ui/dist/assets/dagre-NK4GIZVX-CDlP-dRW.js +4 -0
- package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/dist/assets/diagram-CBK3MWTC-DiV2fn-r.js +24 -0
- package/dist/ui/dist/assets/diagram-I5XIIV2D-Bg5RehGi.js +24 -0
- package/dist/ui/dist/assets/diagram-ODIL2UCW-CFmazz3f.js +30 -0
- package/dist/ui/dist/assets/diagram-PFBEJUXE-uw3ZBqDp.js +3 -0
- package/dist/ui/dist/assets/diagram-RJA3I4VV-uEOYYRYa.js +41 -0
- package/dist/ui/dist/assets/diagram-TYUNFSNR-BN58LF_3.js +200 -0
- package/dist/ui/dist/assets/dist-tWS_lXDS.js +1 -0
- package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-B68Tz-w8.js +1 -0
- package/dist/ui/dist/assets/elk-QONTBCTQ-DZZohNea.js +27 -0
- package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BH_m74pv.js +99 -0
- package/dist/ui/dist/assets/eventmodeling-K75KTNOO-CTp5FHos.js +1 -0
- package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +1 -0
- package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-BDau7_0f.js +292 -0
- package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/ui/dist/assets/gitGraph-VSP46ZUC-Bv84IoCm.js +1 -0
- package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-DzXcMlm8.js +106 -0
- package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/dist/assets/index-BTJoqVVN.js +133 -0
- package/dist/ui/dist/assets/index-D60U3smc.css +1 -0
- package/dist/ui/dist/assets/info-OHQRW6UA-222J0O_j.js +1 -0
- package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-DE2sa3Vz.js +2 -0
- package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js +70 -0
- package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-JSUXX5Hb.js +139 -0
- package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-Bg79b49M.js +89 -0
- package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
- package/dist/ui/dist/assets/line-Bk5qQSIa.js +1 -0
- package/dist/ui/dist/assets/linear-PFnVMru5.js +1 -0
- package/dist/ui/dist/assets/mermaid-parser.core-Dcxlzc-S.js +7 -0
- package/dist/ui/dist/assets/mermaid.core-TeOHQJ00.js +44 -0
- package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-IaNk8NnW.js +98 -0
- package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/dist/assets/packet-JDAUHWVQ-DMff5cVK.js +1 -0
- package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-66Xu4YRc.js +1 -0
- package/dist/ui/dist/assets/pie-XZMESJXO-GEtzLCIM.js +1 -0
- package/dist/ui/dist/assets/pieDiagram-GNMPY74T-7jMBi-kU.js +39 -0
- package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
- package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
- package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
- package/dist/ui/dist/assets/prism-core-FDHlWQ43.js +1 -0
- package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
- package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
- package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
- package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
- package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
- package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
- package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
- package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
- package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
- package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
- package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
- package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
- package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
- package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
- package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
- package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
- package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
- package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
- package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
- package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
- package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
- package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-8hIWayC2.js +7 -0
- package/dist/ui/dist/assets/radar-ABXABTNO-BTdwUOOF.js +1 -0
- package/dist/ui/dist/assets/railroad-I3PHUGI6-DcMbDqBL.js +1 -0
- package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-gTzUTWeI.js +1 -0
- package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-CNtdUPtk.js +1 -0
- package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-DOiExAtE.js +1 -0
- package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-2kE_ygvM.js +1 -0
- package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-CoIAHRq2.js +84 -0
- package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-7p8D3wim.js +40 -0
- package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-C6QFnMvn.js +169 -0
- package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/ui/dist/assets/src-FgijoBVs.js +1 -0
- package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-n9eAb_bT.js +291 -0
- package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-U18mtUzX.js +1 -0
- package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +8 -0
- package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-CD2Z8QGP.js +120 -0
- package/dist/ui/dist/assets/treeView-D4JQ5SDB-sqUjH5gB.js +1 -0
- package/dist/ui/dist/assets/treemap-SAJKECNS-DvZ38FP7.js +1 -0
- package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-CqOGidaZ.js +328 -0
- package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D1SXSBQ5.js +34 -0
- package/dist/ui/dist/assets/wardley-7MLQ67FV-Bpwio-Dx.js +1 -0
- package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-JSUXHMnk.js +78 -0
- package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-B4IFqQTN.js +7 -0
- package/dist/ui/dist/index.html +15 -0
- package/dist/ui/dist/style.css +121 -0
- package/package.json +31 -8
- package/skills/follow-me/SKILL.md +15 -61
- package/dist/src/anthropic.js +0 -101
- package/dist/ui/app.js +0 -797
- package/dist/ui/index.html +0 -40
- package/dist/ui/playback.js +0 -175
- package/dist/ui/style.css +0 -466
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, reportItems } 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 {
|
|
@@ -20,16 +23,19 @@ export class RegistrationError extends Error {
|
|
|
20
23
|
}
|
|
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
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/** The page Vite builds from ui/ into ui/dist; `npm run build` makes it. */
|
|
27
|
+
const UI_DIR = new URL("../ui/dist/", import.meta.url);
|
|
28
|
+
/** Hashed bundles under /assets/ and the few flat files of ui/public at the root. */
|
|
29
|
+
const STATIC_FILE = /^\/((?:assets\/)?[\w-]+(?:\.[\w-]+)*\.(js|css|woff2|svg))$/;
|
|
30
|
+
const STATIC_TYPES = new Map([
|
|
31
|
+
["js", "text/javascript; charset=utf-8"],
|
|
32
|
+
["css", "text/css; charset=utf-8"],
|
|
33
|
+
["woff2", "font/woff2"],
|
|
34
|
+
["svg", "image/svg+xml"],
|
|
28
35
|
]);
|
|
29
36
|
const MAX_BODY_BYTES = 1_000_000;
|
|
30
37
|
const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
|
|
31
38
|
const COMMENT_ID = /^c_[a-z2-7]{6}$/;
|
|
32
|
-
const UNIT_ID = /^[hfm]_[0-9a-f]{12}(~\d+)?$/;
|
|
33
39
|
const SESSION_ID = /^[0-9a-f]{12}$/;
|
|
34
40
|
function isRecord(value) {
|
|
35
41
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -37,15 +43,6 @@ function isRecord(value) {
|
|
|
37
43
|
function isCommentId(value) {
|
|
38
44
|
return typeof value === "string" && COMMENT_ID.test(value);
|
|
39
45
|
}
|
|
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
46
|
export function isSessionId(value) {
|
|
50
47
|
return typeof value === "string" && SESSION_ID.test(value);
|
|
51
48
|
}
|
|
@@ -59,50 +56,6 @@ export function sessionIdOf(dir) {
|
|
|
59
56
|
export function sessionPath(id) {
|
|
60
57
|
return `/s/${id}/`;
|
|
61
58
|
}
|
|
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
59
|
/** The one place untrusted JSON becomes a Registration. */
|
|
107
60
|
export function parseRegistration(raw) {
|
|
108
61
|
if (!isRecord(raw))
|
|
@@ -121,25 +74,26 @@ export function parseRegistration(raw) {
|
|
|
121
74
|
function draftPath(reviewsDir, version) {
|
|
122
75
|
return join(reviewsDir, `v${version}.draft.json`);
|
|
123
76
|
}
|
|
124
|
-
/**
|
|
125
|
-
export async function
|
|
77
|
+
/** What an unfinished review of this version autosaved, or nothing. */
|
|
78
|
+
export async function loadDraft(reviewsDir, version) {
|
|
126
79
|
let text;
|
|
127
80
|
try {
|
|
128
81
|
text = await readFile(draftPath(reviewsDir, version), "utf8");
|
|
129
82
|
}
|
|
130
83
|
catch (error) {
|
|
131
|
-
if (isRecord(error) && error["code"] === "ENOENT")
|
|
132
|
-
return [];
|
|
84
|
+
if (isRecord(error) && error["code"] === "ENOENT") {
|
|
85
|
+
return { comments: [], concerns: [], resolved: [], excluded: { comments: [], concerns: [] } };
|
|
86
|
+
}
|
|
133
87
|
throw error;
|
|
134
88
|
}
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
if (!Array.isArray(list))
|
|
89
|
+
const draft = parseDraft(JSON.parse(text));
|
|
90
|
+
if (typeof draft === "string")
|
|
138
91
|
throw new Error(`malformed review draft for v${version}`);
|
|
139
|
-
return
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
92
|
+
return draft;
|
|
93
|
+
}
|
|
94
|
+
/** Comments autosaved by an unfinished review of this version, or none. */
|
|
95
|
+
export async function loadDraftComments(reviewsDir, version) {
|
|
96
|
+
return (await loadDraft(reviewsDir, version)).comments;
|
|
143
97
|
}
|
|
144
98
|
async function writeAtomic(path, text) {
|
|
145
99
|
// The pid lets `follow-me clean` tell an abandoned temp file from one being written.
|
|
@@ -184,6 +138,17 @@ function sendJson(res, status, body) {
|
|
|
184
138
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
185
139
|
res.end(JSON.stringify(body));
|
|
186
140
|
}
|
|
141
|
+
/** An integer query parameter within [min, max], else the default when absent; bad is a 400. */
|
|
142
|
+
function queryInt(url, name, min, max, fallback) {
|
|
143
|
+
const raw = url.searchParams.get(name);
|
|
144
|
+
if (raw === null)
|
|
145
|
+
return fallback;
|
|
146
|
+
const n = Number(raw);
|
|
147
|
+
if (raw === "" || !Number.isInteger(n) || n < min) {
|
|
148
|
+
throw new HttpError(400, `${name} must be an integer of at least ${min}`);
|
|
149
|
+
}
|
|
150
|
+
return Math.min(n, max);
|
|
151
|
+
}
|
|
187
152
|
function sendHtml(res, status, html) {
|
|
188
153
|
res.writeHead(status, {
|
|
189
154
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -194,15 +159,47 @@ function sendHtml(res, status, html) {
|
|
|
194
159
|
function escapeHtml(text) {
|
|
195
160
|
return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
196
161
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
162
|
+
/** "just now", "5 min ago", "2 h ago" or "3 days ago". */
|
|
163
|
+
export function ago(ms) {
|
|
164
|
+
const minutes = Math.floor(ms / 60_000);
|
|
165
|
+
if (minutes < 1)
|
|
166
|
+
return "just now";
|
|
167
|
+
if (minutes < 60)
|
|
168
|
+
return `${minutes} min ago`;
|
|
169
|
+
const hours = Math.floor(minutes / 60);
|
|
170
|
+
if (hours < 24)
|
|
171
|
+
return `${hours} h ago`;
|
|
172
|
+
const days = Math.floor(hours / 24);
|
|
173
|
+
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
174
|
+
}
|
|
175
|
+
function tildePath(path) {
|
|
176
|
+
const home = homedir();
|
|
177
|
+
return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
178
|
+
}
|
|
179
|
+
function indexPage(rows, now) {
|
|
180
|
+
const items = rows.map(({ summary: s, openedAt }) => {
|
|
181
|
+
const mode = s.mode === "walk"
|
|
182
|
+
? '<span class="mode walk">code review</span>'
|
|
183
|
+
: `<span class="mode split">plan review v${s.version}</span>`;
|
|
200
184
|
const count = `${s.comments} comment${s.comments === 1 ? "" : "s"}`;
|
|
201
|
-
|
|
185
|
+
const repo = escapeHtml(basename(s.repo));
|
|
186
|
+
return ` <li>
|
|
187
|
+
<a class="session" href="${s.url}"><span class="repo">${repo}</span> <span class="branch">${escapeHtml(s.branch)}</span></a>
|
|
188
|
+
${mode}
|
|
189
|
+
<span class="muted">${count} · opened ${ago(now - openedAt)}</span>
|
|
190
|
+
<span class="path muted">${escapeHtml(tildePath(s.repo))}</span>
|
|
191
|
+
</li>`;
|
|
202
192
|
});
|
|
203
|
-
const body =
|
|
204
|
-
?
|
|
205
|
-
|
|
193
|
+
const body = items.length === 0
|
|
194
|
+
? ` <section class="empty">
|
|
195
|
+
<h1>No sessions yet</h1>
|
|
196
|
+
<p>Ask your coding agent to review a branch with follow-me. Each review opens here.</p>
|
|
197
|
+
<p><a href="https://github.com/phanshiyu/follow-me#readme">How follow-me works</a></p>
|
|
198
|
+
</section>`
|
|
199
|
+
: ` <h1>Sessions</h1>
|
|
200
|
+
<ul class="session-index">
|
|
201
|
+
${items.join("\n")}
|
|
202
|
+
</ul>`;
|
|
206
203
|
return `<!doctype html>
|
|
207
204
|
<html lang="en">
|
|
208
205
|
<head>
|
|
@@ -213,109 +210,464 @@ function indexPage(sessions) {
|
|
|
213
210
|
<link rel="stylesheet" href="/style.css" />
|
|
214
211
|
</head>
|
|
215
212
|
<body class="index">
|
|
216
|
-
<
|
|
213
|
+
<header class="topbar"><strong>follow-me</strong></header>
|
|
214
|
+
<main class="index-main">
|
|
217
215
|
${body}
|
|
216
|
+
</main>
|
|
218
217
|
</body>
|
|
219
218
|
</html>
|
|
220
219
|
`;
|
|
221
220
|
}
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
async function readUi(dir, name) {
|
|
222
|
+
try {
|
|
223
|
+
return await readFile(fileURLToPath(new URL(name, dir)));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (isRecord(error) && error["code"] === "ENOENT")
|
|
227
|
+
return null;
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const NOT_BUILT = `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="color-scheme" content="light dark" /><title>follow-me</title></head><body style="font-family: system-ui; max-width: 40rem; margin: 4rem auto; padding: 0 1rem"><h1>The review page is not built</h1><p>This follow-me runs from a checkout without <code>ui/dist</code>. Run <code>npm run build</code> (or <code>npm run ui:build</code>) in the checkout, then reload.</p></body></html>
|
|
232
|
+
`;
|
|
233
|
+
function viewOf(registration, loaded, draft) {
|
|
224
234
|
const { state, reviewsDir } = loaded;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
235
|
+
return {
|
|
236
|
+
registration,
|
|
237
|
+
loaded,
|
|
238
|
+
comments: new Map(state.comments.map((c) => [c.id, c])),
|
|
239
|
+
decisions: new Map(draft.concerns.map((d) => [d.concern, d])),
|
|
240
|
+
resolved: new Set(draft.resolved),
|
|
241
|
+
excludedComments: new Set(draft.excluded.comments),
|
|
242
|
+
excludedConcerns: new Set(draft.excluded.concerns),
|
|
243
|
+
unitIds: new Set(state.units.map((u) => u.id)),
|
|
244
|
+
hunkRows: new Map(state.units.flatMap((u) => u.kind === "hunk" ? [[u.id, u.body.length]] : [])),
|
|
245
|
+
concernIds: new Set(state.commits.flatMap((c) => (c.concerns ?? []).map((k) => k.id))),
|
|
246
|
+
draftFile: draftPath(reviewsDir, state.version),
|
|
247
|
+
reviewFile: join(reviewsDir, `v${state.version}.json`),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function withReplies(comment, replies) {
|
|
251
|
+
const { replies: _dropped, ...rest } = comment;
|
|
252
|
+
return replies === undefined || replies.length === 0 ? rest : { ...rest, replies };
|
|
253
|
+
}
|
|
254
|
+
function mintReplyId(taken) {
|
|
255
|
+
for (;;) {
|
|
256
|
+
let id = "r_";
|
|
257
|
+
for (const byte of randomBytes(6))
|
|
258
|
+
id += BASE32.charAt(byte % 32);
|
|
259
|
+
if (isReplyId(id) && !taken.has(id))
|
|
260
|
+
return id;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* What the agent may see of the log: a pending flag is held back with everything said about it,
|
|
265
|
+
* and reaches the agent as an ordinary "comment" event when the reviewer submits.
|
|
266
|
+
*/
|
|
267
|
+
function forAgent(log) {
|
|
268
|
+
const held = new Set();
|
|
269
|
+
const out = [];
|
|
270
|
+
for (const event of log) {
|
|
271
|
+
if (event.type === "comment" || event.type === "comment-updated") {
|
|
272
|
+
const { comment } = event;
|
|
273
|
+
if (comment.pending === true) {
|
|
274
|
+
held.add(comment.id);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (held.delete(comment.id))
|
|
278
|
+
out.push({ ...event, type: "comment" });
|
|
279
|
+
else
|
|
280
|
+
out.push(event);
|
|
281
|
+
}
|
|
282
|
+
else if (event.type === "comment-deleted") {
|
|
283
|
+
if (!held.delete(event.id))
|
|
284
|
+
out.push(event);
|
|
285
|
+
}
|
|
286
|
+
else if (event.type === "reply") {
|
|
287
|
+
if (!held.has(event.commentId))
|
|
288
|
+
out.push(event);
|
|
289
|
+
}
|
|
290
|
+
else
|
|
291
|
+
out.push(event);
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
const DEFAULT_POLL_MS = 25_000;
|
|
296
|
+
const MAX_POLL_MS = 60_000;
|
|
297
|
+
const STREAM_PING_MS = 15_000;
|
|
298
|
+
/** One hosted review: its comments, its event log, its autosave chain, and how it ends. */
|
|
299
|
+
function hostSession(id, registration, loaded, draft, load) {
|
|
300
|
+
const openedAt = Date.now();
|
|
301
|
+
let view = viewOf(registration, loaded, draft);
|
|
229
302
|
let finish = () => { };
|
|
230
303
|
const done = new Promise((resolve) => {
|
|
231
304
|
finish = resolve;
|
|
232
305
|
});
|
|
306
|
+
// The append-only log a watcher long-polls. seq is the position, counted from 1.
|
|
307
|
+
const events = [];
|
|
308
|
+
const waiters = new Set();
|
|
309
|
+
const listeners = new Set();
|
|
310
|
+
const emit = (body) => {
|
|
311
|
+
events.push({ ...body, seq: events.length + 1 });
|
|
312
|
+
for (const wake of [...waiters])
|
|
313
|
+
wake();
|
|
314
|
+
for (const listener of [...listeners])
|
|
315
|
+
listener();
|
|
316
|
+
};
|
|
317
|
+
const seen = (agent) => (agent ? forAgent(events) : events);
|
|
318
|
+
const waitForEvents = (since, timeoutMs, signal, agent) => new Promise((resolve) => {
|
|
319
|
+
const news = () => seen(agent).filter((e) => e.seq > since);
|
|
320
|
+
const answer = () => resolve({ seq: events.length, events: news() });
|
|
321
|
+
if (news().length > 0 || timeoutMs === 0 || signal.aborted)
|
|
322
|
+
return answer();
|
|
323
|
+
const wake = () => {
|
|
324
|
+
if (news().length === 0 && !signal.aborted)
|
|
325
|
+
return;
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
waiters.delete(wake);
|
|
328
|
+
signal.removeEventListener("abort", wake);
|
|
329
|
+
answer();
|
|
330
|
+
};
|
|
331
|
+
const timer = setTimeout(() => {
|
|
332
|
+
waiters.delete(wake);
|
|
333
|
+
signal.removeEventListener("abort", wake);
|
|
334
|
+
answer();
|
|
335
|
+
}, timeoutMs);
|
|
336
|
+
waiters.add(wake);
|
|
337
|
+
signal.addEventListener("abort", wake);
|
|
338
|
+
});
|
|
233
339
|
let ending = false;
|
|
340
|
+
let swapping = false;
|
|
341
|
+
const open = () => {
|
|
342
|
+
if (ending)
|
|
343
|
+
throw new HttpError(409, "this review is already ending");
|
|
344
|
+
if (swapping)
|
|
345
|
+
throw new HttpError(409, "the session is switching plan versions; try again");
|
|
346
|
+
};
|
|
347
|
+
/** The exclusions as stored, or null when nothing is excluded. */
|
|
348
|
+
const excludedOf = () => view.excludedComments.size + view.excludedConcerns.size === 0
|
|
349
|
+
? null
|
|
350
|
+
: { comments: [...view.excludedComments], concerns: [...view.excludedConcerns] };
|
|
234
351
|
// Writes are chained so two rapid edits never race on the same file.
|
|
235
352
|
let writes = Promise.resolve();
|
|
236
353
|
const saveDraft = () => {
|
|
354
|
+
const { state } = view.loaded;
|
|
355
|
+
const file = view.draftFile;
|
|
356
|
+
const concerns = [...view.decisions.values()];
|
|
357
|
+
const resolved = [...view.resolved];
|
|
358
|
+
const excluded = excludedOf();
|
|
237
359
|
const snapshot = JSON.stringify({
|
|
238
360
|
schema: "follow-me/review-draft@1",
|
|
239
361
|
planVersion: state.version,
|
|
240
|
-
comments: [...comments.values()],
|
|
362
|
+
comments: [...view.comments.values()],
|
|
363
|
+
...(concerns.length === 0 ? {} : { concerns }),
|
|
364
|
+
...(resolved.length === 0 ? {} : { resolved }),
|
|
365
|
+
...(excluded === null ? {} : { excluded }),
|
|
241
366
|
}, null, 2);
|
|
367
|
+
const dir = view.loaded.reviewsDir;
|
|
242
368
|
writes = writes.then(async () => {
|
|
243
|
-
await mkdir(
|
|
244
|
-
await writeAtomic(
|
|
369
|
+
await mkdir(dir, { recursive: true });
|
|
370
|
+
await writeAtomic(file, `${snapshot}\n`);
|
|
245
371
|
});
|
|
246
372
|
return writes;
|
|
247
373
|
};
|
|
248
|
-
const accept = (
|
|
249
|
-
if (
|
|
250
|
-
throw new HttpError(400,
|
|
251
|
-
|
|
252
|
-
|
|
374
|
+
const accept = (parsed) => {
|
|
375
|
+
if (typeof parsed === "string")
|
|
376
|
+
throw new HttpError(400, `malformed comment: ${parsed}`);
|
|
377
|
+
if (!COMMENT_ID.test(parsed.id))
|
|
378
|
+
throw new HttpError(400, "malformed comment: bad id");
|
|
379
|
+
const { anchor } = parsed;
|
|
380
|
+
if (anchor.kind !== "plan" && anchor.commit >= view.loaded.state.commits.length) {
|
|
253
381
|
throw new HttpError(400, "anchor commit out of range");
|
|
254
382
|
}
|
|
255
|
-
if (anchor.kind === "hunk" && !unitIds.has(anchor.unit)) {
|
|
383
|
+
if (anchor.kind === "hunk" && !view.unitIds.has(anchor.unit)) {
|
|
256
384
|
throw new HttpError(400, "anchor names an unknown unit");
|
|
257
385
|
}
|
|
258
|
-
|
|
386
|
+
if (anchor.kind === "hunk" && anchor.offsets !== undefined) {
|
|
387
|
+
const rows = view.hunkRows.get(anchor.unit);
|
|
388
|
+
if (rows === undefined)
|
|
389
|
+
throw new HttpError(400, "anchor.offsets names a unit without rows");
|
|
390
|
+
if (anchor.offsets.some((o) => o >= rows)) {
|
|
391
|
+
throw new HttpError(400, `anchor.offsets is out of range, the unit has ${rows} rows`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return parsed;
|
|
259
395
|
};
|
|
396
|
+
/** Clears `pending` on every held flag at once and tells the agent what it missed. */
|
|
397
|
+
const release = () => {
|
|
398
|
+
const held = [...view.comments.values()].filter((c) => c.pending === true);
|
|
399
|
+
if (held.length === 0)
|
|
400
|
+
return 0;
|
|
401
|
+
emit({ type: "submitted" });
|
|
402
|
+
for (const comment of held) {
|
|
403
|
+
const { pending: _held, ...open } = comment;
|
|
404
|
+
view.comments.set(comment.id, open);
|
|
405
|
+
emit({ type: "comment", comment: open });
|
|
406
|
+
}
|
|
407
|
+
return held.length;
|
|
408
|
+
};
|
|
409
|
+
const review = () => {
|
|
410
|
+
const concerns = [...view.decisions.values()];
|
|
411
|
+
const resolved = [...view.resolved];
|
|
412
|
+
const excluded = excludedOf();
|
|
413
|
+
return {
|
|
414
|
+
schema: "follow-me/review@1",
|
|
415
|
+
mode: view.loaded.state.mode,
|
|
416
|
+
planVersion: view.loaded.state.version,
|
|
417
|
+
planHash: view.loaded.state.planHash,
|
|
418
|
+
endedAt: new Date().toISOString(),
|
|
419
|
+
comments: [...view.comments.values()].filter((c) => c.pending !== true),
|
|
420
|
+
...(concerns.length === 0 ? {} : { concerns }),
|
|
421
|
+
...(resolved.length === 0 ? {} : { resolved }),
|
|
422
|
+
...(excluded === null ? {} : { excluded }),
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
const report = (finished) => {
|
|
426
|
+
const { state } = view.loaded;
|
|
427
|
+
const units = new Map(state.units.map((u) => [u.id, u]));
|
|
428
|
+
return renderReport(finished, state.commits, units, {
|
|
429
|
+
branch: state.anchor.branch,
|
|
430
|
+
responses: [],
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
/** Writes reviews/vN.json and its report once, and removes the autosave. */
|
|
434
|
+
const writeReview = async (finished) => {
|
|
435
|
+
await writes;
|
|
436
|
+
await mkdir(view.loaded.reviewsDir, { recursive: true });
|
|
437
|
+
await writeAtomic(view.reviewFile, `${JSON.stringify(finished, null, 2)}\n`);
|
|
438
|
+
await writeAtomic(view.reviewFile.replace(/\.json$/, ".md"), report(finished));
|
|
439
|
+
await rm(view.draftFile, { force: true });
|
|
440
|
+
return view.reviewFile;
|
|
441
|
+
};
|
|
442
|
+
const hasWork = () => view.comments.size > 0 || view.decisions.size > 0;
|
|
260
443
|
return {
|
|
261
444
|
id,
|
|
262
|
-
registration,
|
|
263
445
|
done,
|
|
446
|
+
openedAt,
|
|
447
|
+
get registration() {
|
|
448
|
+
return view.registration;
|
|
449
|
+
},
|
|
264
450
|
summary: () => ({
|
|
265
451
|
id,
|
|
266
452
|
url: sessionPath(id),
|
|
267
|
-
repo: loaded.repo,
|
|
268
|
-
branch: state.anchor.branch,
|
|
269
|
-
mode: state.mode,
|
|
270
|
-
version: state.version,
|
|
271
|
-
comments: comments.size,
|
|
453
|
+
repo: view.loaded.repo,
|
|
454
|
+
branch: view.loaded.state.anchor.branch,
|
|
455
|
+
mode: view.loaded.state.mode,
|
|
456
|
+
version: view.loaded.state.version,
|
|
457
|
+
comments: view.comments.size,
|
|
458
|
+
openedAt: new Date(openedAt).toISOString(),
|
|
272
459
|
}),
|
|
273
|
-
snapshot: () => ({
|
|
460
|
+
snapshot: () => ({
|
|
461
|
+
...view.loaded.state,
|
|
462
|
+
comments: [...view.comments.values()],
|
|
463
|
+
concerns: [...view.decisions.values()],
|
|
464
|
+
resolved: [...view.resolved],
|
|
465
|
+
excluded: { comments: [...view.excludedComments], concerns: [...view.excludedConcerns] },
|
|
466
|
+
seq: events.length,
|
|
467
|
+
previous: view.loaded.previous,
|
|
468
|
+
}),
|
|
469
|
+
/** Events after `since`, now and as they happen, until unfollowed or the review ends. */
|
|
470
|
+
latest: () => events.length,
|
|
471
|
+
follow: (since, send, stop, agent) => {
|
|
472
|
+
if (since > events.length) {
|
|
473
|
+
throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
|
|
474
|
+
}
|
|
475
|
+
let last = since;
|
|
476
|
+
const flush = () => {
|
|
477
|
+
for (const event of seen(agent).filter((e) => e.seq > last)) {
|
|
478
|
+
send(event);
|
|
479
|
+
last = event.seq;
|
|
480
|
+
if (event.type === "ended") {
|
|
481
|
+
listeners.delete(flush);
|
|
482
|
+
stop();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
listeners.add(flush);
|
|
488
|
+
flush();
|
|
489
|
+
return () => listeners.delete(flush);
|
|
490
|
+
},
|
|
491
|
+
poll: (since, timeoutMs, signal, agent) => {
|
|
492
|
+
if (since > events.length) {
|
|
493
|
+
throw new HttpError(409, `since ${since} is ahead of this session's log (${events.length})`);
|
|
494
|
+
}
|
|
495
|
+
return waitForEvents(since, timeoutMs, signal, agent);
|
|
496
|
+
},
|
|
497
|
+
report: () => report(review()),
|
|
498
|
+
reportItems: () => {
|
|
499
|
+
const { state } = view.loaded;
|
|
500
|
+
const units = new Map(state.units.map((u) => [u.id, u]));
|
|
501
|
+
return reportItems(review(), state.commits, units, {
|
|
502
|
+
branch: state.anchor.branch,
|
|
503
|
+
responses: [],
|
|
504
|
+
});
|
|
505
|
+
},
|
|
274
506
|
add: async (raw) => {
|
|
507
|
+
open();
|
|
275
508
|
if (!isRecord(raw))
|
|
276
509
|
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);
|
|
510
|
+
const commentId = mintCommentId(new Set(view.comments.keys()));
|
|
511
|
+
const comment = withReplies(accept(parseComment({ ...raw, id: commentId, createdAt: new Date().toISOString() })), undefined);
|
|
512
|
+
view.comments.set(commentId, comment);
|
|
513
|
+
emit({ type: "comment", comment });
|
|
280
514
|
await saveDraft();
|
|
281
515
|
return commentId;
|
|
282
516
|
},
|
|
283
517
|
put: async (target, raw) => {
|
|
284
|
-
|
|
285
|
-
|
|
518
|
+
open();
|
|
519
|
+
const parsed = withReplies(accept(parseComment(raw)), undefined);
|
|
520
|
+
if (parsed.id !== target)
|
|
286
521
|
throw new HttpError(400, "comment id does not match the URL");
|
|
287
|
-
|
|
522
|
+
// Replies belong to the conversation, not to the page's copy of the comment.
|
|
523
|
+
const comment = withReplies(parsed, view.comments.get(target)?.replies);
|
|
524
|
+
view.comments.set(comment.id, comment);
|
|
525
|
+
emit({ type: "comment-updated", comment });
|
|
288
526
|
await saveDraft();
|
|
289
527
|
return comment;
|
|
290
528
|
},
|
|
291
529
|
remove: async (target) => {
|
|
292
|
-
|
|
530
|
+
open();
|
|
531
|
+
const gone = view.comments.get(target);
|
|
532
|
+
if (gone === undefined)
|
|
533
|
+
throw new HttpError(404, "no such comment");
|
|
534
|
+
view.comments.delete(target);
|
|
535
|
+
view.excludedComments.delete(gone.id);
|
|
536
|
+
emit({ type: "comment-deleted", id: gone.id });
|
|
537
|
+
await saveDraft();
|
|
538
|
+
},
|
|
539
|
+
reply: async (target, raw) => {
|
|
540
|
+
open();
|
|
541
|
+
const comment = view.comments.get(target);
|
|
542
|
+
if (comment === undefined)
|
|
293
543
|
throw new HttpError(404, "no such comment");
|
|
544
|
+
const parsed = parseNewReply(raw);
|
|
545
|
+
if (typeof parsed === "string")
|
|
546
|
+
throw new HttpError(400, `malformed reply: ${parsed}`);
|
|
547
|
+
const taken = new Set([...view.comments.values()].flatMap((c) => c.replies ?? []).map((r) => r.id));
|
|
548
|
+
const reply = {
|
|
549
|
+
id: mintReplyId(taken),
|
|
550
|
+
...parsed,
|
|
551
|
+
createdAt: new Date().toISOString(),
|
|
552
|
+
};
|
|
553
|
+
view.comments.set(comment.id, { ...comment, replies: [...(comment.replies ?? []), reply] });
|
|
554
|
+
emit({ type: "reply", commentId: comment.id, reply });
|
|
555
|
+
await saveDraft();
|
|
556
|
+
return reply.id;
|
|
557
|
+
},
|
|
558
|
+
decide: async (target, raw) => {
|
|
559
|
+
open();
|
|
560
|
+
if (!isConcernId(target) || !view.concernIds.has(target)) {
|
|
561
|
+
throw new HttpError(404, "no such concern in this plan");
|
|
562
|
+
}
|
|
563
|
+
const parsed = parseNewDecision(target, raw);
|
|
564
|
+
if (typeof parsed === "string")
|
|
565
|
+
throw new HttpError(400, `malformed decision: ${parsed}`);
|
|
566
|
+
const decision = { ...parsed, decidedAt: new Date().toISOString() };
|
|
567
|
+
view.decisions.set(target, decision);
|
|
568
|
+
const { concern: concernId, ...rest } = decision;
|
|
569
|
+
emit({ type: "concern", concernId, ...rest });
|
|
570
|
+
await saveDraft();
|
|
571
|
+
return decision;
|
|
572
|
+
},
|
|
573
|
+
/** Leaves a comment or an agent concern out of the hand-off, or puts it back. */
|
|
574
|
+
exclude: async (raw) => {
|
|
575
|
+
open();
|
|
576
|
+
const body = isRecord(raw) ? raw : {};
|
|
577
|
+
const { comment, concern, excluded } = body;
|
|
578
|
+
if (typeof excluded !== "boolean" || (comment === undefined) === (concern === undefined)) {
|
|
579
|
+
throw new HttpError(400, "expected { comment } or { concern }, and { excluded: boolean }");
|
|
580
|
+
}
|
|
581
|
+
if (comment !== undefined) {
|
|
582
|
+
if (!isCommentId(comment) || !view.comments.has(comment)) {
|
|
583
|
+
throw new HttpError(404, "no such comment");
|
|
584
|
+
}
|
|
585
|
+
if (excluded)
|
|
586
|
+
view.excludedComments.add(comment);
|
|
587
|
+
else
|
|
588
|
+
view.excludedComments.delete(comment);
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
if (!isConcernId(concern) || !view.concernIds.has(concern)) {
|
|
592
|
+
throw new HttpError(404, "no such concern in this plan");
|
|
593
|
+
}
|
|
594
|
+
if (excluded)
|
|
595
|
+
view.excludedConcerns.add(concern);
|
|
596
|
+
else
|
|
597
|
+
view.excludedConcerns.delete(concern);
|
|
598
|
+
}
|
|
294
599
|
await saveDraft();
|
|
600
|
+
return { comments: [...view.excludedComments], concerns: [...view.excludedConcerns] };
|
|
295
601
|
},
|
|
296
|
-
/**
|
|
602
|
+
/** Releases every held flag in one go; the reviewer pressed Submit. */
|
|
603
|
+
submit: async () => {
|
|
604
|
+
open();
|
|
605
|
+
const released = release();
|
|
606
|
+
await saveDraft();
|
|
607
|
+
return { released };
|
|
608
|
+
},
|
|
609
|
+
resolve: async (raw) => {
|
|
610
|
+
open();
|
|
611
|
+
const body = isRecord(raw) ? raw : {};
|
|
612
|
+
const target = body["comment"];
|
|
613
|
+
const asked = view.loaded.previous?.comments.find((c) => c.id === target);
|
|
614
|
+
if (asked === undefined || typeof body["resolved"] !== "boolean") {
|
|
615
|
+
throw new HttpError(400, "expected { comment: <id of a question of the previous version>, resolved }");
|
|
616
|
+
}
|
|
617
|
+
if (body["resolved"])
|
|
618
|
+
view.resolved.add(asked.id);
|
|
619
|
+
else
|
|
620
|
+
view.resolved.delete(asked.id);
|
|
621
|
+
emit({ type: "resolved", commentId: asked.id, resolved: body["resolved"] });
|
|
622
|
+
await saveDraft();
|
|
623
|
+
return [...view.resolved];
|
|
624
|
+
},
|
|
625
|
+
/**
|
|
626
|
+
* Moves the session to a newer frozen plan under the same id and URL. The new version loads
|
|
627
|
+
* first, so a plan that no longer fits leaves the old one untouched; then the old version's
|
|
628
|
+
* review is finalized if anything was said, and the comment set starts fresh.
|
|
629
|
+
*/
|
|
630
|
+
swap: async (version, hash) => {
|
|
631
|
+
open();
|
|
632
|
+
const { state } = view.loaded;
|
|
633
|
+
if (version === state.version) {
|
|
634
|
+
if (hash === state.planHash)
|
|
635
|
+
return;
|
|
636
|
+
throw new HttpError(409, `plan v${version} is already open with another hash`);
|
|
637
|
+
}
|
|
638
|
+
if (version < state.version) {
|
|
639
|
+
throw new HttpError(409, `plan v${version} is older than the open v${state.version}`);
|
|
640
|
+
}
|
|
641
|
+
swapping = true;
|
|
642
|
+
try {
|
|
643
|
+
await writes;
|
|
644
|
+
const next = await load({ ...view.registration, version, hash, lockToken: null }, view.loaded.release);
|
|
645
|
+
release();
|
|
646
|
+
if (hasWork())
|
|
647
|
+
await writeReview(review());
|
|
648
|
+
else
|
|
649
|
+
await rm(view.draftFile, { force: true });
|
|
650
|
+
view = viewOf({ ...view.registration, version, hash }, next, await loadDraft(next.reviewsDir, version));
|
|
651
|
+
emit({ type: "version", version, hash });
|
|
652
|
+
}
|
|
653
|
+
finally {
|
|
654
|
+
swapping = false;
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
/** Writes reviews/vN.json and vN.md exactly once and releases the session's lock. */
|
|
297
658
|
end: async () => {
|
|
298
|
-
|
|
299
|
-
throw new HttpError(409, "this review is already ending");
|
|
659
|
+
open();
|
|
300
660
|
ending = true;
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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 };
|
|
661
|
+
release();
|
|
662
|
+
const finished = review();
|
|
663
|
+
const file = await writeReview(finished);
|
|
664
|
+
view.loaded.release();
|
|
665
|
+
emit({ type: "ended", review: file });
|
|
666
|
+
return { review: finished, file };
|
|
315
667
|
},
|
|
316
|
-
finish: (
|
|
668
|
+
finish: (finished) => finish(finished),
|
|
317
669
|
abandon: () => {
|
|
318
|
-
loaded.release();
|
|
670
|
+
view.loaded.release();
|
|
319
671
|
finish(null);
|
|
320
672
|
},
|
|
321
673
|
};
|
|
@@ -329,6 +681,7 @@ function sameRegistration(a, b) {
|
|
|
329
681
|
* itself once it has had no session for idleMs.
|
|
330
682
|
*/
|
|
331
683
|
export function startServer(opts) {
|
|
684
|
+
const uiDir = opts.uiDir ?? UI_DIR;
|
|
332
685
|
const registry = new Map();
|
|
333
686
|
const pending = new Map();
|
|
334
687
|
let boundPort = 0;
|
|
@@ -380,7 +733,9 @@ export function startServer(opts) {
|
|
|
380
733
|
return { summary: existing.summary(), done: existing.done };
|
|
381
734
|
}
|
|
382
735
|
clearTimeout(idleTimer);
|
|
383
|
-
const loading = opts
|
|
736
|
+
const loading = opts
|
|
737
|
+
.load(registration, null)
|
|
738
|
+
.then(async (loaded) => hostSession(id, registration, loaded, await loadDraft(loaded.reviewsDir, loaded.state.version), opts.load));
|
|
384
739
|
pending.set(id, loading);
|
|
385
740
|
try {
|
|
386
741
|
const session = await loading;
|
|
@@ -408,16 +763,30 @@ export function startServer(opts) {
|
|
|
408
763
|
if (origin !== undefined && origin !== `http://${host}`)
|
|
409
764
|
throw new HttpError(403, "bad origin");
|
|
410
765
|
const method = req.method ?? "GET";
|
|
411
|
-
const
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
766
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
767
|
+
const path = url.pathname;
|
|
768
|
+
const asset = method === "GET" ? STATIC_FILE.exec(path) : null;
|
|
769
|
+
if (asset !== null) {
|
|
770
|
+
const [, name = "", ext = ""] = asset;
|
|
771
|
+
const bytes = await readUi(uiDir, name);
|
|
772
|
+
if (bytes === null)
|
|
773
|
+
throw new HttpError(404, "not found");
|
|
774
|
+
// Vite hashes every name under assets/, so those never change under the same URL.
|
|
775
|
+
const cache = name.startsWith("assets/") ? "max-age=31536000, immutable" : "no-store";
|
|
776
|
+
res.writeHead(200, {
|
|
777
|
+
"content-type": STATIC_TYPES.get(ext) ?? "text/plain",
|
|
778
|
+
"cache-control": cache,
|
|
779
|
+
});
|
|
416
780
|
res.end(bytes);
|
|
417
781
|
return;
|
|
418
782
|
}
|
|
419
|
-
if (method === "GET" && path === "/")
|
|
420
|
-
|
|
783
|
+
if (method === "GET" && path === "/") {
|
|
784
|
+
const rows = [...registry.values()].map((h) => ({
|
|
785
|
+
summary: h.summary(),
|
|
786
|
+
openedAt: h.openedAt,
|
|
787
|
+
}));
|
|
788
|
+
return sendHtml(res, 200, indexPage(rows, Date.now()));
|
|
789
|
+
}
|
|
421
790
|
if (method === "GET" && path === "/api/health") {
|
|
422
791
|
return sendJson(res, 200, { app: HEALTH_APP, pid: process.pid });
|
|
423
792
|
}
|
|
@@ -440,15 +809,79 @@ export function startServer(opts) {
|
|
|
440
809
|
res.end();
|
|
441
810
|
return;
|
|
442
811
|
}
|
|
443
|
-
if (method === "GET" && rest === "/") {
|
|
812
|
+
if (method === "GET" && (rest === "/" || rest === "/report")) {
|
|
444
813
|
if (!isSessionId(rawId) || !registry.has(rawId)) {
|
|
445
|
-
return sendHtml(res, 404, '<!doctype html><title>follow-me</title><
|
|
814
|
+
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
815
|
}
|
|
447
|
-
|
|
816
|
+
const page = await readUi(uiDir, "index.html");
|
|
817
|
+
return page === null ? sendHtml(res, 503, NOT_BUILT) : sendHtml(res, 200, page.toString());
|
|
448
818
|
}
|
|
449
819
|
const session = hosted(rawId);
|
|
450
820
|
if (method === "GET" && rest === "/api/state")
|
|
451
821
|
return sendJson(res, 200, session.snapshot());
|
|
822
|
+
if (method === "GET" && rest === "/api/events/stream") {
|
|
823
|
+
const header = Number(req.headers["last-event-id"]);
|
|
824
|
+
const resume = Number.isInteger(header) && header > 0 ? header : 0;
|
|
825
|
+
const since = Math.max(queryInt(url, "since", 0, Number.MAX_SAFE_INTEGER, 0), resume);
|
|
826
|
+
if (since > session.latest())
|
|
827
|
+
throw new HttpError(409, "since is ahead of this session's log");
|
|
828
|
+
res.writeHead(200, {
|
|
829
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
830
|
+
"cache-control": "no-cache",
|
|
831
|
+
connection: "keep-alive",
|
|
832
|
+
});
|
|
833
|
+
res.write(": follow-me\n\n");
|
|
834
|
+
const ping = setInterval(() => res.write(": ping\n\n"), STREAM_PING_MS);
|
|
835
|
+
const unfollow = session.follow(since, (event) => res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`), () => res.end(), url.searchParams.get("audience") === "agent");
|
|
836
|
+
res.on("close", () => {
|
|
837
|
+
clearInterval(ping);
|
|
838
|
+
unfollow();
|
|
839
|
+
});
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (method === "GET" && rest === "/api/events") {
|
|
843
|
+
const abort = new AbortController();
|
|
844
|
+
res.on("close", () => abort.abort());
|
|
845
|
+
const since = queryInt(url, "since", 0, Number.MAX_SAFE_INTEGER, 0);
|
|
846
|
+
const timeout = queryInt(url, "timeout", 0, MAX_POLL_MS, DEFAULT_POLL_MS);
|
|
847
|
+
const agent = url.searchParams.get("audience") === "agent";
|
|
848
|
+
return sendJson(res, 200, await session.poll(since, timeout, abort.signal, agent));
|
|
849
|
+
}
|
|
850
|
+
if (method === "GET" && rest === "/api/report" && url.searchParams.get("format") === "json") {
|
|
851
|
+
return sendJson(res, 200, session.reportItems());
|
|
852
|
+
}
|
|
853
|
+
if (method === "GET" && rest === "/api/report") {
|
|
854
|
+
res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
|
|
855
|
+
res.end(session.report());
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (method === "POST" && rest === "/api/version") {
|
|
859
|
+
const body = await readJson(req);
|
|
860
|
+
const version = isRecord(body) ? body["version"] : undefined;
|
|
861
|
+
const hash = isRecord(body) ? body["hash"] : undefined;
|
|
862
|
+
if (!isPlanVersion(version) || typeof hash !== "string" || hash === "") {
|
|
863
|
+
throw new HttpError(400, "expected { version, hash }");
|
|
864
|
+
}
|
|
865
|
+
await session.swap(version, hash);
|
|
866
|
+
return sendJson(res, 200, session.summary());
|
|
867
|
+
}
|
|
868
|
+
if (method === "POST" && rest === "/api/submit") {
|
|
869
|
+
return sendJson(res, 200, await session.submit());
|
|
870
|
+
}
|
|
871
|
+
if (method === "POST" && rest === "/api/excluded") {
|
|
872
|
+
return sendJson(res, 200, await session.exclude(await readJson(req)));
|
|
873
|
+
}
|
|
874
|
+
if (method === "POST" && rest === "/api/resolved") {
|
|
875
|
+
return sendJson(res, 200, { resolved: await session.resolve(await readJson(req)) });
|
|
876
|
+
}
|
|
877
|
+
const replies = /^\/api\/comments\/([^/]+)\/replies$/.exec(rest ?? "")?.[1];
|
|
878
|
+
if (replies !== undefined && method === "POST") {
|
|
879
|
+
return sendJson(res, 201, { id: await session.reply(replies, await readJson(req)) });
|
|
880
|
+
}
|
|
881
|
+
const concern = /^\/api\/concerns\/([^/]+)$/.exec(rest ?? "")?.[1];
|
|
882
|
+
if (concern !== undefined && method === "POST") {
|
|
883
|
+
return sendJson(res, 200, await session.decide(concern, await readJson(req)));
|
|
884
|
+
}
|
|
452
885
|
if (method === "POST" && rest === "/api/comments") {
|
|
453
886
|
return sendJson(res, 201, { id: await session.add(await readJson(req)) });
|
|
454
887
|
}
|