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.
- package/.env.example +30 -0
- package/LICENSE +661 -0
- package/README.md +170 -0
- package/bin/reviewzy +7 -0
- package/package.json +29 -0
- package/src/config.ts +76 -0
- package/src/daemon/app.ts +159 -0
- package/src/daemon/main.ts +103 -0
- package/src/daemon/origin.ts +60 -0
- package/src/dashboard/auth.ts +122 -0
- package/src/dashboard/mount.ts +373 -0
- package/src/dashboard/static/components.css +1116 -0
- package/src/dashboard/static/dashboard.css +182 -0
- package/src/dashboard/static/dashboard.js +169 -0
- package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/htmx.min.js +2 -0
- package/src/dashboard/static/tokens.css +213 -0
- package/src/dashboard/views.tsx +1388 -0
- package/src/db/human-save.ts +242 -0
- package/src/db/migrate.ts +71 -0
- package/src/db/migrations.ts +149 -0
- package/src/db/queries.ts +425 -0
- package/src/db/store.ts +83 -0
- package/src/db/style-guide.ts +236 -0
- package/src/db/sweep.ts +88 -0
- package/src/mcp/await-approved.ts +156 -0
- package/src/mcp/fetch-approved.ts +132 -0
- package/src/mcp/file-entries.ts +219 -0
- package/src/mcp/list-entries.ts +107 -0
- package/src/mcp/mark-applied.ts +103 -0
- package/src/mcp/route.ts +88 -0
- package/src/mcp/server.ts +59 -0
- package/src/mcp/style-guide-resource.ts +40 -0
- package/src/notify.ts +142 -0
- package/src/shim/daemon.ts +318 -0
- package/src/shim/lockfile.ts +153 -0
- package/src/shim/main.ts +172 -0
- package/src/shim/semver.ts +29 -0
- package/src/version.ts +4 -0
- 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
|
+
}
|