@frixaco/hbench 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/README.md +51 -0
- package/bin/hbench.js +59 -0
- package/bun-env.d.ts +17 -0
- package/lib/.gitkeep +0 -0
- package/package.json +74 -0
- package/server/build.ts +172 -0
- package/server/index.ts +539 -0
- package/server/review.ts +162 -0
- package/server/tsconfig.json +9 -0
- package/tsconfig.base.json +25 -0
- package/tsconfig.json +4 -0
- package/ui/app.tsx +15 -0
- package/ui/components/button.tsx +57 -0
- package/ui/components/cmd-bar.tsx +131 -0
- package/ui/components/diff-view.tsx +51 -0
- package/ui/components/input.tsx +18 -0
- package/ui/components/review-sheet.tsx +261 -0
- package/ui/components/review-view.tsx +40 -0
- package/ui/components/select.tsx +199 -0
- package/ui/components/sheet.tsx +131 -0
- package/ui/components/sonner.tsx +41 -0
- package/ui/components/tui.tsx +313 -0
- package/ui/ghostty-web.tsx +138 -0
- package/ui/index.html +13 -0
- package/ui/index.tsx +20 -0
- package/ui/lib/agent-patterns.ts +127 -0
- package/ui/lib/diff-client.ts +38 -0
- package/ui/lib/models.json +8 -0
- package/ui/lib/reviewer.ts +82 -0
- package/ui/lib/store.ts +90 -0
- package/ui/lib/utils.ts +7 -0
- package/ui/lib/websocket.tsx +144 -0
- package/ui/styles.css +89 -0
- package/ui/tsconfig.json +8 -0
package/server/index.ts
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
const procs = new Map<string, Bun.Subprocess>();
|
|
2
|
+
const agentWorktrees = new Map<string, string>();
|
|
3
|
+
|
|
4
|
+
const agents = Object.keys(agentModeMapping);
|
|
5
|
+
const defaultCols = 80;
|
|
6
|
+
const defaultRows = 24;
|
|
7
|
+
const sandboxRoot = path.join(os.homedir(), ".hbench");
|
|
8
|
+
const portFromEnv = parsePort(
|
|
9
|
+
process.env.BUN_PORT ?? process.env.PORT ?? process.env.NODE_PORT,
|
|
10
|
+
);
|
|
11
|
+
const serverIdleTimeoutSeconds = 120;
|
|
12
|
+
const stopDelayMs = {
|
|
13
|
+
interrupt: 250,
|
|
14
|
+
secondInterrupt: 350,
|
|
15
|
+
term: 1200,
|
|
16
|
+
kill: 500,
|
|
17
|
+
};
|
|
18
|
+
let stopAllInFlight: Promise<void> | null = null;
|
|
19
|
+
|
|
20
|
+
const agentBranchName = (agent: string) => `agent/${agent}`;
|
|
21
|
+
|
|
22
|
+
const server = Bun.serve({
|
|
23
|
+
...(portFromEnv ? { port: portFromEnv } : {}),
|
|
24
|
+
idleTimeout: serverIdleTimeoutSeconds,
|
|
25
|
+
routes: {
|
|
26
|
+
"/*": index,
|
|
27
|
+
|
|
28
|
+
"/api/stop": {
|
|
29
|
+
async POST() {
|
|
30
|
+
try {
|
|
31
|
+
await stopAllProcesses();
|
|
32
|
+
return Response.json({ status: "success" });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const message =
|
|
35
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
36
|
+
return new Response(message, { status: 500 });
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
"/api/diff": {
|
|
42
|
+
async GET(req) {
|
|
43
|
+
const url = new URL(req.url);
|
|
44
|
+
const agent = url.searchParams.get("agent");
|
|
45
|
+
if (!agent || !agents.includes(agent)) {
|
|
46
|
+
return new Response("Unknown agent", { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
let cwd = agentWorktrees.get(agent);
|
|
49
|
+
if (!cwd) {
|
|
50
|
+
const repoUrl = url.searchParams.get("repoUrl");
|
|
51
|
+
const slug = repoUrl ? repoSlugFromUrl(repoUrl) : null;
|
|
52
|
+
if (slug) {
|
|
53
|
+
const candidate = path.join(sandboxRoot, slug, "worktrees", agent);
|
|
54
|
+
if (existsSync(candidate)) {
|
|
55
|
+
cwd = candidate;
|
|
56
|
+
agentWorktrees.set(agent, candidate);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!cwd) {
|
|
61
|
+
return new Response("No worktree configured. Run SETUP first.", {
|
|
62
|
+
status: 400,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const diff = await buildWorktreeDiff(cwd);
|
|
67
|
+
return new Response(diff || "", {
|
|
68
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const message =
|
|
72
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
73
|
+
return new Response(message, { status: 500 });
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
"/api/review": {
|
|
79
|
+
POST(req) {
|
|
80
|
+
return handleReviewPost(req);
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
"/api/vt": (req, server) => {
|
|
85
|
+
if (server.upgrade(req)) return;
|
|
86
|
+
return new Response("Upgrade failed", { status: 400 });
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
websocket: {
|
|
91
|
+
async message(ws, message) {
|
|
92
|
+
console.log(message);
|
|
93
|
+
|
|
94
|
+
if (typeof message !== "string") return;
|
|
95
|
+
const text = message;
|
|
96
|
+
|
|
97
|
+
if (agents.includes(text)) {
|
|
98
|
+
await initAgent();
|
|
99
|
+
return;
|
|
100
|
+
} else if (text.startsWith("{")) {
|
|
101
|
+
await runCommand();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const [agent, input] = message.split(":");
|
|
106
|
+
procs.get(agent!)?.terminal?.write(input!);
|
|
107
|
+
|
|
108
|
+
async function initAgent() {
|
|
109
|
+
const agent = text;
|
|
110
|
+
const cwd = agentWorktrees.get(agent);
|
|
111
|
+
if (!cwd) {
|
|
112
|
+
console.warn("Missing worktree for agent", agent);
|
|
113
|
+
sendAgentNotice(
|
|
114
|
+
ws,
|
|
115
|
+
agent,
|
|
116
|
+
"No worktree configured. Run SETUP first.\r\n",
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await stopAgentProcess(agent);
|
|
122
|
+
|
|
123
|
+
const proc = Bun.spawn([agent], {
|
|
124
|
+
cwd,
|
|
125
|
+
env: { ...process.env },
|
|
126
|
+
onExit(exitedProc, _exitCode, _signalCode, _error) {
|
|
127
|
+
if (procs.get(agent) === exitedProc) {
|
|
128
|
+
procs.delete(agent);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
terminal: {
|
|
132
|
+
cols: defaultCols,
|
|
133
|
+
rows: defaultRows,
|
|
134
|
+
data(_terminal, data) {
|
|
135
|
+
const encoded = Buffer.from(data).toString("base64");
|
|
136
|
+
ws.send(
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
type: "output",
|
|
139
|
+
agent,
|
|
140
|
+
data: encoded,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
procs.set(agent, proc);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function runCommand() {
|
|
151
|
+
try {
|
|
152
|
+
const payload = JSON.parse(text);
|
|
153
|
+
if (payload.type === "wipe") {
|
|
154
|
+
sendStatus(ws, "wipe-status", "start");
|
|
155
|
+
try {
|
|
156
|
+
await stopAllProcesses();
|
|
157
|
+
wipeSandbox();
|
|
158
|
+
sendStatus(ws, "wipe-status", "success");
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const errorMessage =
|
|
161
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
162
|
+
sendStatus(ws, "wipe-status", "error", { message: errorMessage });
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (payload.type === "setup") {
|
|
167
|
+
const repoUrl = payload.repoUrl;
|
|
168
|
+
if (!repoUrl || typeof repoUrl !== "string") {
|
|
169
|
+
sendStatus(ws, "setup-status", "error", {
|
|
170
|
+
message: "Missing repository URL",
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
sendStatus(ws, "setup-status", "start", { repoUrl });
|
|
176
|
+
await ensureAgentWorktrees(repoUrl);
|
|
177
|
+
sendStatus(ws, "setup-status", "success", { repoUrl });
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const errorMessage =
|
|
180
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
181
|
+
sendStatus(ws, "setup-status", "error", {
|
|
182
|
+
repoUrl,
|
|
183
|
+
message: errorMessage,
|
|
184
|
+
});
|
|
185
|
+
console.warn("Setup failed", error);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (payload.type === "use-existing") {
|
|
190
|
+
const repoUrl = payload.repoUrl;
|
|
191
|
+
if (!repoUrl || typeof repoUrl !== "string") {
|
|
192
|
+
sendStatus(ws, "setup-status", "error", {
|
|
193
|
+
message: "Missing repository URL",
|
|
194
|
+
mode: "existing",
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
sendStatus(ws, "setup-status", "start", {
|
|
200
|
+
repoUrl,
|
|
201
|
+
mode: "existing",
|
|
202
|
+
});
|
|
203
|
+
loadExistingAgentWorktrees(repoUrl);
|
|
204
|
+
sendStatus(ws, "setup-status", "success", {
|
|
205
|
+
repoUrl,
|
|
206
|
+
mode: "existing",
|
|
207
|
+
});
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const errorMessage =
|
|
210
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
211
|
+
sendStatus(ws, "setup-status", "error", {
|
|
212
|
+
repoUrl,
|
|
213
|
+
mode: "existing",
|
|
214
|
+
message: errorMessage,
|
|
215
|
+
});
|
|
216
|
+
console.warn("Use existing worktrees failed", error);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (payload.type === "input") {
|
|
221
|
+
const agent = payload.agent;
|
|
222
|
+
if (!agent || typeof agent !== "string") return;
|
|
223
|
+
if (!agents.includes(agent)) return;
|
|
224
|
+
if (typeof payload.data !== "string") return;
|
|
225
|
+
procs.get(agent)?.terminal?.write(payload.data);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (payload.type === "resize") {
|
|
229
|
+
const agent = payload.agent;
|
|
230
|
+
if (!agent || typeof agent !== "string") return;
|
|
231
|
+
if (!agents.includes(agent)) return;
|
|
232
|
+
const cols = Math.trunc(Number(payload.cols));
|
|
233
|
+
const rows = Math.trunc(Number(payload.rows));
|
|
234
|
+
if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows))
|
|
235
|
+
return;
|
|
236
|
+
if (cols < 2 || rows < 1) return;
|
|
237
|
+
if (cols > 2000 || rows > 1000) return;
|
|
238
|
+
try {
|
|
239
|
+
procs.get(agent)?.terminal?.resize(cols, rows);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.warn("Failed to resize terminal", {
|
|
242
|
+
agent,
|
|
243
|
+
cols,
|
|
244
|
+
rows,
|
|
245
|
+
error,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
console.warn("Invalid message payload", error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
open(_ws) {
|
|
256
|
+
console.log("opening");
|
|
257
|
+
},
|
|
258
|
+
close(_ws, _code, _message) {
|
|
259
|
+
console.log("closing");
|
|
260
|
+
void stopAllProcesses().catch((error) => {
|
|
261
|
+
console.warn("Failed to stop processes on socket close", error);
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
drain(_ws) {
|
|
265
|
+
console.log("draining");
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
development: process.env.NODE_ENV !== "production" && {
|
|
270
|
+
// Enable browser hot reloading in development
|
|
271
|
+
hmr: true,
|
|
272
|
+
|
|
273
|
+
// Echo console logs from the browser to the server
|
|
274
|
+
console: true,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
console.log(`🚀 Server running at ${server.url}`);
|
|
279
|
+
|
|
280
|
+
// UTILS
|
|
281
|
+
|
|
282
|
+
const repoSlugFromUrl = (repoUrl: string) => {
|
|
283
|
+
const match = repoUrl.trim().match(/[:/]([^/]+\/[^/]+?)(\.git)?$/);
|
|
284
|
+
if (!match) return null;
|
|
285
|
+
return match[1]?.replace(/\.git$/, "").replace(/[/\\]/g, "-");
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
function parsePort(value: string | undefined) {
|
|
289
|
+
if (!value) return null;
|
|
290
|
+
if (!/^\d+$/.test(value)) return null;
|
|
291
|
+
|
|
292
|
+
const parsed = Number.parseInt(value, 10);
|
|
293
|
+
if (!Number.isSafeInteger(parsed)) return null;
|
|
294
|
+
if (parsed < 1 || parsed > 65535) return null;
|
|
295
|
+
|
|
296
|
+
return parsed;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const runGit = async (
|
|
300
|
+
args: string[],
|
|
301
|
+
cwd: string,
|
|
302
|
+
acceptedExitCodes: number[] = [0],
|
|
303
|
+
): Promise<{ stdout: string; stderr: string }> => {
|
|
304
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
305
|
+
cwd,
|
|
306
|
+
stdout: "pipe",
|
|
307
|
+
stderr: "pipe",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
311
|
+
proc.exited,
|
|
312
|
+
proc.stdout.text(),
|
|
313
|
+
proc.stderr.text(),
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
if (!acceptedExitCodes.includes(exitCode)) {
|
|
317
|
+
throw new Error(`git ${args.join(" ")} failed: ${stderr.trim()}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { stdout, stderr };
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const buildWorktreeDiff = async (cwd: string) => {
|
|
324
|
+
const diff = await runGit(["-C", cwd, "diff"], cwd);
|
|
325
|
+
const status = await runGit(["-C", cwd, "status", "--porcelain"], cwd);
|
|
326
|
+
const untracked = status.stdout
|
|
327
|
+
.split("\n")
|
|
328
|
+
.map((line) => line.trim())
|
|
329
|
+
.filter((line) => line.startsWith("?? "))
|
|
330
|
+
.map((line) => line.slice(3));
|
|
331
|
+
|
|
332
|
+
if (untracked.length === 0) {
|
|
333
|
+
return diff.stdout;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const untrackedDiffs = await Promise.all(
|
|
337
|
+
untracked.map((file) =>
|
|
338
|
+
runGit(
|
|
339
|
+
["-C", cwd, "diff", "--no-index", "--", "/dev/null", file],
|
|
340
|
+
cwd,
|
|
341
|
+
[0, 1],
|
|
342
|
+
),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
return [diff.stdout, ...untrackedDiffs.map((entry) => entry.stdout)]
|
|
346
|
+
.filter(Boolean)
|
|
347
|
+
.join("\n");
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const sendStatus = (
|
|
351
|
+
ws: Bun.ServerWebSocket<undefined>,
|
|
352
|
+
type: "setup-status" | "wipe-status",
|
|
353
|
+
status: "start" | "success" | "error",
|
|
354
|
+
payload: Record<string, unknown> = {},
|
|
355
|
+
) => {
|
|
356
|
+
ws.send(
|
|
357
|
+
JSON.stringify({
|
|
358
|
+
type,
|
|
359
|
+
status,
|
|
360
|
+
...payload,
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const sendAgentNotice = (
|
|
366
|
+
ws: Bun.ServerWebSocket<undefined>,
|
|
367
|
+
agent: string,
|
|
368
|
+
message: string,
|
|
369
|
+
) => {
|
|
370
|
+
ws.send(
|
|
371
|
+
JSON.stringify({
|
|
372
|
+
type: "output",
|
|
373
|
+
agent,
|
|
374
|
+
data: Buffer.from(message).toString("base64"),
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const wait = (ms: number) =>
|
|
380
|
+
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
381
|
+
|
|
382
|
+
const waitForExit = async (proc: Bun.Subprocess, timeoutMs: number) => {
|
|
383
|
+
if (proc.exitCode !== null) return true;
|
|
384
|
+
await Promise.race([proc.exited, wait(timeoutMs)]);
|
|
385
|
+
return proc.exitCode !== null;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const stopTrackedProcess = async (agent: string, proc: Bun.Subprocess) => {
|
|
389
|
+
if (proc.exitCode === null) {
|
|
390
|
+
try {
|
|
391
|
+
proc.terminal?.write("\u0003");
|
|
392
|
+
} catch (error) {
|
|
393
|
+
console.warn("Failed to send interrupt", agent, error);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const exitedAfterInterrupt = await waitForExit(proc, stopDelayMs.interrupt);
|
|
397
|
+
if (!exitedAfterInterrupt && proc.exitCode === null) {
|
|
398
|
+
try {
|
|
399
|
+
proc.terminal?.write("\u0003");
|
|
400
|
+
} catch (error) {
|
|
401
|
+
console.warn("Failed to send second interrupt", agent, error);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const exitedAfterSecondInterrupt = await waitForExit(
|
|
406
|
+
proc,
|
|
407
|
+
stopDelayMs.secondInterrupt,
|
|
408
|
+
);
|
|
409
|
+
if (!exitedAfterSecondInterrupt && proc.exitCode === null) {
|
|
410
|
+
try {
|
|
411
|
+
proc.kill("SIGTERM");
|
|
412
|
+
} catch (error) {
|
|
413
|
+
console.warn("Failed to send SIGTERM", agent, error);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const exitedAfterTerm = await waitForExit(proc, stopDelayMs.term);
|
|
418
|
+
if (!exitedAfterTerm && proc.exitCode === null) {
|
|
419
|
+
try {
|
|
420
|
+
proc.kill("SIGKILL");
|
|
421
|
+
} catch (error) {
|
|
422
|
+
console.warn("Failed to send SIGKILL", agent, error);
|
|
423
|
+
}
|
|
424
|
+
await waitForExit(proc, stopDelayMs.kill);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
proc.terminal?.close();
|
|
430
|
+
} catch (error) {
|
|
431
|
+
console.warn("Failed to close terminal", agent, error);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (procs.get(agent) === proc) {
|
|
435
|
+
procs.delete(agent);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const stopAgentProcess = async (agent: string) => {
|
|
440
|
+
const proc = procs.get(agent);
|
|
441
|
+
if (!proc) return;
|
|
442
|
+
await stopTrackedProcess(agent, proc);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const stopAllProcesses = async () => {
|
|
446
|
+
if (stopAllInFlight) {
|
|
447
|
+
await stopAllInFlight;
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
stopAllInFlight = (async () => {
|
|
452
|
+
const entries = Array.from(procs.entries());
|
|
453
|
+
await Promise.all(
|
|
454
|
+
entries.map(([agent, proc]) => stopTrackedProcess(agent, proc)),
|
|
455
|
+
);
|
|
456
|
+
procs.clear();
|
|
457
|
+
})();
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
await stopAllInFlight;
|
|
461
|
+
} finally {
|
|
462
|
+
stopAllInFlight = null;
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const wipeSandbox = () => {
|
|
467
|
+
if (!existsSync(sandboxRoot)) return;
|
|
468
|
+
rmSync(sandboxRoot, { force: true, recursive: true });
|
|
469
|
+
agentWorktrees.clear();
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const ensureAgentWorktrees = async (repoUrl: string) => {
|
|
473
|
+
const slug = repoSlugFromUrl(repoUrl);
|
|
474
|
+
if (!slug) throw new Error("Invalid repository URL");
|
|
475
|
+
|
|
476
|
+
const repoRoot = path.join(sandboxRoot, slug);
|
|
477
|
+
const baseRepoPath = path.join(repoRoot, "repo");
|
|
478
|
+
const worktreeRoot = path.join(repoRoot, "worktrees");
|
|
479
|
+
|
|
480
|
+
if (existsSync(repoRoot)) {
|
|
481
|
+
rmSync(repoRoot, { force: true, recursive: true });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
mkdirSync(repoRoot, { recursive: true });
|
|
485
|
+
mkdirSync(worktreeRoot, { recursive: true });
|
|
486
|
+
await runGit(["clone", repoUrl, baseRepoPath], repoRoot);
|
|
487
|
+
|
|
488
|
+
for (const agent of agents) {
|
|
489
|
+
const worktreePath = path.join(worktreeRoot, agent);
|
|
490
|
+
if (!existsSync(worktreePath)) {
|
|
491
|
+
await runGit(
|
|
492
|
+
[
|
|
493
|
+
"-C",
|
|
494
|
+
baseRepoPath,
|
|
495
|
+
"worktree",
|
|
496
|
+
"add",
|
|
497
|
+
"-b",
|
|
498
|
+
agentBranchName(agent),
|
|
499
|
+
worktreePath,
|
|
500
|
+
],
|
|
501
|
+
repoRoot,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
agentWorktrees.set(agent, worktreePath);
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const loadExistingAgentWorktrees = (repoUrl: string) => {
|
|
509
|
+
const slug = repoSlugFromUrl(repoUrl);
|
|
510
|
+
if (!slug) throw new Error("Invalid repository URL");
|
|
511
|
+
|
|
512
|
+
const repoRoot = path.join(sandboxRoot, slug);
|
|
513
|
+
const worktreeRoot = path.join(repoRoot, "worktrees");
|
|
514
|
+
if (!existsSync(worktreeRoot)) {
|
|
515
|
+
throw new Error("No existing worktrees found. Run SETUP first.");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const missingAgents = agents.filter(
|
|
519
|
+
(agent) => !existsSync(path.join(worktreeRoot, agent)),
|
|
520
|
+
);
|
|
521
|
+
if (missingAgents.length > 0) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`Missing worktrees for: ${missingAgents.join(", ")}. Run SETUP to recreate.`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
for (const agent of agents) {
|
|
528
|
+
const worktreePath = path.join(worktreeRoot, agent);
|
|
529
|
+
agentWorktrees.set(agent, worktreePath);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
import index from "../ui/index.html";
|
|
534
|
+
|
|
535
|
+
import { existsSync, mkdirSync, rmSync } from "fs";
|
|
536
|
+
import os from "os";
|
|
537
|
+
import path from "path";
|
|
538
|
+
import { handleReviewPost } from "./review";
|
|
539
|
+
import agentModeMapping from "../ui/lib/models.json";
|
package/server/review.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const allowedReviewModels = new Set([
|
|
2
|
+
"openai/gpt-5.2",
|
|
3
|
+
"google/gemini-3-pro-preview",
|
|
4
|
+
]);
|
|
5
|
+
|
|
6
|
+
const maxPromptChars = 48_000;
|
|
7
|
+
|
|
8
|
+
type ReviewRequestPayload = {
|
|
9
|
+
model: string;
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
prompt: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
class ReviewRequestError extends Error {
|
|
15
|
+
status: number;
|
|
16
|
+
|
|
17
|
+
constructor(status: number, message: string) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ReviewRequestError";
|
|
20
|
+
this.status = status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const createRequestId = () =>
|
|
25
|
+
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
26
|
+
|
|
27
|
+
const logReviewApi = (
|
|
28
|
+
requestId: string,
|
|
29
|
+
message: string,
|
|
30
|
+
payload?: unknown,
|
|
31
|
+
) => {
|
|
32
|
+
if (payload === undefined) {
|
|
33
|
+
console.log(`[review-api:${requestId}] ${message}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`[review-api:${requestId}] ${message}`, payload);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const errorResponse = (status: number, message: string, requestId?: string) =>
|
|
41
|
+
new Response(message, {
|
|
42
|
+
status,
|
|
43
|
+
headers: {
|
|
44
|
+
"content-type": "text/plain; charset=utf-8",
|
|
45
|
+
...(requestId ? { "x-review-request-id": requestId } : {}),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const asNonEmptyString = (value: unknown): string | null => {
|
|
50
|
+
if (typeof value !== "string") return null;
|
|
51
|
+
const trimmed = value.trim();
|
|
52
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const parsePrompt = (value: unknown): string => {
|
|
56
|
+
const prompt = asNonEmptyString(value);
|
|
57
|
+
if (!prompt) {
|
|
58
|
+
throw new ReviewRequestError(400, "Missing review prompt");
|
|
59
|
+
}
|
|
60
|
+
if (prompt.length > maxPromptChars) {
|
|
61
|
+
throw new ReviewRequestError(400, "Review prompt is too large");
|
|
62
|
+
}
|
|
63
|
+
return prompt;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const parseReviewRequest = async (
|
|
67
|
+
req: Request,
|
|
68
|
+
): Promise<ReviewRequestPayload> => {
|
|
69
|
+
let payload: unknown;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
payload = await req.json();
|
|
73
|
+
} catch {
|
|
74
|
+
throw new ReviewRequestError(400, "Invalid JSON body");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!payload || typeof payload !== "object") {
|
|
78
|
+
throw new ReviewRequestError(400, "Invalid review request body");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const record = payload as Record<string, unknown>;
|
|
82
|
+
const model = asNonEmptyString(record.model);
|
|
83
|
+
if (!model) {
|
|
84
|
+
throw new ReviewRequestError(400, "Missing review model");
|
|
85
|
+
}
|
|
86
|
+
if (!allowedReviewModels.has(model)) {
|
|
87
|
+
throw new ReviewRequestError(400, "Unsupported review model");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const apiKey = asNonEmptyString(record.apiKey) ?? undefined;
|
|
91
|
+
const prompt = parsePrompt(record.prompt);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
model,
|
|
95
|
+
apiKey,
|
|
96
|
+
prompt,
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const handleReviewPost = async (req: Request) => {
|
|
101
|
+
const requestId = createRequestId();
|
|
102
|
+
try {
|
|
103
|
+
logReviewApi(requestId, "incoming review request");
|
|
104
|
+
const payload = await parseReviewRequest(req);
|
|
105
|
+
const resolvedApiKey =
|
|
106
|
+
payload.apiKey ?? asNonEmptyString(process.env.OPENROUTER_API_KEY);
|
|
107
|
+
|
|
108
|
+
if (!resolvedApiKey) {
|
|
109
|
+
throw new ReviewRequestError(
|
|
110
|
+
400,
|
|
111
|
+
"Missing OpenRouter API key. Provide a key or set OPENROUTER_API_KEY.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
logReviewApi(requestId, "validated review request", {
|
|
116
|
+
model: payload.model,
|
|
117
|
+
promptChars: payload.prompt.length,
|
|
118
|
+
keySource: payload.apiKey ? "request" : "env",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const openrouter = createOpenRouter({ apiKey: resolvedApiKey });
|
|
122
|
+
const result = streamText({
|
|
123
|
+
model: openrouter(payload.model),
|
|
124
|
+
prompt: payload.prompt,
|
|
125
|
+
providerOptions: { openrouter: { reasoning: { effort: "none" } } },
|
|
126
|
+
maxRetries: 1,
|
|
127
|
+
onFinish({ finishReason, usage }) {
|
|
128
|
+
logReviewApi(requestId, "review stream finished", {
|
|
129
|
+
finishReason,
|
|
130
|
+
totalTokens: usage.totalTokens,
|
|
131
|
+
inputTokens: usage.inputTokens,
|
|
132
|
+
outputTokens: usage.outputTokens,
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
onError({ error }) {
|
|
136
|
+
const message =
|
|
137
|
+
error instanceof Error ? error.message : "Unknown stream error";
|
|
138
|
+
logReviewApi(requestId, "review stream error", { message });
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
logReviewApi(requestId, "stream response opened");
|
|
143
|
+
return result.toTextStreamResponse({
|
|
144
|
+
headers: {
|
|
145
|
+
"x-review-request-id": requestId,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const message =
|
|
150
|
+
error instanceof Error ? error.message : "Failed to start review stream";
|
|
151
|
+
logReviewApi(requestId, "review handler failed", { message });
|
|
152
|
+
|
|
153
|
+
if (error instanceof ReviewRequestError) {
|
|
154
|
+
return errorResponse(error.status, error.message, requestId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return errorResponse(500, message, requestId);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
|
162
|
+
import { streamText } from "ai";
|