reviewzy 0.1.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.
Files changed (46) hide show
  1. package/.env.example +30 -0
  2. package/LICENSE +661 -0
  3. package/README.md +170 -0
  4. package/bin/reviewzy +7 -0
  5. package/package.json +29 -0
  6. package/src/config.ts +76 -0
  7. package/src/daemon/app.ts +159 -0
  8. package/src/daemon/main.ts +103 -0
  9. package/src/daemon/origin.ts +60 -0
  10. package/src/dashboard/auth.ts +122 -0
  11. package/src/dashboard/mount.ts +373 -0
  12. package/src/dashboard/static/components.css +1116 -0
  13. package/src/dashboard/static/dashboard.css +182 -0
  14. package/src/dashboard/static/dashboard.js +169 -0
  15. package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
  16. package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
  17. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  18. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  19. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  20. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  21. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  22. package/src/dashboard/static/htmx.min.js +2 -0
  23. package/src/dashboard/static/tokens.css +213 -0
  24. package/src/dashboard/views.tsx +1388 -0
  25. package/src/db/human-save.ts +242 -0
  26. package/src/db/migrate.ts +71 -0
  27. package/src/db/migrations.ts +149 -0
  28. package/src/db/queries.ts +425 -0
  29. package/src/db/store.ts +83 -0
  30. package/src/db/style-guide.ts +236 -0
  31. package/src/db/sweep.ts +88 -0
  32. package/src/mcp/await-approved.ts +156 -0
  33. package/src/mcp/fetch-approved.ts +132 -0
  34. package/src/mcp/file-entries.ts +219 -0
  35. package/src/mcp/list-entries.ts +107 -0
  36. package/src/mcp/mark-applied.ts +103 -0
  37. package/src/mcp/route.ts +88 -0
  38. package/src/mcp/server.ts +59 -0
  39. package/src/mcp/style-guide-resource.ts +40 -0
  40. package/src/notify.ts +142 -0
  41. package/src/shim/daemon.ts +318 -0
  42. package/src/shim/lockfile.ts +153 -0
  43. package/src/shim/main.ts +172 -0
  44. package/src/shim/semver.ts +29 -0
  45. package/src/version.ts +4 -0
  46. package/tsconfig.json +26 -0
