@repo-prism/dispatch-hub 1.1.16 → 1.8.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 +2 -1
- package/dist/api-errors.d.ts +26 -0
- package/dist/api-errors.d.ts.map +1 -0
- package/dist/api-errors.js +61 -0
- package/dist/api-errors.js.map +1 -0
- package/dist/auth.d.ts +8 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +22 -1
- package/dist/auth.js.map +1 -1
- package/dist/bin.js +0 -0
- package/dist/dashboard/app.css +1 -1
- package/dist/dashboard/app.js +199 -111
- package/dist/dashboard/app.js.map +636 -6
- package/dist/dashboard/hub.css +17449 -275
- package/dist/dashboard/index.html +4 -3
- package/dist/dashboard/prism-mark.png +0 -0
- package/dist/host-telemetry.d.ts +9 -0
- package/dist/host-telemetry.d.ts.map +1 -0
- package/dist/host-telemetry.js +30 -0
- package/dist/host-telemetry.js.map +1 -0
- package/dist/hub-record.d.ts.map +1 -1
- package/dist/hub-record.js +36 -1
- package/dist/hub-record.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/intelligence.d.ts +37 -0
- package/dist/intelligence.d.ts.map +1 -0
- package/dist/intelligence.js +102 -0
- package/dist/intelligence.js.map +1 -0
- package/dist/notes.d.ts +32 -0
- package/dist/notes.d.ts.map +1 -0
- package/dist/notes.js +117 -0
- package/dist/notes.js.map +1 -0
- package/dist/notify.d.ts +6 -0
- package/dist/notify.d.ts.map +1 -1
- package/dist/notify.js +66 -12
- package/dist/notify.js.map +1 -1
- package/dist/paths.d.ts +25 -1
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +30 -2
- package/dist/paths.js.map +1 -1
- package/dist/pick-folder.d.ts +9 -0
- package/dist/pick-folder.d.ts.map +1 -0
- package/dist/pick-folder.js +49 -0
- package/dist/pick-folder.js.map +1 -0
- package/dist/registry.d.ts +7 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +45 -6
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +18 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +421 -29
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +14 -2
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +44 -9
- package/dist/snapshot.js.map +1 -1
- package/dist/statusline.d.ts +2 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +23 -5
- package/dist/statusline.js.map +1 -1
- package/dist/types.d.ts +66 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -1
- package/dist/watch.d.ts +22 -5
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +59 -13
- package/dist/watch.js.map +1 -1
- package/package.json +7 -3
package/dist/server.js
CHANGED
|
@@ -3,15 +3,22 @@ import { access } from "node:fs/promises";
|
|
|
3
3
|
import { createReadStream } from "node:fs";
|
|
4
4
|
import { dirname, extname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { createClaudeWorkerPort, createCursorWorkerPort, createDispatchRuntime, readRunLog, } from "@repo-prism/dispatch";
|
|
7
|
-
import {
|
|
6
|
+
import { createClaudeWorkerPort, createCursorWorkerPort, createDispatchRuntime, DispatchConfigSchema, discoverHostConnectors, vendorCoverage, drainWorkspace, loadConfig, listWorkerModels, requestedWorkerModel, resolveWorkerBackend, getJob, upsertJob, readRunLog, saveConfig, } from "@repo-prism/dispatch";
|
|
7
|
+
import { parseHostRequest } from "@repo-prism/host-session/protocol";
|
|
8
|
+
import { originAllowed, tokenFromRequest, tokensMatch, hubCookieHeader, } from "./auth.js";
|
|
8
9
|
import { createIdleTimer, IDLE_MS } from "./idle.js";
|
|
9
|
-
import {
|
|
10
|
+
import { createIntelligencePlane, } from "./intelligence.js";
|
|
11
|
+
import { newHubToken, packageVersion, readHubRecord, writeHubRecord, } from "./hub-record.js";
|
|
10
12
|
import { formatJobFinishedNotice } from "./notice.js";
|
|
13
|
+
import { listJobNotes, readJobNote } from "./notes.js";
|
|
11
14
|
import { createOsNotifier } from "./notify.js";
|
|
15
|
+
import { readHostTelemetry } from "./host-telemetry.js";
|
|
12
16
|
import { dashboardUrl, hubPort } from "./paths.js";
|
|
13
|
-
import {
|
|
17
|
+
import { HUB_ERROR, publicCaughtError } from "./api-errors.js";
|
|
18
|
+
import { pickLocalFolder } from "./pick-folder.js";
|
|
19
|
+
import { dropMissingWorkspaces, registerWorkspace, unregisterWorkspace, workspaceLabel, } from "./registry.js";
|
|
14
20
|
import { collectJobs, isInFlight, pathExists, watchWorkspaces, } from "./watch.js";
|
|
21
|
+
import { toSnapshot } from "./snapshot.js";
|
|
15
22
|
const MIME = {
|
|
16
23
|
".html": "text/html; charset=utf-8",
|
|
17
24
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -22,25 +29,74 @@ const MIME = {
|
|
|
22
29
|
function defaultAssetsDir() {
|
|
23
30
|
return join(dirname(fileURLToPath(import.meta.url)), "dashboard");
|
|
24
31
|
}
|
|
25
|
-
async function
|
|
32
|
+
async function defaultStartJob(workspacePath, args) {
|
|
26
33
|
const runtime = createDispatchRuntime({
|
|
27
34
|
workspaceRoot: workspacePath,
|
|
28
35
|
worker: createCursorWorkerPort(),
|
|
29
36
|
claudeWorker: createClaudeWorkerPort(),
|
|
37
|
+
getClientName: () => String(args.hostClient ?? "console"),
|
|
38
|
+
});
|
|
39
|
+
return runtime.handle("start_job", {
|
|
40
|
+
...args,
|
|
41
|
+
hostClient: args.hostClient ?? "console",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function defaultControl(workspacePath, jobId, action, extra) {
|
|
45
|
+
const runtime = createDispatchRuntime({
|
|
46
|
+
workspaceRoot: workspacePath,
|
|
47
|
+
worker: createCursorWorkerPort(),
|
|
48
|
+
claudeWorker: createClaudeWorkerPort(),
|
|
49
|
+
getClientName: () => "console",
|
|
50
|
+
...(action === "reverify" ? { deferVerify: true } : {}),
|
|
51
|
+
});
|
|
52
|
+
return runtime.handle("job_control", {
|
|
53
|
+
jobId,
|
|
54
|
+
action,
|
|
55
|
+
...(extra?.path ? { path: extra.path } : {}),
|
|
56
|
+
...(extra?.context ? { context: extra.context } : {}),
|
|
30
57
|
});
|
|
31
|
-
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Advance one workspace's job queue (ADR-0047).
|
|
61
|
+
*
|
|
62
|
+
* The hub is the only always-on process on the machine, which makes it the
|
|
63
|
+
* right owner of the drain: a job queued by an MCP server that has since
|
|
64
|
+
* exited still starts, and a job parked behind the cap starts as soon as a
|
|
65
|
+
* slot frees.
|
|
66
|
+
*/
|
|
67
|
+
async function defaultDrain(workspacePath) {
|
|
68
|
+
const cursor = createCursorWorkerPort();
|
|
69
|
+
const claude = createClaudeWorkerPort();
|
|
70
|
+
const runtime = createDispatchRuntime({
|
|
71
|
+
workspaceRoot: workspacePath,
|
|
72
|
+
worker: cursor,
|
|
73
|
+
claudeWorker: claude,
|
|
74
|
+
});
|
|
75
|
+
await drainWorkspace(runtime.drainDeps());
|
|
32
76
|
}
|
|
33
77
|
export async function startHub(options = {}) {
|
|
34
78
|
const env = options.env ?? process.env;
|
|
35
79
|
const notify = options.notify ?? createOsNotifier();
|
|
36
80
|
const assetsDir = options.assetsDir ?? defaultAssetsDir();
|
|
37
81
|
const control = options.control ?? defaultControl;
|
|
82
|
+
const startJob = options.startJob ?? defaultStartJob;
|
|
38
83
|
const version = options.version ?? packageVersion();
|
|
39
|
-
const
|
|
84
|
+
const intelligence = options.intelligence ?? createIntelligencePlane();
|
|
85
|
+
const pickFolder = options.pickFolder ?? pickLocalFolder;
|
|
86
|
+
const listModels = options.listWorkerModels ??
|
|
87
|
+
((input) => listWorkerModels({ backend: input.backend }));
|
|
88
|
+
const previous = await readHubRecord(env);
|
|
89
|
+
const token = typeof previous?.token === "string" && previous.token.length >= 16
|
|
90
|
+
? previous.token
|
|
91
|
+
: newHubToken();
|
|
40
92
|
const wantedPort = hubPort(env);
|
|
41
93
|
let workspaces = await dropMissingWorkspaces(pathExists, env);
|
|
42
94
|
const sse = new Set();
|
|
43
95
|
let jobs = [];
|
|
96
|
+
// Mirrored from the watcher so every payload can state when it was read and
|
|
97
|
+
// which workspaces failed, rather than presenting a partial list as whole.
|
|
98
|
+
let asOf = new Date().toISOString();
|
|
99
|
+
let workspaceErrors = [];
|
|
44
100
|
let server;
|
|
45
101
|
let closed = false;
|
|
46
102
|
let liveRecord = {
|
|
@@ -51,8 +107,11 @@ export async function startHub(options = {}) {
|
|
|
51
107
|
startedAt: new Date().toISOString(),
|
|
52
108
|
};
|
|
53
109
|
const broadcast = (event) => {
|
|
54
|
-
if (event.type === "snapshot")
|
|
110
|
+
if (event.type === "snapshot") {
|
|
55
111
|
jobs = [...event.jobs];
|
|
112
|
+
asOf = event.asOf;
|
|
113
|
+
workspaceErrors = [...event.errors];
|
|
114
|
+
}
|
|
56
115
|
if (event.type === "job.updated") {
|
|
57
116
|
jobs = [
|
|
58
117
|
...jobs.filter((row) => !(row.id === event.job.id &&
|
|
@@ -60,6 +119,10 @@ export async function startHub(options = {}) {
|
|
|
60
119
|
event.job,
|
|
61
120
|
];
|
|
62
121
|
}
|
|
122
|
+
if (event.type === "job.removed") {
|
|
123
|
+
jobs = jobs.filter((row) => !(row.id === event.job.id &&
|
|
124
|
+
row.workspacePath === event.job.workspacePath));
|
|
125
|
+
}
|
|
63
126
|
const payload = `data: ${JSON.stringify(event)}\n\n`;
|
|
64
127
|
for (const client of sse) {
|
|
65
128
|
try {
|
|
@@ -77,8 +140,12 @@ export async function startHub(options = {}) {
|
|
|
77
140
|
}
|
|
78
141
|
idle.touch();
|
|
79
142
|
};
|
|
143
|
+
// The hub tick is the queue's safety net (ADR-0047). `start_job` kicks its
|
|
144
|
+
// own drain, but that kick dies with the MCP process; this catches anything
|
|
145
|
+
// left `queued`, and re-checks jobs parked behind the concurrency cap.
|
|
80
146
|
const watcher = watchWorkspaces(() => workspaces, onEvent, {
|
|
81
147
|
pollMs: options.pollMs,
|
|
148
|
+
drain: options.drain ?? defaultDrain,
|
|
82
149
|
});
|
|
83
150
|
const idle = createIdleTimer({
|
|
84
151
|
idleMs: options.idleMs ?? IDLE_MS,
|
|
@@ -93,6 +160,7 @@ export async function startHub(options = {}) {
|
|
|
93
160
|
closed = true;
|
|
94
161
|
idle.stop();
|
|
95
162
|
watcher.close();
|
|
163
|
+
intelligence.close();
|
|
96
164
|
for (const client of sse) {
|
|
97
165
|
try {
|
|
98
166
|
client.res.end();
|
|
@@ -107,7 +175,18 @@ export async function startHub(options = {}) {
|
|
|
107
175
|
resolve();
|
|
108
176
|
return;
|
|
109
177
|
}
|
|
110
|
-
|
|
178
|
+
const timer = setTimeout(resolve, 1_000);
|
|
179
|
+
timer.unref();
|
|
180
|
+
server.close(() => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
resolve();
|
|
183
|
+
});
|
|
184
|
+
try {
|
|
185
|
+
server.closeAllConnections();
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* bun / older node */
|
|
189
|
+
}
|
|
111
190
|
});
|
|
112
191
|
};
|
|
113
192
|
server = createServer((req, res) => {
|
|
@@ -133,13 +212,21 @@ export async function startHub(options = {}) {
|
|
|
133
212
|
}
|
|
134
213
|
liveRecord = { ...liveRecord, port: listen.port };
|
|
135
214
|
await writeHubRecord(liveRecord, env);
|
|
136
|
-
|
|
137
|
-
|
|
215
|
+
void listModels({ backend: "cursor" }).catch(() => {
|
|
216
|
+
/* spawn still lists if this misses */
|
|
217
|
+
});
|
|
218
|
+
const initial = await collectJobs(workspaces);
|
|
219
|
+
broadcast({
|
|
220
|
+
type: "snapshot",
|
|
221
|
+
jobs: initial.jobs,
|
|
222
|
+
asOf: new Date().toISOString(),
|
|
223
|
+
errors: initial.errors,
|
|
224
|
+
});
|
|
138
225
|
async function handleRequest(req, res) {
|
|
139
226
|
const host = req.headers.host ?? `127.0.0.1:${liveRecord.port}`;
|
|
140
227
|
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
141
228
|
if (!originAllowed(req.headers.origin)) {
|
|
142
|
-
json(res, 403, { error:
|
|
229
|
+
json(res, 403, { error: HUB_ERROR.origin });
|
|
143
230
|
return;
|
|
144
231
|
}
|
|
145
232
|
if (url.pathname === "/api/healthz") {
|
|
@@ -149,16 +236,157 @@ export async function startHub(options = {}) {
|
|
|
149
236
|
pid: process.pid,
|
|
150
237
|
version,
|
|
151
238
|
workspaces: workspaces.length,
|
|
239
|
+
// Whether the Intelligence plane has actually loaded Core, and on
|
|
240
|
+
// what. A reader can tell an idle Console from a busy one.
|
|
241
|
+
intelligence: {
|
|
242
|
+
loaded: intelligence.loaded(),
|
|
243
|
+
workspace: intelligence.openWorkspace() ?? null,
|
|
244
|
+
},
|
|
152
245
|
});
|
|
153
246
|
return;
|
|
154
247
|
}
|
|
155
248
|
const isApi = url.pathname.startsWith("/api/");
|
|
156
249
|
if (isApi && !tokensMatch(liveRecord.token, tokenFromRequest(req, url))) {
|
|
157
|
-
json(res, 401, { error:
|
|
250
|
+
json(res, 401, { error: HUB_ERROR.unauthorized });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (req.method === "GET" && url.pathname === "/api/telemetry/host") {
|
|
254
|
+
json(res, 200, await readHostTelemetry(workspaces[0]?.path ?? process.cwd()));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (req.method === "GET" && url.pathname === "/api/worker-models") {
|
|
258
|
+
const requested = url.searchParams.get("backend")?.trim();
|
|
259
|
+
const workspace = url.searchParams.get("workspace")?.trim() || workspaces[0]?.path;
|
|
260
|
+
let backend;
|
|
261
|
+
if (requested === "cursor" || requested === "claude") {
|
|
262
|
+
backend = requested;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const config = workspace
|
|
266
|
+
? await loadConfig(workspace).catch(() => undefined)
|
|
267
|
+
: undefined;
|
|
268
|
+
backend = resolveWorkerBackend({ config, env });
|
|
269
|
+
}
|
|
270
|
+
json(res, 200, {
|
|
271
|
+
backend,
|
|
272
|
+
models: await listModels({ backend }),
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (req.method === "POST" && url.pathname === "/api/jobs") {
|
|
277
|
+
const body = await readBody(req);
|
|
278
|
+
const workspace = String(body.workspace ?? "").trim() || workspaces[0]?.path || "";
|
|
279
|
+
const title = String(body.title ?? "").trim();
|
|
280
|
+
if (!workspace || !title) {
|
|
281
|
+
json(res, 400, { error: HUB_ERROR.titleRequired });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const workerModel = requestedWorkerModel(body.workerModel ?? body.model);
|
|
286
|
+
const origin = body.origin === "retry" ||
|
|
287
|
+
body.origin === "reverify" ||
|
|
288
|
+
body.origin === "finding" ||
|
|
289
|
+
body.origin === "instruct"
|
|
290
|
+
? body.origin
|
|
291
|
+
: undefined;
|
|
292
|
+
const parentJobId = String(body.parentJobId ?? "").trim();
|
|
293
|
+
const result = await startJob(workspace, {
|
|
294
|
+
title,
|
|
295
|
+
prd: String(body.prd ?? ""),
|
|
296
|
+
playbook: String(body.playbook ?? "console"),
|
|
297
|
+
...(body.placement === "worktree" || body.placement === "checkout"
|
|
298
|
+
? { placement: body.placement }
|
|
299
|
+
: {}),
|
|
300
|
+
...(body.workerBackend === "cursor" || body.workerBackend === "claude"
|
|
301
|
+
? { workerBackend: body.workerBackend }
|
|
302
|
+
: {}),
|
|
303
|
+
...(workerModel ? { workerModel } : {}),
|
|
304
|
+
...(parentJobId ? { parentJobId } : {}),
|
|
305
|
+
...(origin ? { origin } : {}),
|
|
306
|
+
hostClient: "console",
|
|
307
|
+
});
|
|
308
|
+
await watcher.refresh({ drain: true });
|
|
309
|
+
json(res, 200, result ?? { ok: true });
|
|
310
|
+
}
|
|
311
|
+
catch (cause) {
|
|
312
|
+
json(res, 500, { error: publicCaughtError(cause) });
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const jobPatch = /^\/api\/jobs\/([^/]+)$/.exec(url.pathname);
|
|
317
|
+
if (req.method === "PATCH" && jobPatch) {
|
|
318
|
+
const jobId = decodeURIComponent(jobPatch[1] ?? "");
|
|
319
|
+
const body = await readBody(req);
|
|
320
|
+
const workspace = String(body.workspace ?? "").trim() ||
|
|
321
|
+
jobs.find((job) => job.id === jobId)?.workspacePath ||
|
|
322
|
+
"";
|
|
323
|
+
if (!workspace || !jobId) {
|
|
324
|
+
json(res, 400, { error: HUB_ERROR.workspaceJob });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!("prd" in body)) {
|
|
328
|
+
json(res, 400, { error: HUB_ERROR.prdRequired });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const current = await getJob(workspace, jobId);
|
|
333
|
+
if (!current) {
|
|
334
|
+
json(res, 404, { error: HUB_ERROR.jobMissing });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const next = await upsertJob(workspace, {
|
|
338
|
+
...current,
|
|
339
|
+
prd: String(body.prd ?? ""),
|
|
340
|
+
});
|
|
341
|
+
await watcher.refresh({ drain: false });
|
|
342
|
+
json(res, 200, {
|
|
343
|
+
job: toSnapshot(next, workspace),
|
|
344
|
+
message: "Saved the brief.",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
catch (cause) {
|
|
348
|
+
json(res, 500, { error: publicCaughtError(cause) });
|
|
349
|
+
}
|
|
158
350
|
return;
|
|
159
351
|
}
|
|
160
352
|
if (req.method === "GET" && url.pathname === "/api/jobs") {
|
|
161
|
-
json(res, 200, {
|
|
353
|
+
json(res, 200, {
|
|
354
|
+
jobs: [...watcher.jobs()],
|
|
355
|
+
asOf: watcher.asOf(),
|
|
356
|
+
errors: [...watcher.errors()],
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// The Repos plane needs to tell "no repositories registered" apart from
|
|
361
|
+
// "registered, but no jobs" — two very different things the old board
|
|
362
|
+
// rendered as one empty sentence (ADR-0048).
|
|
363
|
+
if (req.method === "GET" && url.pathname === "/api/repos") {
|
|
364
|
+
json(res, 200, {
|
|
365
|
+
repos: workspaces.map((entry) => ({
|
|
366
|
+
path: entry.path,
|
|
367
|
+
label: entry.label,
|
|
368
|
+
lastSeenAt: entry.lastSeenAt,
|
|
369
|
+
jobCount: jobs.filter((job) => job.workspacePath === entry.path)
|
|
370
|
+
.length,
|
|
371
|
+
error: workspaceErrors.find((row) => row.workspacePath === entry.path)
|
|
372
|
+
?.detail,
|
|
373
|
+
})),
|
|
374
|
+
asOf,
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// What the user's agent window already has connected (ADR-0049). Served
|
|
379
|
+
// from here rather than read directly by the IDE, so the extension keeps
|
|
380
|
+
// its one dependency on Dispatch — HTTP — instead of importing it.
|
|
381
|
+
if (req.method === "GET" && url.pathname === "/api/connectors") {
|
|
382
|
+
const workspace = url.searchParams.get("workspace")?.trim() || workspaces[0]?.path;
|
|
383
|
+
const discovery = await discoverHostConnectors(workspace ? { workspaceRoot: workspace } : {});
|
|
384
|
+
json(res, 200, {
|
|
385
|
+
connectors: discovery.connectors,
|
|
386
|
+
unreadable: discovery.unreadable,
|
|
387
|
+
vendors: vendorCoverage(discovery.connectors),
|
|
388
|
+
asOf: new Date().toISOString(),
|
|
389
|
+
});
|
|
162
390
|
return;
|
|
163
391
|
}
|
|
164
392
|
// Per-job console for the board's expander (M-066 P-P6).
|
|
@@ -169,15 +397,55 @@ export async function startHub(options = {}) {
|
|
|
169
397
|
jobs.find((job) => job.id === jobId)?.workspacePath ??
|
|
170
398
|
"";
|
|
171
399
|
if (!workspace) {
|
|
172
|
-
json(res, 400, { error:
|
|
400
|
+
json(res, 400, { error: HUB_ERROR.workspaceRequired });
|
|
173
401
|
return;
|
|
174
402
|
}
|
|
403
|
+
const since = url.searchParams.get("since")?.trim();
|
|
175
404
|
const page = await readRunLog(workspace, jobId, {
|
|
176
405
|
limit: Number(url.searchParams.get("limit") ?? "200") || 200,
|
|
406
|
+
...(since ? { since } : {}),
|
|
177
407
|
});
|
|
178
408
|
json(res, 200, page);
|
|
179
409
|
return;
|
|
180
410
|
}
|
|
411
|
+
const notesMatch = /^\/api\/jobs\/([^/]+)\/notes$/.exec(url.pathname);
|
|
412
|
+
if (req.method === "GET" && notesMatch) {
|
|
413
|
+
const jobId = decodeURIComponent(notesMatch[1] ?? "");
|
|
414
|
+
const job = jobs.find((row) => row.id === jobId);
|
|
415
|
+
const workspace = url.searchParams.get("workspace") ?? job?.workspacePath ?? "";
|
|
416
|
+
if (!workspace) {
|
|
417
|
+
json(res, 400, { error: HUB_ERROR.workspaceRequired });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const rel = url.searchParams.get("path")?.trim();
|
|
421
|
+
try {
|
|
422
|
+
if (rel) {
|
|
423
|
+
const file = await readJobNote({
|
|
424
|
+
workspace,
|
|
425
|
+
rel,
|
|
426
|
+
...(job?.worktreePath ? { worktreePath: job.worktreePath } : {}),
|
|
427
|
+
});
|
|
428
|
+
if (!file) {
|
|
429
|
+
json(res, 404, { error: HUB_ERROR.noteMissing });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
json(res, 200, file);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const listed = await listJobNotes({
|
|
436
|
+
workspace,
|
|
437
|
+
jobId,
|
|
438
|
+
...(job?.worktreePath ? { worktreePath: job.worktreePath } : {}),
|
|
439
|
+
...(job?.resultSummary ? { summary: job.resultSummary } : {}),
|
|
440
|
+
...(job?.notes ? { stored: job.notes } : {}),
|
|
441
|
+
});
|
|
442
|
+
json(res, 200, { notes: listed });
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
json(res, 500, { error: HUB_ERROR.noteRead });
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
181
449
|
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
182
450
|
res.writeHead(200, {
|
|
183
451
|
"Content-Type": "text/event-stream",
|
|
@@ -187,7 +455,12 @@ export async function startHub(options = {}) {
|
|
|
187
455
|
const client = { res };
|
|
188
456
|
sse.add(client);
|
|
189
457
|
idle.touch();
|
|
190
|
-
res.write(`data: ${JSON.stringify({
|
|
458
|
+
res.write(`data: ${JSON.stringify({
|
|
459
|
+
type: "snapshot",
|
|
460
|
+
jobs: [...watcher.jobs()],
|
|
461
|
+
asOf: watcher.asOf(),
|
|
462
|
+
errors: [...watcher.errors()],
|
|
463
|
+
})}\n\n`);
|
|
191
464
|
req.on("close", () => {
|
|
192
465
|
sse.delete(client);
|
|
193
466
|
});
|
|
@@ -197,7 +470,7 @@ export async function startHub(options = {}) {
|
|
|
197
470
|
const body = await readBody(req);
|
|
198
471
|
const path = String(body.path ?? "").trim();
|
|
199
472
|
if (!path) {
|
|
200
|
-
json(res, 400, { error:
|
|
473
|
+
json(res, 400, { error: HUB_ERROR.pathRequired });
|
|
201
474
|
return;
|
|
202
475
|
}
|
|
203
476
|
workspaces = await registerWorkspace(path, env);
|
|
@@ -205,6 +478,74 @@ export async function startHub(options = {}) {
|
|
|
205
478
|
json(res, 200, { workspaces });
|
|
206
479
|
return;
|
|
207
480
|
}
|
|
481
|
+
if (req.method === "POST" && url.pathname === "/api/workspaces/remove") {
|
|
482
|
+
const body = await readBody(req);
|
|
483
|
+
const path = String(body.path ?? "").trim();
|
|
484
|
+
if (!path) {
|
|
485
|
+
json(res, 400, { error: HUB_ERROR.pathRequired });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const before = workspaces.length;
|
|
489
|
+
workspaces = await unregisterWorkspace(path, env);
|
|
490
|
+
if (workspaces.length === before) {
|
|
491
|
+
json(res, 404, { error: "That repository is not registered." });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
void watcher.refresh();
|
|
495
|
+
json(res, 200, { workspaces });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (req.method === "POST" && url.pathname === "/api/workspaces/pick") {
|
|
499
|
+
const picked = await pickFolder();
|
|
500
|
+
if (!picked) {
|
|
501
|
+
json(res, 200, { cancelled: true });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
workspaces = await registerWorkspace(picked, env);
|
|
505
|
+
void watcher.refresh();
|
|
506
|
+
json(res, 200, {
|
|
507
|
+
path: picked,
|
|
508
|
+
label: workspaceLabel(picked),
|
|
509
|
+
workspaces,
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// The Intelligence plane (ADR-0048). Same `HostRequest`/`HostResponse`
|
|
514
|
+
// contract the IDE webview already speaks, so `@repo-prism/app-shell`
|
|
515
|
+
// mounts against it unchanged — but behind this Console's token and origin
|
|
516
|
+
// allowlist, rather than the retired bridge's `Access-Control-Allow-Origin: *`.
|
|
517
|
+
if (req.method === "POST" && url.pathname === "/api/host") {
|
|
518
|
+
const body = await readBody(req);
|
|
519
|
+
const id = typeof body.id === "string" ? body.id : "?";
|
|
520
|
+
// Validated with the same guard the webview host uses, so an unknown
|
|
521
|
+
// method is a 400 here rather than an unhandled cast three layers down.
|
|
522
|
+
const parsed = parseHostRequest(body);
|
|
523
|
+
if (!parsed.ok) {
|
|
524
|
+
json(res, 400, { id, ok: false, error: parsed.reason });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const workspace = (typeof body.workspace === "string" ? body.workspace.trim() : "") ||
|
|
528
|
+
url.searchParams.get("workspace") ||
|
|
529
|
+
workspaces[0]?.path ||
|
|
530
|
+
"";
|
|
531
|
+
if (!workspace) {
|
|
532
|
+
json(res, 200, {
|
|
533
|
+
id,
|
|
534
|
+
ok: false,
|
|
535
|
+
error: "No repository registered with Prism yet. Open a repo in your editor, or run a Prism command in it.",
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
idle.touch();
|
|
540
|
+
try {
|
|
541
|
+
const answer = await intelligence.handle(workspace, parsed.value);
|
|
542
|
+
json(res, 200, answer);
|
|
543
|
+
}
|
|
544
|
+
catch (cause) {
|
|
545
|
+
json(res, 200, { id, ok: false, error: publicCaughtError(cause) });
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
208
549
|
const controlMatch = /^\/api\/jobs\/([^/]+)\/control$/.exec(url.pathname);
|
|
209
550
|
if (req.method === "POST" && controlMatch) {
|
|
210
551
|
const jobId = decodeURIComponent(controlMatch[1] ?? "");
|
|
@@ -213,36 +554,87 @@ export async function startHub(options = {}) {
|
|
|
213
554
|
const workspace = String(body.workspace ?? "").trim() ||
|
|
214
555
|
jobs.find((job) => job.id === jobId)?.workspacePath;
|
|
215
556
|
if (!workspace || !action) {
|
|
216
|
-
json(res, 400, { error:
|
|
557
|
+
json(res, 400, { error: HUB_ERROR.actionRequired });
|
|
217
558
|
return;
|
|
218
559
|
}
|
|
219
560
|
try {
|
|
220
|
-
const
|
|
221
|
-
|
|
561
|
+
const extra = {
|
|
562
|
+
...(typeof body.path === "string" ? { path: body.path } : {}),
|
|
563
|
+
...(typeof body.context === "string"
|
|
564
|
+
? { context: body.context }
|
|
565
|
+
: {}),
|
|
566
|
+
};
|
|
567
|
+
const result = await control(workspace, jobId, action, extra);
|
|
568
|
+
await watcher.refresh({ drain: false });
|
|
222
569
|
json(res, 200, result ?? { ok: true });
|
|
223
570
|
}
|
|
224
571
|
catch (cause) {
|
|
225
|
-
|
|
226
|
-
|
|
572
|
+
json(res, 500, { error: publicCaughtError(cause) });
|
|
573
|
+
}
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (req.method === "GET" && url.pathname === "/api/settings") {
|
|
577
|
+
const workspace = url.searchParams.get("workspace")?.trim() || workspaces[0]?.path;
|
|
578
|
+
if (!workspace) {
|
|
579
|
+
json(res, 400, { error: HUB_ERROR.workspaceRequired });
|
|
580
|
+
return;
|
|
227
581
|
}
|
|
582
|
+
const config = await loadConfig(workspace);
|
|
583
|
+
const entry = workspaces.find((row) => row.path === workspace);
|
|
584
|
+
json(res, 200, {
|
|
585
|
+
workspace,
|
|
586
|
+
label: entry?.label ?? workspace,
|
|
587
|
+
config,
|
|
588
|
+
});
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (req.method === "POST" && url.pathname === "/api/settings") {
|
|
592
|
+
const body = await readBody(req);
|
|
593
|
+
const workspace = String(body.workspace ?? "").trim() || workspaces[0]?.path;
|
|
594
|
+
if (!workspace) {
|
|
595
|
+
json(res, 400, { error: HUB_ERROR.workspaceRequired });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const parsed = DispatchConfigSchema.partial().safeParse(body);
|
|
599
|
+
if (!parsed.success) {
|
|
600
|
+
json(res, 400, { error: publicCaughtError(parsed.error) });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const patch = {};
|
|
604
|
+
for (const [key, value] of Object.entries(parsed.data)) {
|
|
605
|
+
if (value !== undefined)
|
|
606
|
+
patch[key] = value;
|
|
607
|
+
}
|
|
608
|
+
const config = await saveConfig(workspace, patch);
|
|
609
|
+
json(res, 200, { workspace, config });
|
|
228
610
|
return;
|
|
229
611
|
}
|
|
230
612
|
if (req.method === "GET" &&
|
|
231
613
|
(url.pathname === "/" || url.pathname === "/index.html")) {
|
|
232
|
-
|
|
614
|
+
const headers = {
|
|
615
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
616
|
+
"Cache-Control": "no-store",
|
|
617
|
+
};
|
|
618
|
+
if (tokensMatch(liveRecord.token, tokenFromRequest(req, url))) {
|
|
619
|
+
headers["Set-Cookie"] = hubCookieHeader(liveRecord.token);
|
|
620
|
+
}
|
|
621
|
+
await serveFile(res, join(assetsDir, "index.html"), headers);
|
|
233
622
|
return;
|
|
234
623
|
}
|
|
235
624
|
if (req.method === "GET" && url.pathname.startsWith("/assets/")) {
|
|
236
625
|
const relative = url.pathname.slice("/assets/".length);
|
|
237
626
|
if (relative.includes("..")) {
|
|
238
|
-
json(res, 400, { error:
|
|
627
|
+
json(res, 400, { error: HUB_ERROR.badPath });
|
|
239
628
|
return;
|
|
240
629
|
}
|
|
241
630
|
const file = join(assetsDir, relative);
|
|
242
|
-
await serveFile(res, file,
|
|
631
|
+
await serveFile(res, file, {
|
|
632
|
+
"Content-Type": MIME[extname(file)] ?? "application/octet-stream",
|
|
633
|
+
"Cache-Control": "no-store",
|
|
634
|
+
});
|
|
243
635
|
return;
|
|
244
636
|
}
|
|
245
|
-
json(res, 404, { error:
|
|
637
|
+
json(res, 404, { error: HUB_ERROR.notFound });
|
|
246
638
|
}
|
|
247
639
|
return {
|
|
248
640
|
record: liveRecord,
|
|
@@ -268,15 +660,15 @@ async function readBody(req) {
|
|
|
268
660
|
return {};
|
|
269
661
|
}
|
|
270
662
|
}
|
|
271
|
-
async function serveFile(res, path,
|
|
663
|
+
async function serveFile(res, path, headers) {
|
|
272
664
|
try {
|
|
273
665
|
await access(path);
|
|
274
666
|
}
|
|
275
667
|
catch {
|
|
276
|
-
json(res, 404, { error:
|
|
668
|
+
json(res, 404, { error: HUB_ERROR.notFound });
|
|
277
669
|
return;
|
|
278
670
|
}
|
|
279
|
-
res.writeHead(200,
|
|
671
|
+
res.writeHead(200, headers);
|
|
280
672
|
createReadStream(path).pipe(res);
|
|
281
673
|
}
|
|
282
674
|
//# sourceMappingURL=server.js.map
|