@a-t-h-i/bot-lobby 0.6.16 → 0.6.18
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 +53 -690
- package/package.json +1 -1
- package/src/ask/relay.ts +3 -3
- package/src/index.ts +4 -1
- package/src/lobby/host.ts +3 -0
- package/src/lobby/keys.ts +2 -0
- package/src/lobby/planner.ts +25 -0
- package/src/lobby/pulls.ts +44 -1
- package/src/lobby/service.ts +31 -3
- package/src/state/attachments.ts +20 -20
- package/src/state/comments.ts +25 -3
- package/src/state/previews.ts +15 -0
- package/src/webui/api/index.ts +10 -2
- package/src/webui/api/lobby.ts +1 -1
- package/src/webui/api/planner.ts +15 -2
- package/src/webui/api/prompts.ts +4 -4
- package/src/webui/api/quickfix.ts +1 -1
- package/src/webui/api/sessions.ts +2 -2
- package/src/webui/api/tasks.ts +20 -2
- package/src/webui/dev/fake-service.ts +19 -1
- package/src/webui/files.ts +15 -17
- package/src/webui/gateway.ts +64 -0
- package/src/webui/projects.ts +111 -0
- package/src/webui/protocol.ts +4 -0
- package/src/webui/server.ts +35 -6
- package/src/webui/uploads.ts +19 -9
- package/src/workflow/workflow.ts +37 -3
- package/webui/dist/assets/index-CHLNxlA1.js +98 -0
- package/webui/dist/assets/index-m9XcQvH8.css +2 -0
- package/webui/dist/build.json +99 -67
- package/webui/dist/index.html +3 -3
- package/webui/dist/sw.js +0 -1
- package/webui/dist/assets/index-h1uo5XHE.js +0 -92
- package/webui/dist/assets/index-kCj0-8z_.css +0 -2
- package/webui/dist/vite.svg +0 -1
|
@@ -9,6 +9,7 @@ import { LobbyFeed } from "../../lobby/feed.ts";
|
|
|
9
9
|
import { promptHub } from "../../lobby/prompt-hub.ts";
|
|
10
10
|
import { lobbyTopics } from "../../lobby/topics.ts";
|
|
11
11
|
import type { BackgroundSession, DialogAnswer, SessionDialog } from "../../lobby/sessions.ts";
|
|
12
|
+
import type { PlanComment } from "../../state/comments.ts";
|
|
12
13
|
import type { LobbyService } from "../../lobby/host.ts";
|
|
13
14
|
import { loadScenario, type ScenarioFixture } from "./fixtures.ts";
|
|
14
15
|
|
|
@@ -40,6 +41,13 @@ function fakePlanner(entry: NonNullable<ScenarioFixture["mockPlanner"]>): Record
|
|
|
40
41
|
async send(text: string): Promise<void> {
|
|
41
42
|
(session.messages as Array<Record<string, unknown>>).push({ role: "you", text, at: Date.now() });
|
|
42
43
|
},
|
|
44
|
+
async editMessage(messageIndex: number, at: number, text: string): Promise<void> {
|
|
45
|
+
const message = session.messages[messageIndex];
|
|
46
|
+
if (session.busy || !message || message.at !== at) throw new Error("the message has changed");
|
|
47
|
+
if (message.role !== "you" || (Array.isArray(message.settled) && message.settled.length > 0)) throw new Error("only ordinary user messages can be edited");
|
|
48
|
+
if (!text.trim()) throw new Error("an edited message needs some text");
|
|
49
|
+
session.messages[messageIndex] = { ...message, text: text.trim(), editedAt: Date.now() };
|
|
50
|
+
},
|
|
43
51
|
async retry(): Promise<void> {},
|
|
44
52
|
commentOnLine(line: string, text: string): boolean {
|
|
45
53
|
session.lineComments.push({ line, text });
|
|
@@ -464,10 +472,20 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
|
|
|
464
472
|
checkExcalidraw: async () => ({ ok: true, text: "reached the server; nobody has this session open yet — open the link in Excalidraw, and agents can read and draw" }),
|
|
465
473
|
comments: (taskId: string) => [...(comments.get(taskId) ?? [])],
|
|
466
474
|
comment: (taskId: string, text: string) => {
|
|
467
|
-
const entry = { id: `C-mock-${(commentSeq += 1)}`, taskId, text, createdAt: new Date().toISOString(), status: "open" };
|
|
475
|
+
const entry = { id: `C-mock-${(commentSeq += 1)}`, taskId, text, by: fixture.status.sessionId, createdAt: new Date().toISOString(), status: "open" };
|
|
468
476
|
comments.set(taskId, [...(comments.get(taskId) ?? []), entry]);
|
|
469
477
|
return "comment sent to the oracle — it will amend the plan";
|
|
470
478
|
},
|
|
479
|
+
editComment: (taskId: string, commentId: string, text: string) => {
|
|
480
|
+
const existing = (comments.get(taskId) ?? []).find((entry) => entry.id === commentId);
|
|
481
|
+
if (!existing) throw new Error(`no comment ${commentId}`);
|
|
482
|
+
if (!existing.by || existing.by !== fixture.status.sessionId) throw new Error("only your own comments can be edited");
|
|
483
|
+
const entry: Record<string, unknown> = { ...existing, text: text.trim(), editedAt: new Date().toISOString(), status: "open" };
|
|
484
|
+
delete entry.deliveredAt;
|
|
485
|
+
delete entry.addressedAt;
|
|
486
|
+
comments.set(taskId, [...(comments.get(taskId) ?? []).map((other) => (other.id === commentId ? entry : other))]);
|
|
487
|
+
return entry as unknown as PlanComment;
|
|
488
|
+
},
|
|
471
489
|
startPlanned: (plan: { id: string }) => `starting ${plan.id} here — its agreed plan needs no approval…`,
|
|
472
490
|
discardPlan: (id: string) => {
|
|
473
491
|
const index = plans.findIndex((plan) => plan.id === id);
|
package/src/webui/files.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { ServerResponse } from "node:http";
|
|
8
8
|
import { readFile, realpath } from "node:fs/promises";
|
|
9
9
|
import { extname, join, relative, resolve, sep } from "node:path";
|
|
10
|
-
import {
|
|
10
|
+
import { previewRoot } from "../state/previews.ts";
|
|
11
11
|
import { previewDir } from "../ask/relay.ts";
|
|
12
12
|
import { applyBaseHeaders, HttpError, sendError } from "./api/index.ts";
|
|
13
13
|
|
|
@@ -19,20 +19,16 @@ const PREVIEW_TYPES: Record<string, string> = {
|
|
|
19
19
|
".webp": "image/webp",
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
function previewsRoot(): string {
|
|
23
|
-
return join(tmpdir(), "bot-lobby-previews");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
22
|
/** Whether `task`/`name` name a file without reaching outside their folder. */
|
|
27
23
|
export function isPreviewName(task: string, name: string): boolean {
|
|
28
|
-
if (!/^[\w.-]+$/.test(task) || !/^[\w.-]+$/.test(name)) return false;
|
|
24
|
+
if (!/^[\w.-]+$/.test(task) || task === "." || task === ".." || !/^[\w.-]+$/.test(name)) return false;
|
|
29
25
|
const ext = extname(name).toLowerCase();
|
|
30
26
|
return PREVIEW_TYPES[ext] !== undefined;
|
|
31
27
|
}
|
|
32
28
|
|
|
33
29
|
/** An absolute image path under the previews root as its preview URL, if it is one. */
|
|
34
|
-
export function previewUrlFor(absPath: string): string | undefined {
|
|
35
|
-
const root =
|
|
30
|
+
export function previewUrlFor(absPath: string, projectRoot?: string): string | undefined {
|
|
31
|
+
const root = previewRoot(projectRoot);
|
|
36
32
|
const resolved = resolve(absPath);
|
|
37
33
|
if (resolved !== root && !resolved.startsWith(root + sep)) return undefined;
|
|
38
34
|
const rel = relative(root, resolved);
|
|
@@ -42,27 +38,29 @@ export function previewUrlFor(absPath: string): string | undefined {
|
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
/** Every absolute preview path in a prompt payload as its preview URL. */
|
|
45
|
-
export function rewritePreviewImages(payload: unknown): unknown {
|
|
46
|
-
if (typeof payload === "string") return previewUrlFor(payload) ?? payload;
|
|
47
|
-
if (Array.isArray(payload)) return payload.map(rewritePreviewImages);
|
|
41
|
+
export function rewritePreviewImages(payload: unknown, root?: string): unknown {
|
|
42
|
+
if (typeof payload === "string") return previewUrlFor(payload, root) ?? payload;
|
|
43
|
+
if (Array.isArray(payload)) return payload.map((value) => rewritePreviewImages(value, root));
|
|
48
44
|
if (payload && typeof payload === "object") {
|
|
49
|
-
return Object.fromEntries(Object.entries(payload as Record<string, unknown>).map(([key, value]) => [key, rewritePreviewImages(value)]));
|
|
45
|
+
return Object.fromEntries(Object.entries(payload as Record<string, unknown>).map(([key, value]) => [key, rewritePreviewImages(value, root)]));
|
|
50
46
|
}
|
|
51
47
|
return payload;
|
|
52
48
|
}
|
|
53
49
|
|
|
54
50
|
/** The preview file's bytes, jailed to its task's preview dir, or undefined when refused. */
|
|
55
|
-
export async function readPreview(task: string, name: string): Promise<{ body: Buffer; type: string } | undefined> {
|
|
51
|
+
export async function readPreview(task: string, name: string, root?: string): Promise<{ body: Buffer; type: string } | undefined> {
|
|
56
52
|
if (!isPreviewName(task, name)) return undefined;
|
|
57
53
|
let real: string;
|
|
58
54
|
try {
|
|
59
|
-
real = await realpath(join(previewDir(task), name));
|
|
55
|
+
real = await realpath(join(previewDir(task, root), name));
|
|
60
56
|
} catch {
|
|
61
57
|
return undefined;
|
|
62
58
|
}
|
|
63
59
|
let base: string;
|
|
64
60
|
try {
|
|
65
|
-
base = await realpath(previewDir(task));
|
|
61
|
+
base = await realpath(previewDir(task, root));
|
|
62
|
+
const jail = await realpath(previewRoot(root));
|
|
63
|
+
if (!base.startsWith(jail + sep)) return undefined;
|
|
66
64
|
} catch {
|
|
67
65
|
return undefined;
|
|
68
66
|
}
|
|
@@ -75,12 +73,12 @@ export async function readPreview(task: string, name: string): Promise<{ body: B
|
|
|
75
73
|
}
|
|
76
74
|
|
|
77
75
|
/** Serve one preview; 401 without the cookie, 404 for anything jailed away. */
|
|
78
|
-
export async function servePreview(res: ServerResponse, task: string, name: string, signedIn: boolean): Promise<void> {
|
|
76
|
+
export async function servePreview(res: ServerResponse, task: string, name: string, signedIn: boolean, root?: string): Promise<void> {
|
|
79
77
|
if (!signedIn) {
|
|
80
78
|
sendError(res, "unauthorized", "open the link Pi printed", 401);
|
|
81
79
|
return;
|
|
82
80
|
}
|
|
83
|
-
const found = await readPreview(task, name);
|
|
81
|
+
const found = await readPreview(task, name, root);
|
|
84
82
|
if (!found) throw new HttpError(404, "not_found", "no such preview");
|
|
85
83
|
applyBaseHeaders(res);
|
|
86
84
|
res.writeHead(200, { "Content-Type": found.type, "Cache-Control": "no-store" });
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { request, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { applyBaseHeaders, HttpError, sendError } from "./api/index.ts";
|
|
3
|
+
import { browserSession, probeProject, scanProjects, type ProjectRegistry, type ProjectRecord } from "./projects.ts";
|
|
4
|
+
|
|
5
|
+
/** Only API and preview paths are project-owned; static files stay at entry. */
|
|
6
|
+
export function projectRoute(path: string): { id: string; path: string } | undefined {
|
|
7
|
+
const match = /^\/projects\/([a-f0-9]{32})(\/(?:api\/[^/]+|files\/preview\/[^/]+\/[^/]+))$/.exec(path);
|
|
8
|
+
if (!match) return undefined;
|
|
9
|
+
return { id: match[1]!, path: match[2]! };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function gateway(req: IncomingMessage, res: ServerResponse, url: URL, registry: ProjectRegistry): Promise<URL | undefined> {
|
|
13
|
+
const target = projectRoute(url.pathname);
|
|
14
|
+
if (!target) throw new HttpError(404, "not_found", "no such project route");
|
|
15
|
+
if (/^\/api\/(auth\.login|projects\.)/.test(target.path)) throw new HttpError(403, "forbidden", "entry-only call");
|
|
16
|
+
if (target.id === registry.record.id) { url.pathname = target.path; return url; }
|
|
17
|
+
const record = scanProjects(registry.dir).find((r) => r.id === target.id);
|
|
18
|
+
if (!record) throw new HttpError(404, "not_found", "no such running project");
|
|
19
|
+
const cookie = browserSession(req.headers.cookie);
|
|
20
|
+
const health = await probeProject(record, cookie);
|
|
21
|
+
if (health === "unauthorized") { sendError(res, "unauthorized", "open the link Pi printed", 401); return; }
|
|
22
|
+
if (health !== "healthy") throw new HttpError(503, "failed", "the project is offline or changed");
|
|
23
|
+
await proxy(req, res, record, target.path + url.search, cookie);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function requestHeaders(req: IncomingMessage, port: number, cookie?: string): Record<string, string> {
|
|
27
|
+
const headers: Record<string, string> = { Host: `127.0.0.1:${port}`, Origin: `http://127.0.0.1:${port}` };
|
|
28
|
+
for (const name of ["content-type", "content-length", "accept", "last-event-id"]) {
|
|
29
|
+
const value = req.headers[name];
|
|
30
|
+
if (typeof value === "string") headers[name] = value;
|
|
31
|
+
}
|
|
32
|
+
if (cookie) headers.Cookie = cookie;
|
|
33
|
+
return headers;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function responseHeaders(res: IncomingMessage): Record<string, string> {
|
|
37
|
+
const headers: Record<string, string> = {};
|
|
38
|
+
const hop = String(res.headers.connection ?? "").toLowerCase().split(",").map((name) => name.trim());
|
|
39
|
+
for (const name of ["content-type", "content-length", "cache-control", "x-content-type-options"]) {
|
|
40
|
+
const value = res.headers[name];
|
|
41
|
+
if (typeof value === "string" && !hop.includes(name)) headers[name] = value;
|
|
42
|
+
}
|
|
43
|
+
return headers;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function proxy(req: IncomingMessage, res: ServerResponse, record: ProjectRecord, path: string, cookie?: string): Promise<void> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const upstream = request({ hostname: "127.0.0.1", port: record.port, path, method: req.method,
|
|
49
|
+
headers: requestHeaders(req, record.port, cookie) }, (response) => {
|
|
50
|
+
if (response.headers["content-type"]?.startsWith("text/event-stream")) clearTimeout(timer);
|
|
51
|
+
applyBaseHeaders(res);
|
|
52
|
+
res.writeHead(response.statusCode ?? 503, responseHeaders(response));
|
|
53
|
+
response.pipe(res);
|
|
54
|
+
response.on("end", () => { clearTimeout(timer); resolve(); });
|
|
55
|
+
response.on("error", fail);
|
|
56
|
+
});
|
|
57
|
+
const fail = () => { clearTimeout(timer); upstream.destroy(); reject(new HttpError(503, "failed", "the project is offline")); };
|
|
58
|
+
const timer = setTimeout(fail, 5000);
|
|
59
|
+
upstream.on("error", fail);
|
|
60
|
+
res.on("close", () => { clearTimeout(timer); upstream.destroy(); resolve(); });
|
|
61
|
+
req.on("aborted", () => upstream.destroy());
|
|
62
|
+
req.pipe(upstream);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { request } from "node:http";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { globalConfigDir } from "../state/project.ts";
|
|
6
|
+
import { canonicalRoot } from "../state/previews.ts";
|
|
7
|
+
import type { ProjectInfo } from "./protocol.ts";
|
|
8
|
+
|
|
9
|
+
export interface ProjectRecord extends ProjectInfo { pid: number; startedAt: number }
|
|
10
|
+
export const PROJECT_ID = /^[a-f0-9]{32}$/;
|
|
11
|
+
export const registryDirectory = () => join(globalConfigDir(), "web-projects");
|
|
12
|
+
export const projectInfo = ({ id, name, cwd, port }: ProjectInfo): ProjectInfo => ({ id, name, cwd, port });
|
|
13
|
+
|
|
14
|
+
export function livePid(pid: number): boolean {
|
|
15
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validRecord(r: ProjectRecord): boolean {
|
|
19
|
+
return !!r && PROJECT_ID.test(r.id) && typeof r.name === "string" && typeof r.cwd === "string"
|
|
20
|
+
&& r.cwd === canonicalRoot(r.cwd) && Number.isInteger(r.port) && r.port > 0 && r.port <= 65535
|
|
21
|
+
&& Number.isInteger(r.pid) && r.pid > 0 && Number.isFinite(r.startedAt) && livePid(r.pid);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function scanProjects(dir: string): ProjectRecord[] {
|
|
25
|
+
let names: string[];
|
|
26
|
+
try { names = readdirSync(dir).sort().slice(0, 128); } catch { return []; }
|
|
27
|
+
return names.flatMap((name) => readRecord(dir, name) ?? []);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readRecord(dir: string, name: string): ProjectRecord | undefined {
|
|
31
|
+
if (!/^[a-f0-9]{32}\.json$/.test(name)) return undefined;
|
|
32
|
+
try {
|
|
33
|
+
const path = join(dir, name);
|
|
34
|
+
const stat = lstatSync(path);
|
|
35
|
+
if (!stat.isFile() || stat.size > 16384) return undefined;
|
|
36
|
+
const record = JSON.parse(readFileSync(path, "utf8")) as ProjectRecord;
|
|
37
|
+
return validRecord(record) && name === `${record.id}.json` ? record : undefined;
|
|
38
|
+
} catch { return undefined; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ProjectRegistry {
|
|
42
|
+
record: ProjectRecord;
|
|
43
|
+
readonly dir: string;
|
|
44
|
+
constructor(dir: string, root: string, port: number) {
|
|
45
|
+
this.dir = dir;
|
|
46
|
+
this.record = this.make(root, port);
|
|
47
|
+
this.save();
|
|
48
|
+
}
|
|
49
|
+
private make(root: string, port: number): ProjectRecord {
|
|
50
|
+
const cwd = canonicalRoot(root);
|
|
51
|
+
return { id: randomBytes(16).toString("hex"), name: basename(cwd) || cwd, cwd, port, pid: process.pid, startedAt: Date.now() };
|
|
52
|
+
}
|
|
53
|
+
private save(): void {
|
|
54
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
55
|
+
chmodSync(this.dir, 0o700);
|
|
56
|
+
writeFileSync(join(this.dir, `${this.record.id}.json`), JSON.stringify(this.record), { mode: 0o600, flag: "wx" });
|
|
57
|
+
}
|
|
58
|
+
rebind(root: string): void {
|
|
59
|
+
const cwd = canonicalRoot(root);
|
|
60
|
+
const previous = this.record;
|
|
61
|
+
this.close();
|
|
62
|
+
this.record = cwd === previous.cwd ? { ...previous, name: basename(cwd) || cwd } : this.make(cwd, previous.port);
|
|
63
|
+
this.save();
|
|
64
|
+
}
|
|
65
|
+
close(): void { rmSync(join(this.dir, `${this.record.id}.json`), { force: true }); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The only browser credential we relay; never forward arbitrary cookies. */
|
|
69
|
+
export function browserSession(cookie?: string): string | undefined {
|
|
70
|
+
return cookie?.split(";").map((s) => s.trim()).find((s) => /^bl_session=[^;\s]+$/.test(s));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function probeProject(record: ProjectRecord, cookie?: string): Promise<"healthy" | "unauthorized" | "offline"> {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const req = request({ hostname: "127.0.0.1", port: record.port, path: "/api/projects.self", method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) } }, (res) => {
|
|
77
|
+
const chunks: Buffer[] = []; let size = 0;
|
|
78
|
+
res.on("data", (chunk: Buffer) => { size += chunk.length; if (size > 16384) { resolve("offline"); req.destroy(); } else chunks.push(chunk); });
|
|
79
|
+
res.on("end", () => resolve(probeResult(res.statusCode, Buffer.concat(chunks), record)));
|
|
80
|
+
res.on("error", () => resolve("offline"));
|
|
81
|
+
});
|
|
82
|
+
const timer = setTimeout(() => { resolve("offline"); req.destroy(); }, 1000);
|
|
83
|
+
req.on("close", () => clearTimeout(timer));
|
|
84
|
+
req.on("error", () => resolve("offline"));
|
|
85
|
+
req.end("{}");
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function probeResult(status: number | undefined, bytes: Buffer, record: ProjectRecord): "healthy" | "unauthorized" | "offline" {
|
|
90
|
+
if (status === 401) return "unauthorized";
|
|
91
|
+
try {
|
|
92
|
+
const reply = JSON.parse(bytes.toString());
|
|
93
|
+
const p = reply.result?.project;
|
|
94
|
+
return status === 200 && reply.ok === true && p?.id === record.id && p.cwd === record.cwd && p.port === record.port ? "healthy" : "offline";
|
|
95
|
+
} catch { return "offline"; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function selectProjects(records: ProjectRecord[], local: ProjectRecord): ProjectInfo[] {
|
|
99
|
+
const grouped = new Map<string, ProjectRecord>();
|
|
100
|
+
for (const r of records.sort((a, b) => b.startedAt - a.startedAt || b.id.localeCompare(a.id))) {
|
|
101
|
+
if (!grouped.has(r.cwd)) grouped.set(r.cwd, r);
|
|
102
|
+
}
|
|
103
|
+
grouped.set(local.cwd, local);
|
|
104
|
+
return [...grouped.values()].map(projectInfo).sort((a, b) => a.name.localeCompare(b.name) || a.cwd.localeCompare(b.cwd));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function listProjects(registry: ProjectRegistry, cookie?: string): Promise<ProjectInfo[]> {
|
|
108
|
+
const records = scanProjects(registry.dir);
|
|
109
|
+
const healthy = await Promise.all(records.map(async (r) => await probeProject(r, cookie) === "healthy" ? r : undefined));
|
|
110
|
+
return selectProjects(healthy.filter((r): r is ProjectRecord => !!r), registry.record);
|
|
111
|
+
}
|
package/src/webui/protocol.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type { BotLobbyConfig, PanelMember } from "../schemas/configuration.ts";
|
|
|
13
13
|
import type { MemberState, PanelNote, PanelQuestion, PlannerMessage } from "../lobby/planner.ts";
|
|
14
14
|
import type { QuickFixJob } from "../lobby/quickfix.ts";
|
|
15
15
|
|
|
16
|
+
export interface ProjectInfo { id: string; name: string; cwd: string; port: number }
|
|
17
|
+
|
|
16
18
|
export type { WebPrompt };
|
|
17
19
|
export type { LobbyTopic };
|
|
18
20
|
export type { DialogAnswer, SessionDialog, TaskRow, PlanComment };
|
|
@@ -510,6 +512,7 @@ export interface Api {
|
|
|
510
512
|
"tasks.archived": { request: Record<string, never>; result: { rows: TaskRow[] } };
|
|
511
513
|
"tasks.comments": { request: { taskId: string }; result: { comments: PlanComment[] } };
|
|
512
514
|
"tasks.comment": { request: { taskId: string; text: string; attachments?: string[] }; result: { notice?: string } };
|
|
515
|
+
"tasks.editComment": { request: { taskId: string; commentId: string; text: string }; result: { comment: PlanComment } };
|
|
513
516
|
"tasks.archive": { request: { taskId: string }; result: { notice: string } };
|
|
514
517
|
"tasks.restore": { request: { taskId: string }; result: { notice: string } };
|
|
515
518
|
"tasks.delete": { request: { taskId: string; where: "list" | "archive" }; result: { notice: string } };
|
|
@@ -532,6 +535,7 @@ export interface Api {
|
|
|
532
535
|
"planner.get": { request: Record<string, never>; result: PlannerSnapshot };
|
|
533
536
|
"planner.new": { request: { seed?: PlannerSeedInfo; seats?: PanelMember[] }; result: { notice: string } };
|
|
534
537
|
"planner.send": { request: { text: string; attachments?: string[] }; result: { notice: string } };
|
|
538
|
+
"planner.editMessage": { request: { messageIndex: number; at: number; text: string }; result: { message: PlannerMessage; notice: string } };
|
|
535
539
|
"planner.toggleSeat": { request: { member: PanelMember }; result: { notice: string; seated: boolean } };
|
|
536
540
|
"planner.retry": { request: Record<string, never>; result: { notice: string } };
|
|
537
541
|
"planner.commentLine": { request: { line: string; text: string }; result: { notice: string } };
|
package/src/webui/server.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
8
8
|
import type { AddressInfo } from "node:net";
|
|
9
|
-
import { loadConfig } from "../state/project.ts";
|
|
9
|
+
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
10
|
+
import { canonicalRoot } from "../state/previews.ts";
|
|
11
|
+
import { ProjectRegistry, registryDirectory, projectInfo, listProjects, browserSession } from "./projects.ts";
|
|
12
|
+
import { gateway } from "./gateway.ts";
|
|
10
13
|
import { lobbyFeed } from "../lobby/feed.ts";
|
|
11
14
|
import type { LobbyService } from "../lobby/host.ts";
|
|
12
15
|
import {
|
|
@@ -35,6 +38,8 @@ export const PORT_RANGE = 20;
|
|
|
35
38
|
|
|
36
39
|
export interface WebServerOptions {
|
|
37
40
|
service: LobbyService;
|
|
41
|
+
projectRoot?: string;
|
|
42
|
+
registryDir?: string;
|
|
38
43
|
/** Base port (config `lobby.web.port` once a later step adds it). */
|
|
39
44
|
port?: number;
|
|
40
45
|
/** The built page; defaults to `webui/dist`. */
|
|
@@ -82,7 +87,7 @@ function hookExit(): void {
|
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
/** Read a JSON body, capped at a megabyte (413 past it, 400 when it is no JSON). */
|
|
85
|
-
async function readJson(req: IncomingMessage): Promise<unknown> {
|
|
90
|
+
async function readJson(req: IncomingMessage, allowEmpty = true): Promise<unknown> {
|
|
86
91
|
const chunks: Buffer[] = [];
|
|
87
92
|
let size = 0;
|
|
88
93
|
for await (const chunk of req) {
|
|
@@ -92,7 +97,7 @@ async function readJson(req: IncomingMessage): Promise<unknown> {
|
|
|
92
97
|
chunks.push(buffer);
|
|
93
98
|
}
|
|
94
99
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
95
|
-
if (!text.trim()) return {};
|
|
100
|
+
if (!text.trim() && allowEmpty) return {};
|
|
96
101
|
try {
|
|
97
102
|
return JSON.parse(text);
|
|
98
103
|
} catch {
|
|
@@ -133,7 +138,7 @@ async function upload(req: IncomingMessage, res: ServerResponse, url: URL, state
|
|
|
133
138
|
sendError(res, "bad_request", "the file is empty");
|
|
134
139
|
return;
|
|
135
140
|
}
|
|
136
|
-
sendJson(res, 200, { ok: true, result: saveUpload(name, url.searchParams.get("type") ?? "", bytes) });
|
|
141
|
+
sendJson(res, 200, { ok: true, result: saveUpload(name, url.searchParams.get("type") ?? "", bytes, state.projectRoot) });
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
function numParam(url: URL, name: string): number | undefined {
|
|
@@ -151,6 +156,8 @@ interface RouteState {
|
|
|
151
156
|
guard: LoginGuard;
|
|
152
157
|
hub: EventHub;
|
|
153
158
|
root: string;
|
|
159
|
+
projectRoot: string;
|
|
160
|
+
registry?: ProjectRegistry;
|
|
154
161
|
}
|
|
155
162
|
|
|
156
163
|
async function login(req: IncomingMessage, res: ServerResponse, state: RouteState): Promise<void> {
|
|
@@ -190,6 +197,15 @@ async function routeApi(req: IncomingMessage, res: ServerResponse, url: URL, sta
|
|
|
190
197
|
sendError(res, "unauthorized", "open the link Pi printed", 401);
|
|
191
198
|
return;
|
|
192
199
|
}
|
|
200
|
+
if (url.pathname === "/api/projects.self" || url.pathname === "/api/projects.list") {
|
|
201
|
+
const body = await readJson(req, false);
|
|
202
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length) throw new HttpError(400, "bad_request", "send exactly {}");
|
|
203
|
+
const registry = state.registry!;
|
|
204
|
+
const result = url.pathname.endsWith(".self") ? { project: projectInfo(registry.record) }
|
|
205
|
+
: { projects: await listProjects(registry, browserSession(req.headers.cookie)), currentId: registry.record.id };
|
|
206
|
+
sendJson(res, 200, { ok: true, result });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
193
209
|
const name = callName(url);
|
|
194
210
|
if (name === undefined) {
|
|
195
211
|
sendError(res, "not_found", `no call ${url.pathname.slice("/api/".length)}`, 404);
|
|
@@ -210,7 +226,14 @@ async function route(req: IncomingMessage, res: ServerResponse, state: RouteStat
|
|
|
210
226
|
sendError(res, "forbidden", "unknown host", 403);
|
|
211
227
|
return;
|
|
212
228
|
}
|
|
213
|
-
|
|
229
|
+
let url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
230
|
+
if (url.pathname.startsWith("/projects/")) {
|
|
231
|
+
if (isCrossSite(req.headers.origin, req.headers["sec-fetch-site"] as string | undefined, state.port)) throw new HttpError(403, "forbidden", "cross-site request");
|
|
232
|
+
if (!isSignedIn(req.headers.cookie, state.session)) throw new HttpError(401, "unauthorized", "open the link Pi printed");
|
|
233
|
+
const local = await gateway(req, res, url, state.registry!);
|
|
234
|
+
if (!local) return;
|
|
235
|
+
url = local;
|
|
236
|
+
}
|
|
214
237
|
if (url.pathname.startsWith("/files/preview/")) {
|
|
215
238
|
if (req.method !== "GET") {
|
|
216
239
|
sendError(res, "unsupported", "only GET", 405);
|
|
@@ -221,7 +244,7 @@ async function route(req: IncomingMessage, res: ServerResponse, state: RouteStat
|
|
|
221
244
|
sendError(res, "not_found", "no such preview", 404);
|
|
222
245
|
return;
|
|
223
246
|
}
|
|
224
|
-
await servePreview(res, names.task, names.name, isSignedIn(req.headers.cookie, state.session));
|
|
247
|
+
await servePreview(res, names.task, names.name, isSignedIn(req.headers.cookie, state.session), state.projectRoot);
|
|
225
248
|
return;
|
|
226
249
|
}
|
|
227
250
|
if (!url.pathname.startsWith("/api/")) {
|
|
@@ -288,6 +311,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
|
|
|
288
311
|
guard: new LoginGuard(),
|
|
289
312
|
hub,
|
|
290
313
|
root: distDir(options.dist),
|
|
314
|
+
projectRoot: canonicalRoot(options.service.projectRoot?.() ?? options.projectRoot ?? detectProjectRoot(process.cwd())),
|
|
291
315
|
};
|
|
292
316
|
const server: Server = createServer((req, res) => {
|
|
293
317
|
route(req, res, state).catch((error: Error) => {
|
|
@@ -318,6 +342,8 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
|
|
|
318
342
|
throw new Error(`no free port from ${base} to ${base + PORT_RANGE}`);
|
|
319
343
|
}
|
|
320
344
|
state.port = bound;
|
|
345
|
+
try { state.registry = new ProjectRegistry(options.registryDir ?? registryDirectory(), state.projectRoot, bound); }
|
|
346
|
+
catch (error) { hub.close(); server.close(); throw error; }
|
|
321
347
|
hookExit();
|
|
322
348
|
const api: WebServer = {
|
|
323
349
|
link: `http://127.0.0.1:${bound}/#token=${link}`,
|
|
@@ -325,6 +351,8 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
|
|
|
325
351
|
clients: () => hub.clients(),
|
|
326
352
|
rebind: (service) => {
|
|
327
353
|
state.service = service;
|
|
354
|
+
state.projectRoot = canonicalRoot(service.projectRoot?.() ?? options.projectRoot ?? detectProjectRoot(process.cwd()));
|
|
355
|
+
state.registry!.rebind(state.projectRoot);
|
|
328
356
|
hub.attach(service);
|
|
329
357
|
hub.hello();
|
|
330
358
|
},
|
|
@@ -336,6 +364,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
|
|
|
336
364
|
return `http://127.0.0.1:${state.port}/#token=${state.link}`;
|
|
337
365
|
},
|
|
338
366
|
close: async () => {
|
|
367
|
+
state.registry!.close();
|
|
339
368
|
hub.close();
|
|
340
369
|
await new Promise<void>((resolve) => {
|
|
341
370
|
server.close(() => resolve());
|
package/src/webui/uploads.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* them. Images are also served back to the page through the preview route.
|
|
7
7
|
*/
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
|
-
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { extname, join } from "node:path";
|
|
9
|
+
import { existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { extname, join, sep } from "node:path";
|
|
11
|
+
import { previewRoot } from "../state/previews.ts";
|
|
11
12
|
import { previewDir } from "../ask/relay.ts";
|
|
12
13
|
import { claimAttachments, sweepAttachments } from "../state/attachments.ts";
|
|
13
14
|
import { ATTACHMENTS_MARK } from "../lobby/prompts.ts";
|
|
@@ -44,9 +45,9 @@ function kindOf(mime: string): UploadKind {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/** Save one file; the id is its name in the attachments folder. */
|
|
47
|
-
export function saveUpload(name: string, claimedType: string, bytes: Buffer): Upload {
|
|
48
|
-
sweepAttachments(STALE_MS);
|
|
49
|
-
const dir = previewDir(ATTACHMENT_BUCKET);
|
|
48
|
+
export function saveUpload(name: string, claimedType: string, bytes: Buffer, root?: string): Upload {
|
|
49
|
+
sweepAttachments(STALE_MS, Date.now(), root);
|
|
50
|
+
const dir = previewDir(ATTACHMENT_BUCKET, root);
|
|
50
51
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
51
52
|
const id = `${Date.now().toString(36)}${randomBytes(3).toString("hex")}-${safeName(name)}`;
|
|
52
53
|
writeFileSync(join(dir, id), bytes, { mode: 0o600 });
|
|
@@ -55,14 +56,23 @@ export function saveUpload(name: string, claimedType: string, bytes: Buffer): Up
|
|
|
55
56
|
return { id, name: safeName(name), mime, size: bytes.length, kind, ...(kind === "image" ? { url: `/files/preview/${ATTACHMENT_BUCKET}/${id}` } : {}) };
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function attachmentExists(dir: string, id: string, root?: string): boolean {
|
|
60
|
+
if (!/^[\w.-]+$/.test(id) || !existsSync(join(dir, id))) return false;
|
|
61
|
+
try {
|
|
62
|
+
const base = realpathSync(dir);
|
|
63
|
+
return base.startsWith(realpathSync(previewRoot(root)) + sep)
|
|
64
|
+
&& realpathSync(join(dir, id)).startsWith(base + sep) && statSync(join(dir, id)).isFile();
|
|
65
|
+
} catch { return false; }
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
/** `text`, then the files it carries by path, the way the agents read them; they belong to `taskId` and go with it. */
|
|
59
|
-
export function withAttachments(text: string, ids: readonly string[] | undefined, taskId?: string): string {
|
|
69
|
+
export function withAttachments(text: string, ids: readonly string[] | undefined, taskId?: string, root?: string): string {
|
|
60
70
|
if (!ids || ids.length === 0) return text;
|
|
61
|
-
const dir = previewDir(ATTACHMENT_BUCKET);
|
|
71
|
+
const dir = previewDir(ATTACHMENT_BUCKET, root);
|
|
62
72
|
const lines = ids.slice(0, MAX_ATTACHMENTS).map((id) => {
|
|
63
|
-
if (
|
|
73
|
+
if (!attachmentExists(dir, id, root)) fail(400, "bad_request", `the attachment ${id} is not there; attach it again`);
|
|
64
74
|
return `- ${join(dir, id)} (${mimeOf(id, "")})`;
|
|
65
75
|
});
|
|
66
|
-
if (taskId) claimAttachments(ids.slice(0, MAX_ATTACHMENTS), taskId);
|
|
76
|
+
if (taskId) claimAttachments(ids.slice(0, MAX_ATTACHMENTS), taskId, root);
|
|
67
77
|
return `${text.trim()}${text.trim() ? "\n\n" : ""}${ATTACHMENTS_MARK}\n${lines.join("\n")}`;
|
|
68
78
|
}
|
package/src/workflow/workflow.ts
CHANGED
|
@@ -64,6 +64,8 @@ import { appendMetrics, metricFromRun } from "../state/metrics.ts";
|
|
|
64
64
|
import { markCommentsAddressed, pendingComments, readPlanComments } from "../state/comments.ts";
|
|
65
65
|
import { fastNext, onFastTrack, parseRoster, qaRequired, qaStillDue, qaTookPart, rosterWords, trackSummary } from "./track.ts";
|
|
66
66
|
import { missingWorktree, taskCwd } from "../execution/workspace.ts";
|
|
67
|
+
import { createPullRequest } from "../lobby/pulls.ts";
|
|
68
|
+
import type { Exec } from "../lobby/issues.ts";
|
|
67
69
|
|
|
68
70
|
export const ORCHESTRATE_ACTIONS = [
|
|
69
71
|
"clarify",
|
|
@@ -144,6 +146,8 @@ export interface WorkflowDeps {
|
|
|
144
146
|
askQuestions?: (questions: AskQuestion[], from: string, signal?: AbortSignal) => Promise<AskResult>;
|
|
145
147
|
notify: (message: string, level?: "info" | "warning" | "error") => void;
|
|
146
148
|
runProcess?: ProcessRunner;
|
|
149
|
+
/** Command seam for completion's bounded git/gh calls. */
|
|
150
|
+
exec?: Exec;
|
|
147
151
|
/** Likely files for scouts and workers, while the classifier's file hints are on. */
|
|
148
152
|
hints?: FileHinter;
|
|
149
153
|
/** Picks the relevant sections of long knowledge files for scouts, workers and the QA gate, while the classifier's knowledge picks are on. */
|
|
@@ -708,7 +712,7 @@ function askRelay(task: Task, deps: WorkflowDeps, domain: Domain): { onAsk: Rela
|
|
|
708
712
|
if (!ASKING_DOMAINS.has(domain) || !deps.askQuestions || isAutoMode(deps.root, deps.configDir, task.id)) return undefined;
|
|
709
713
|
const askQuestions = deps.askQuestions;
|
|
710
714
|
const label = AGENT_LABELS[domain];
|
|
711
|
-
const previews = previewDir(task.id);
|
|
715
|
+
const previews = previewDir(task.id, deps.root);
|
|
712
716
|
try {
|
|
713
717
|
mkdirSync(previews, { recursive: true });
|
|
714
718
|
} catch {
|
|
@@ -1300,13 +1304,43 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
1300
1304
|
const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
|
|
1301
1305
|
if (oversized.length > 0) throw new Error(`cannot complete: knowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. First record domain-relevant facts where they belong with action=knowledge (domain=designer|backend|qa), then rewrite each oversized file with action=compact, then call complete again.`);
|
|
1302
1306
|
const summary = params.text?.trim() || task.proposal || task.title;
|
|
1303
|
-
flushDecisions(deps, task);
|
|
1304
1307
|
recordCompletion(deps, task, summary);
|
|
1305
1308
|
removeTaskScratchpads(deps.root, deps.configDir, task.id);
|
|
1306
1309
|
task.blockers = [];
|
|
1307
1310
|
if (task.state === "implementing") transition(task, "reviewing");
|
|
1308
1311
|
transition(task, "completed");
|
|
1309
|
-
|
|
1312
|
+
const opened = await openPullRequest(task, deps);
|
|
1313
|
+
flushDecisions(deps, task);
|
|
1314
|
+
return `Task ${task.id} completed. History recorded and temporary scratchpads removed.${opened}`;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** Longest pull request body `gh` takes without complaint. */
|
|
1318
|
+
const PR_BODY_CHARS = 60_000;
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Open the task's pull request once it completes, so its branch lands in
|
|
1322
|
+
* review by itself. Every reason it cannot (no isolation, no remote, `gh` not
|
|
1323
|
+
* signed in) is recorded as a note and the completion stands either way.
|
|
1324
|
+
*/
|
|
1325
|
+
async function openPullRequest(task: Task, deps: WorkflowDeps): Promise<string> {
|
|
1326
|
+
const git = task.git;
|
|
1327
|
+
if (!git || (git.mode !== "branch" && git.mode !== "worktree")) return pullNote(task, "No pull request opened — task has no git isolation");
|
|
1328
|
+
const base = git.from?.trim();
|
|
1329
|
+
if (!base || /^detached at /i.test(base) || /^[a-f0-9]{40,64}$/i.test(base)) return pullNote(task, "No pull request opened — task has no valid base branch");
|
|
1330
|
+
const body = [task.proposal?.trim() || task.plan?.trim() || task.title, task.qaVerdict ? `QA verdict: ${task.qaVerdict}` : ""].filter(Boolean).join("\n\n").slice(0, PR_BODY_CHARS);
|
|
1331
|
+
try {
|
|
1332
|
+
const pull = await createPullRequest({ exec: deps.exec, cwd: taskCwd(task, deps.cwd), branch: git.branch, base, title: task.title, body });
|
|
1333
|
+
recordDecision(task, `Opened pull request #${pull.number} for ${git.branch}: ${pull.url}`);
|
|
1334
|
+
deps.notify(`Pull request #${pull.number} is open: ${pull.url}`, "info");
|
|
1335
|
+
return ` Pull request #${pull.number} is open: ${pull.url}.`;
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
return pullNote(task, `No pull request opened for ${git.branch} — ${(error as Error).message}`);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function pullNote(task: Task, note: string): string {
|
|
1342
|
+
recordDecision(task, note);
|
|
1343
|
+
return ` ${note}.`;
|
|
1310
1344
|
}
|
|
1311
1345
|
|
|
1312
1346
|
/** Asked when the oracle completes a task QA has not passed (the user told it to finish, say). */
|