package/src/notify.ts ADDED
@@ -0,0 +1,142 @@
1
+ import type { Config } from "./config.ts";
2
+
3
+ /** The debounce window: filings for one project inside it merge into a single ping per transport. */
4
+ const DEFAULT_WINDOW_MS = 1000;
5
+
6
+ /** Every ping's deadline; a hung transport can hold nothing, since the ping itself is fire-and-forget. */
7
+ const PING_TIMEOUT_MS = 5_000;
8
+
9
+ /**
10
+ * Everything a notification run needs, all injectable for tests: `config` arms the transports,
11
+ * `baseUrl` builds the dashboard link, and `fetch`/`windowMs` let a test point pings at real local
12
+ * servers and collapse bursts quickly.
13
+ */
14
+ export type NotifierOptions = {
15
+ config: Config;
16
+ baseUrl: string;
17
+ fetch?: typeof fetch;
18
+ windowMs?: number;
19
+ };
20
+
21
+ /** One project's merged filings waiting out the debounce window. */
22
+ type Pending = {
23
+ count: number;
24
+ batchId: string;
25
+ timer: Timer;
26
+ };
27
+
28
+ /**
29
+ * Fires filing notifications for `file_entries`. Debounce is trailing, keyed per project slug:
30
+ * each `notifyBatch` inside the window replaces the timer and merges into the pending ping (count
31
+ * sums, batch id goes latest), so an agent loop re-filing a burst lands as one ping at window end.
32
+ *
33
+ * Delivery is fire-and-forget: `notifyBatch` returns immediately, and a failing transport logs to
34
+ * stderr naming itself — it can never touch the filing result. The timer is unref'd, so a pending
35
+ * ping never holds the daemon open at shutdown; `dispose()` cancels it outright. All timing lives
36
+ * here: nothing upstream waits on or measures a window.
37
+ */
38
+ export class Notifier {
39
+ readonly #config: Config;
40
+ readonly #baseUrl: string;
41
+ readonly #fetch: typeof fetch;
42
+ readonly #windowMs: number;
43
+ readonly #pending = new Map<string, Pending>();
44
+
45
+ constructor(options: NotifierOptions) {
46
+ this.#config = options.config;
47
+ this.#baseUrl = options.baseUrl;
48
+ this.#fetch = options.fetch ?? fetch;
49
+ this.#windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
50
+ }
51
+
52
+ /**
53
+ * Called after a filing that changed the store: the caller (file_entries) computes `count` from
54
+ * its results and only calls here for a batch that created or updated at least one entry, so a
55
+ * zero count never arrives. Silent when every transport is unarmed.
56
+ */
57
+ notifyBatch(project: string, batchId: string, count: number): void {
58
+ // Guard on the real arming conditions, not just the urls: a topic-less ntfy url arms nothing,
59
+ // and a timer armed for a transport that can never ping is dead work per filing. The config is
60
+ // fixed for the daemon's life, so this never flips mid-debounce.
61
+ const ntfyArmed = this.#config.NTFY_URL !== undefined && this.#config.NTFY_TOPIC !== undefined;
62
+ if (!ntfyArmed && this.#config.WEBHOOK_URL === undefined) return;
63
+
64
+ const existing = this.#pending.get(project);
65
+ if (existing !== undefined) {
66
+ existing.count += count;
67
+ existing.batchId = batchId;
68
+ // The callback captures its values at arm time, and clearing the stale timer guarantees it
69
+ // never fires, so the one live timer always carries the merged count and batch id.
70
+ clearTimeout(existing.timer);
71
+ existing.timer = this.#arm(project, existing.count, existing.batchId);
72
+ return;
73
+ }
74
+ this.#pending.set(project, { count, batchId, timer: this.#arm(project, count, batchId) });
75
+ }
76
+
77
+ /** Cancels every pending debounce. The stop path calls this before closing the store. */
78
+ dispose(): void {
79
+ for (const pending of this.#pending.values()) clearTimeout(pending.timer);
80
+ this.#pending.clear();
81
+ }
82
+
83
+ #arm(project: string, count: number, batchId: string): Timer {
84
+ const timer = setTimeout(() => {
85
+ this.#pending.delete(project);
86
+ this.#ping(project, batchId, count);
87
+ }, this.#windowMs);
88
+ // A pending ping dropped at exit is acceptable, so the timer must never hold the process open.
89
+ timer.unref();
90
+ return timer;
91
+ }
92
+
93
+ #ping(project: string, batchId: string, count: number): void {
94
+ const dashboardUrl = `${this.#baseUrl}/?project=${encodeURIComponent(project)}`;
95
+ void this.#pingNtfy(project, count, dashboardUrl);
96
+ void this.#pingWebhook(project, batchId, count, dashboardUrl);
97
+ }
98
+
99
+ async #pingNtfy(project: string, count: number, dashboardUrl: string): Promise<void> {
100
+ const url = this.#config.NTFY_URL;
101
+ const topic = this.#config.NTFY_TOPIC;
102
+ // ntfy is armed only when both are set; a url alone posts to a topic named nothing.
103
+ if (url === undefined || topic === undefined) return;
104
+ const noun = count === 1 ? "entry" : "entries";
105
+ try {
106
+ const response = await this.#fetch(url, {
107
+ method: "POST",
108
+ headers: {
109
+ Topic: topic,
110
+ Title: `reviewzy: ${project}`,
111
+ Priority: String(this.#config.NTFY_PRIORITY),
112
+ Click: dashboardUrl,
113
+ },
114
+ body: `${count} new ${noun} from ${project} await review`,
115
+ signal: AbortSignal.timeout(PING_TIMEOUT_MS),
116
+ });
117
+ if (!response.ok) {
118
+ console.error(`reviewzy: ntfy notification failed: server answered ${response.status}`);
119
+ }
120
+ } catch (error) {
121
+ console.error(`reviewzy: ntfy notification failed: ${(error as Error).message}`);
122
+ }
123
+ }
124
+
125
+ async #pingWebhook(project: string, batchId: string, count: number, dashboardUrl: string): Promise<void> {
126
+ const url = this.#config.WEBHOOK_URL;
127
+ if (url === undefined) return;
128
+ try {
129
+ const response = await this.#fetch(url, {
130
+ method: "POST",
131
+ headers: { "content-type": "application/json" },
132
+ body: JSON.stringify({ project, batch_id: batchId, count, dashboard_url: dashboardUrl }),
133
+ signal: AbortSignal.timeout(PING_TIMEOUT_MS),
134
+ });
135
+ if (!response.ok) {
136
+ console.error(`reviewzy: webhook notification failed: server answered ${response.status}`);
137
+ }
138
+ } catch (error) {
139
+ console.error(`reviewzy: webhook notification failed: ${(error as Error).message}`);
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,318 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, openSync, rmSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { NAME } from "../version.ts";
6
+ import {
7
+ acquireClaim,
8
+ claimHolderIsDead,
9
+ isPidAlive,
10
+ isReviewzyProcess,
11
+ readLockfile,
12
+ releaseClaim,
13
+ writeLockfileAtomic,
14
+ type Lockfile,
15
+ } from "./lockfile.ts";
16
+ import { compareSemver } from "./semver.ts";
17
+
18
+ export type Health = { name: string; version: string; pid: number; nonce: string; startedAt: string };
19
+ export type DaemonHandle = { pid: number; port: number; version: string };
20
+
21
+ /** A lifecycle failure a caller can tell apart from a bug: bad state on disk, not broken code. */
22
+ export class ShimError extends Error {
23
+ override readonly name = "ShimError";
24
+ }
25
+
26
+ const HEALTH_POLL_MS = 50;
27
+ const HEALTH_BOOT_TIMEOUT_MS = 10_000;
28
+ const CLAIM_POLL_MS = 50;
29
+ const CLAIM_WAIT_MS = 15_000;
30
+ const DRAIN_EXIT_TIMEOUT_MS = 10_000;
31
+ const ATTEMPTS = 4;
32
+
33
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
34
+
35
+ const daemonEntry = fileURLToPath(new URL("../daemon/main.ts", import.meta.url));
36
+
37
+ /**
38
+ * The daemon's `/health`, or null when nothing reviewzy-shaped answers on that port. This is the
39
+ * authoritative liveness check: the pid and name in its body are what tie a lockfile entry to a
40
+ * process that is really ours and really serving.
41
+ */
42
+ export async function fetchHealth(port: number, timeoutMs = 1_500): Promise<Health | null> {
43
+ try {
44
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
45
+ signal: AbortSignal.timeout(timeoutMs),
46
+ });
47
+ if (!response.ok) return null;
48
+
49
+ const body = (await response.json()) as Partial<Health>;
50
+ if (
51
+ typeof body.name !== "string" ||
52
+ typeof body.version !== "string" ||
53
+ !Number.isInteger(body.pid) ||
54
+ typeof body.nonce !== "string" ||
55
+ typeof body.startedAt !== "string"
56
+ ) {
57
+ return null;
58
+ }
59
+ return body as Health;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Spawns the daemon detached: the shim exits on stdin EOF while the daemon keeps serving, so the
67
+ * child must never share the shim's life. stderr lands in `daemon.log` beside the lockfile — never
68
+ * the shim's own stderr, which a stdio client may pipe and which must stay free of daemon noise.
69
+ */
70
+ export function spawnDetachedDaemon(
71
+ env: Record<string, string | undefined>,
72
+ lockPath: string,
73
+ ): number {
74
+ const dataDir = dirname(lockPath);
75
+ mkdirSync(dataDir, { recursive: true });
76
+ const logFd = openSync(join(dataDir, "daemon.log"), "a");
77
+ const child = spawn(process.execPath, ["run", daemonEntry], {
78
+ env,
79
+ detached: true,
80
+ stdio: ["ignore", "ignore", logFd],
81
+ });
82
+ child.unref();
83
+ if (child.pid === undefined) throw new ShimError("reviewzy: spawning the daemon produced no pid");
84
+ console.error(`reviewzy: spawned daemon (pid ${child.pid})`);
85
+ return child.pid;
86
+ }
87
+
88
+ /**
89
+ * Waits until the process `childPid` spawned is the one answering /health on the port. Anything
90
+ * else answering /health with our name (a spoof, or an incumbent daemon that beat the child to the
91
+ * bind) ends the wait early with null, so the caller's fallback can decide instead of a spoof
92
+ * riding a dead spawn into the lockfile.
93
+ */
94
+ async function waitHealthy(port: number, timeoutMs: number, childPid: number): Promise<Health | null> {
95
+ const deadline = Date.now() + timeoutMs;
96
+ for (;;) {
97
+ const health = await fetchHealth(port);
98
+ if (health !== null && health.name === NAME) {
99
+ if (health.pid === childPid) return health;
100
+ return null;
101
+ }
102
+ if (Date.now() > deadline) return null;
103
+ await sleep(HEALTH_POLL_MS);
104
+ }
105
+ }
106
+
107
+ /** The daemon a valid lockfile names, when it is alive, ours, and actually serving its port. */
108
+ async function liveDaemon(lockPath: string): Promise<{ lock: Lockfile; health: Health } | null> {
109
+ const lock = readLockfile(lockPath);
110
+ if (lock === null) return null;
111
+ if (!isPidAlive(lock.pid)) return null;
112
+ if (!isReviewzyProcess(lock.pid)) return null;
113
+
114
+ const health = await fetchHealth(lock.port);
115
+ if (health === null || health.name !== NAME || health.pid !== lock.pid) return null;
116
+ // The boot nonce ties this lockfile to the one boot it recorded: a port answered by another
117
+ // process (or another boot's daemon) fails here, and a cross-user spoof cannot read the
118
+ // lockfile to echo the value.
119
+ if (health.nonce !== lock.nonce) return null;
120
+ return { lock, health };
121
+ }
122
+
123
+ /**
124
+ * Asks a running daemon to drain and waits out its exit. The daemon's exit frees the port, so a new
125
+ * daemon cannot be started before this returns without stealing the old one's port out from under
126
+ * in-flight requests.
127
+ */
128
+ async function drainAndAwaitExit(lock: Lockfile, token: string | undefined): Promise<void> {
129
+ const headers: Record<string, string> = {};
130
+ if (token !== undefined) headers.authorization = `Bearer ${token}`;
131
+
132
+ let response: Response;
133
+ try {
134
+ response = await fetch(`http://127.0.0.1:${lock.port}/drain`, {
135
+ method: "POST",
136
+ headers,
137
+ signal: AbortSignal.timeout(2_000),
138
+ });
139
+ } catch (error) {
140
+ throw new ShimError(
141
+ `reviewzy: could not ask daemon pid ${lock.pid} to drain: ${(error as Error).message}`,
142
+ );
143
+ }
144
+ if (response.status !== 200) {
145
+ throw new ShimError(`reviewzy: daemon pid ${lock.pid} refused /drain with http ${response.status}`);
146
+ }
147
+
148
+ const deadline = Date.now() + DRAIN_EXIT_TIMEOUT_MS;
149
+ while (isPidAlive(lock.pid)) {
150
+ if (Date.now() > deadline) {
151
+ throw new ShimError(
152
+ `reviewzy: daemon pid ${lock.pid} did not exit within ${DRAIN_EXIT_TIMEOUT_MS}ms of draining`,
153
+ );
154
+ }
155
+ await sleep(HEALTH_POLL_MS);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Waits for the claim to clear, sweeping it when its holder died mid-spawn (a shim killed between
161
+ * acquiring and releasing would otherwise lock every future session out).
162
+ */
163
+ async function waitForClaimRelease(lockPath: string): Promise<boolean> {
164
+ const claim = `${lockPath}.claim`;
165
+ const deadline = Date.now() + CLAIM_WAIT_MS;
166
+ while (Date.now() < deadline) {
167
+ if (!existsSync(claim)) return true;
168
+ if (claimHolderIsDead(lockPath)) {
169
+ try {
170
+ rmSync(claim);
171
+ } catch {
172
+ // Another waiter swept it first; the existsSync above re-decides.
173
+ }
174
+ if (!existsSync(claim)) return true;
175
+ }
176
+ await sleep(CLAIM_POLL_MS);
177
+ }
178
+ return false;
179
+ }
180
+
181
+ export type EnsureDaemonOptions = {
182
+ lockfile: string;
183
+ /** The configured `REVIEWZY_PORT`: the port the daemon is spawned on and health-checked against. */
184
+ port: number;
185
+ /** The shim's own package version, compared semantically against the daemon's `/health` version. */
186
+ shimVersion: string;
187
+ /** The bearer token for `/drain` and `/mcp` when `REVIEWZY_TOKEN` is set. */
188
+ token?: string | undefined;
189
+ /** The environment the spawned daemon inherits. */
190
+ env: Record<string, string | undefined>;
191
+ /** Overridable spawner, so tests can count spawns. Defaults to the real detached spawn. */
192
+ spawnDaemon?: ((env: Record<string, string | undefined>, lockPath: string) => number) | undefined;
193
+ };
194
+
195
+ /**
196
+ * Returns a live daemon handle, spawning or handshaking a handoff as needed:
197
+ *
198
+ * 1. A live, reviewzy-owned daemon named by a valid lockfile is adopted (and drained first, when
199
+ * the shim's version semantically outranks the daemon's).
200
+ * 2. Otherwise, under an exclusive claim, the shim re-checks for a live daemon (another shim may
201
+ * have finished while this one waited), spawns the daemon detached, waits for `/health`, and
202
+ * writes the lockfile from the health body — so the recorded pid is always the process that is
203
+ * actually serving, even when a racing spawn lost the port bind and its child died.
204
+ */
205
+ export async function ensureDaemon(options: EnsureDaemonOptions): Promise<DaemonHandle> {
206
+ const spawnDaemon = options.spawnDaemon ?? spawnDetachedDaemon;
207
+
208
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
209
+ const live = await liveDaemon(options.lockfile);
210
+ if (live !== null) {
211
+ const rank = compareSemver(options.shimVersion, live.health.version);
212
+ if (rank !== null && rank > 0) {
213
+ console.error(
214
+ `reviewzy: shim ${options.shimVersion} outranks daemon ${live.health.version}; draining pid ${live.health.pid}`,
215
+ );
216
+ await drainAndAwaitExit(live.lock, options.token);
217
+ // Falls through to the spawn phase; the drained daemon is gone and cannot be adopted.
218
+ } else {
219
+ return { pid: live.health.pid, port: live.lock.port, version: live.health.version };
220
+ }
221
+ }
222
+
223
+ if (!acquireClaim(options.lockfile)) {
224
+ const cleared = await waitForClaimRelease(options.lockfile);
225
+ if (!cleared) continue;
226
+ // The winner may have finished while we waited; the next loop adopts its daemon, or retries.
227
+ continue;
228
+ }
229
+
230
+ try {
231
+ // Re-check under the claim: another shim can have completed between our first read and here.
232
+ const raced = await liveDaemon(options.lockfile);
233
+ if (raced !== null) {
234
+ // The same version rung the adopt path applies: a raced daemon was fully booted before the
235
+ // winning shim released the claim (release follows the health wait), so draining it here
236
+ // cannot interrupt a boot.
237
+ const rank = compareSemver(options.shimVersion, raced.health.version);
238
+ if (rank !== null && rank > 0) {
239
+ console.error(
240
+ `reviewzy: shim ${options.shimVersion} outranks raced daemon ${raced.health.version}; draining pid ${raced.health.pid}`,
241
+ );
242
+ await drainAndAwaitExit(raced.lock, options.token);
243
+ // Falls through to the spawn phase; the claim is already ours.
244
+ } else {
245
+ return { pid: raced.health.pid, port: raced.lock.port, version: raced.health.version };
246
+ }
247
+ }
248
+
249
+ const spawnAndVerify = async (): Promise<DaemonHandle> => {
250
+ const childPid = spawnDaemon(options.env, options.lockfile);
251
+ const health = await waitHealthy(options.port, HEALTH_BOOT_TIMEOUT_MS, childPid);
252
+ if (health !== null) {
253
+ writeLockfileAtomic(options.lockfile, {
254
+ pid: health.pid,
255
+ port: options.port,
256
+ version: health.version,
257
+ nonce: health.nonce,
258
+ });
259
+ console.error(`reviewzy: daemon ${health.version} ready (pid ${health.pid}, port ${options.port})`);
260
+ return { pid: health.pid, port: options.port, version: health.version };
261
+ }
262
+
263
+ // The child is not the process answering the port, and it is ours. Kill it HERE, before any
264
+ // branch can leave it behind: left booting, it binds the port the moment the blocker leaves
265
+ // it and orphans itself — on the adoption branch nobody ever reclaims it, and on the drain
266
+ // branch it becomes the thief the drain then has to drain.
267
+ if (isPidAlive(childPid)) {
268
+ try {
269
+ process.kill(childPid, "SIGKILL");
270
+ } catch {
271
+ // Already gone between the check and the kill.
272
+ }
273
+ }
274
+
275
+ // Either the bind was lost to an incumbent daemon the (missing or corrupt) lockfile never
276
+ // named, or the child died on its own and a spoof or foreign process is answering. Only a
277
+ // real reviewzy daemon gets the incumbent treatment — the same version rung the adopt path
278
+ // applies, so a newer shim still drains it instead of bridging to an old daemon all session.
279
+ const incumbent = await fetchHealth(options.port);
280
+ if (
281
+ incumbent !== null &&
282
+ incumbent.name === NAME &&
283
+ isReviewzyProcess(incumbent.pid) &&
284
+ incumbent.pid !== childPid
285
+ ) {
286
+ const incumbentLock = {
287
+ pid: incumbent.pid,
288
+ port: options.port,
289
+ version: incumbent.version,
290
+ nonce: incumbent.nonce,
291
+ };
292
+ const rank = compareSemver(options.shimVersion, incumbent.version);
293
+ if (rank !== null && rank > 0) {
294
+ console.error(
295
+ `reviewzy: shim ${options.shimVersion} outranks incumbent daemon ${incumbent.version}; draining pid ${incumbent.pid}`,
296
+ );
297
+ await drainAndAwaitExit(incumbentLock, options.token);
298
+ return spawnAndVerify();
299
+ }
300
+ writeLockfileAtomic(options.lockfile, incumbentLock);
301
+ console.error(`reviewzy: adopted daemon ${incumbent.version} (pid ${incumbent.pid}, port ${options.port})`);
302
+ return { pid: incumbent.pid, port: options.port, version: incumbent.version };
303
+ }
304
+
305
+ throw new ShimError(
306
+ `reviewzy: the spawned daemon never answered /health on port ${options.port} within ${HEALTH_BOOT_TIMEOUT_MS}ms (see daemon.log beside the lockfile)`,
307
+ );
308
+ };
309
+ return await spawnAndVerify();
310
+ } finally {
311
+ releaseClaim(options.lockfile);
312
+ }
313
+ }
314
+
315
+ throw new ShimError(
316
+ `reviewzy: could not establish a daemon on port ${options.port} after ${ATTEMPTS} attempts`,
317
+ );
318
+ }
@@ -0,0 +1,153 @@
1
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export type Lockfile = { pid: number; port: number; version: string; nonce: string };
6
+
7
+ /**
8
+ * The lockfile lives in the directory the db DEFAULTS to (`docs/design.md` lifecycle row), not
9
+ * wherever `REVIEWZY_DB` points: the daemon's discovery point must stay fixed even when an operator
10
+ * relocates the store, or two shims with different dbs would hunt two different lockfiles and both
11
+ * spawn. Mirrors `defaultDbPath` in `src/config.ts`.
12
+ */
13
+ export function lockfilePath(env: Record<string, string | undefined>): string {
14
+ const dataRoot = env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), ".local", "share");
15
+ return join(dataRoot, "reviewzy", "daemon.lock");
16
+ }
17
+
18
+ /**
19
+ * Reads and validates the lockfile. Anything wrong reads as null (stale): a missing file, a
20
+ * corrupt body, or a shape missing `pid`/`port`/`version`. Staleness of a WELL-FORMED lockfile is
21
+ * decided by the caller from pid liveness, process identity, and `/health`.
22
+ */
23
+ export function readLockfile(path: string): Lockfile | null {
24
+ let raw: string;
25
+ try {
26
+ raw = readFileSync(path, "utf8");
27
+ } catch {
28
+ return null;
29
+ }
30
+
31
+ let parsed: unknown;
32
+ try {
33
+ parsed = JSON.parse(raw);
34
+ } catch {
35
+ return null;
36
+ }
37
+ if (typeof parsed !== "object" || parsed === null) return null;
38
+
39
+ const { pid, port, version, nonce } = parsed as Record<string, unknown>;
40
+ if (!Number.isInteger(pid) || (pid as number) <= 0) return null;
41
+ if (!Number.isInteger(port) || (port as number) < 1 || (port as number) > 65535) return null;
42
+ if (typeof version !== "string") return null;
43
+ // The nonce ties the lockfile to one daemon boot: adoption requires /health to carry it.
44
+ if (typeof nonce !== "string") return null;
45
+ return { pid: pid as number, port: port as number, version: version as string, nonce: nonce as string };
46
+ }
47
+
48
+ /**
49
+ * Writes via `rename(2)` from a per-pid temp file in the same directory, so a reader never sees a
50
+ * half-written lockfile and two racing writers each atomically land a complete document. Both
51
+ * writers derive their content from the daemon's own `/health`, so whichever rename lands last,
52
+ * the file names a daemon that is really serving.
53
+ */
54
+ export function writeLockfileAtomic(path: string, lock: Lockfile): void {
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
57
+ writeFileSync(temp, `${JSON.stringify(lock)}\n`);
58
+ renameSync(temp, path);
59
+ }
60
+
61
+ /**
62
+ * `kill(pid, 0)` probes without signalling. `EPERM` means the process exists but belongs to
63
+ * another user: alive, which is the only question here.
64
+ */
65
+ export function isPidAlive(pid: number): boolean {
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ } catch (error) {
70
+ return (error as NodeJS.ErrnoException).code === "EPERM";
71
+ }
72
+ }
73
+
74
+ /** The real uid `/proc/<pid>/status` reports, or null when the file is unreadable. */
75
+ export function processUid(pid: number): number | null {
76
+ try {
77
+ const match = /^Uid:\s*(\d+)/m.exec(readFileSync(`/proc/${pid}/status`, "utf8"));
78
+ return match === null ? null : Number(match[1]);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Answers "could this pid be OUR daemon". The daemon's argv always embeds its entry path, and this
86
+ * matches the entry path's tail `/src/daemon/main.ts`, never the checkout dir's name: a renamed
87
+ * checkout and the npm layout (`node_modules/reviewzy/src/daemon/main.ts`) both match, while a
88
+ * process that merely mentions "reviewzy" in its argv (an editor with this repo open) is still
89
+ * foreign. An unreadable cmdline (no `/proc`, or the process vanished mid-read) reads as "could be
90
+ * ours": the caller's `/health` check is the authoritative decision, and refusing here would drain
91
+ * a healthy daemon on a probe hiccup.
92
+ */
93
+ export function isReviewzyProcess(pid: number): boolean {
94
+ let cmdline: string;
95
+ try {
96
+ cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
97
+ } catch {
98
+ return true;
99
+ }
100
+ if (!cmdline.includes("/src/daemon/main.ts")) return false;
101
+
102
+ // Cross-user defence: another local user can bind 127.0.0.1 and craft argv embedding the entry
103
+ // path, and a spoof adopted on the lockfile-less incumbent path would receive the shim's bearer
104
+ // token on /mcp. The daemon always runs as the shim's user, so a different uid is never ours.
105
+ // An unreadable status keeps the cmdline verdict.
106
+ const uid = processUid(pid);
107
+ const ownUid = process.getuid?.();
108
+ if (uid !== null && ownUid !== undefined && uid !== ownUid) return false;
109
+ return true;
110
+ }
111
+
112
+ /**
113
+ * One shim may spawn the daemon at a time, enforced by an exclusively-created claim file beside the
114
+ * lockfile. `O_EXCL` is the whole mutex: no advisory locks to leak, and a crashed claimer is
115
+ * detected by the pid it wrote into the claim.
116
+ */
117
+ export function acquireClaim(lockPath: string): boolean {
118
+ const claim = `${lockPath}.claim`;
119
+ // The claim is the FIRST thing a fresh install ever writes here: no lockfile has created the
120
+ // directory yet, so the claim creates it.
121
+ mkdirSync(dirname(lockPath), { recursive: true });
122
+ try {
123
+ const fd = openSync(claim, "wx");
124
+ try {
125
+ writeSync(fd, String(process.pid));
126
+ } finally {
127
+ closeSync(fd);
128
+ }
129
+ return true;
130
+ } catch (error) {
131
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ export function releaseClaim(lockPath: string): void {
137
+ rmSync(`${lockPath}.claim`, { force: true });
138
+ }
139
+
140
+ /**
141
+ * True when the claim is safe to sweep: the shim that created it is gone, OR its content is
142
+ * unparseable. A holder killed between `openSync(claim, "wx")` and `writeSync` leaves an empty or
143
+ * partial file, and reading that as "still held" would wedge every future shim permanently.
144
+ */
145
+ export function claimHolderIsDead(lockPath: string): boolean {
146
+ try {
147
+ const pid = Number.parseInt(readFileSync(`${lockPath}.claim`, "utf8").trim(), 10);
148
+ if (Number.isNaN(pid)) return true;
149
+ return !isPidAlive(pid);
150
+ } catch {
151
+ return false;
152
+ }
153
+ }