@runravel/ravel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +202 -0
  3. package/README.md +68 -0
  4. package/bin/ravel.mjs +6 -0
  5. package/examples/acme/agent.md +14 -0
  6. package/examples/acme/growth/agent.md +19 -0
  7. package/examples/acme/growth/copywriter/agent.md +13 -0
  8. package/examples/acme/growth/copywriter/tools.json +11 -0
  9. package/examples/acme/growth/researcher/agent.md +11 -0
  10. package/examples/acme/processes/prospect-outreach.process.md +24 -0
  11. package/examples/harbor/agent.md +31 -0
  12. package/examples/harbor/processes/new-client-quote.process.md +31 -0
  13. package/examples/harbor/processes/resolve-ticket-batch.process.md +33 -0
  14. package/examples/harbor/sales/agent.md +29 -0
  15. package/examples/harbor/sales/sdr/agent.md +24 -0
  16. package/examples/harbor/sales/solutions/agent.md +29 -0
  17. package/examples/harbor/sales/solutions/tools.json +16 -0
  18. package/examples/harbor/support/agent.md +31 -0
  19. package/examples/harbor/support/kb-writer/agent.md +25 -0
  20. package/examples/harbor/support/kb-writer/tools.json +10 -0
  21. package/examples/harbor/support/qa-reviewer/agent.md +23 -0
  22. package/examples/harbor/support/tools.json +16 -0
  23. package/examples/harbor/support/triage/agent.md +24 -0
  24. package/examples/harbor/support/triage/tools.json +10 -0
  25. package/examples/plugin-demo/agent.md +15 -0
  26. package/examples/plugin-demo/processes/jot.process.md +21 -0
  27. package/examples/plugin-demo/scribe/agent.md +15 -0
  28. package/examples/plugin-demo/scribe/plugin.ts +53 -0
  29. package/examples/plugin-demo/scribe/tools.json +9 -0
  30. package/package.json +65 -0
  31. package/src/cli/main.ts +428 -0
  32. package/src/control-plane/registry.ts +294 -0
  33. package/src/control-plane/watcher.ts +132 -0
  34. package/src/domain/ids.ts +17 -0
  35. package/src/domain/pricing.ts +51 -0
  36. package/src/domain/types.ts +168 -0
  37. package/src/index.ts +35 -0
  38. package/src/memory/genericTools.ts +276 -0
  39. package/src/memory/kv.ts +52 -0
  40. package/src/memory/store.ts +76 -0
  41. package/src/messaging/bus.ts +143 -0
  42. package/src/messaging/inbox.ts +119 -0
  43. package/src/orchestrator/orchestrator.ts +270 -0
  44. package/src/orchestrator/planner.ts +218 -0
  45. package/src/platform/app.ts +287 -0
  46. package/src/plugins/loader.ts +86 -0
  47. package/src/plugins/server.ts +41 -0
  48. package/src/plugins/types.ts +96 -0
  49. package/src/runtime/agent.ts +488 -0
  50. package/src/runtime/engine.ts +84 -0
  51. package/src/runtime/fakeEngine.ts +75 -0
  52. package/src/runtime/lifecycle.ts +85 -0
  53. package/src/runtime/officeActions.ts +58 -0
  54. package/src/runtime/officeTools.ts +42 -0
  55. package/src/runtime/sdkEngine.ts +213 -0
  56. package/src/schemas/agent.ts +47 -0
  57. package/src/schemas/common.ts +52 -0
  58. package/src/schemas/frontmatter.ts +36 -0
  59. package/src/schemas/process.ts +55 -0
  60. package/src/schemas/tools.ts +83 -0
  61. package/src/secrets/store.ts +131 -0
  62. package/src/service/scheduler.ts +377 -0
  63. package/src/service/server.ts +554 -0
  64. package/src/trust/approval.ts +180 -0
  65. package/src/trust/audit.ts +195 -0
  66. package/src/trust/budget.ts +64 -0
  67. package/src/trust/emittingAudit.ts +32 -0
  68. package/src/trust/executor.ts +73 -0
  69. package/src/trust/killswitch.ts +73 -0
  70. package/src/trust/observability.ts +97 -0
  71. package/src/trust/proposals.ts +114 -0
  72. package/ui/dist/assets/index-C6CxDaPS.js +44 -0
  73. package/ui/dist/assets/index-CD-lhs0Z.css +1 -0
  74. package/ui/dist/index.html +13 -0
@@ -0,0 +1,554 @@
1
+ import http from "node:http";
2
+ import path from "node:path";
3
+ import { promises as fs } from "node:fs";
4
+ import type { App } from "../platform/app.js";
5
+ import type { EmittingAudit } from "../trust/emittingAudit.js";
6
+ import { compileRegistry } from "../control-plane/registry.js";
7
+ import { newId } from "../domain/ids.js";
8
+ import { ENV_KEY_RE } from "../secrets/store.js";
9
+ import type { ProcessRunResult } from "../orchestrator/orchestrator.js";
10
+ import { Scheduler } from "./scheduler.js";
11
+
12
+ const MAX_BODY = 32 * 1024 * 1024; // 32 MB (base64 file uploads)
13
+
14
+ interface RunRecord {
15
+ runId: string;
16
+ processName: string;
17
+ status: "running" | "completed" | "failed" | "stopped";
18
+ /** Owner node id of the process — the scope we kill to stop the run. */
19
+ owner?: string;
20
+ result?: ProcessRunResult;
21
+ error?: string;
22
+ }
23
+
24
+ interface ServerDeps {
25
+ app: App;
26
+ /** The structured-event tap the App was built with (audit). */
27
+ events: EmittingAudit;
28
+ /** Built operator console (ui/dist). When set, non-/api GETs serve it. */
29
+ uiDir?: string;
30
+ /**
31
+ * Hosted workers get their config from a git checkout and their secrets from
32
+ * the platform's vault — disable the HTTP write paths for both (scheduler
33
+ * config stays writable: it's team state, not config).
34
+ */
35
+ readOnlyConfig?: boolean;
36
+ }
37
+
38
+ /** A local, single-operator HTTP + SSE service over one App. No auth by design. */
39
+ export function createServer(deps: ServerDeps): http.Server {
40
+ const { app, events, uiDir, readOnlyConfig } = deps;
41
+ const runs = new Map<string, RunRecord>();
42
+ const orgRoot = path.resolve(app.root);
43
+ const memRoot = path.resolve(app.runtimeDir, "memory");
44
+ let compileVersion = 1_000_000;
45
+
46
+ // Self-pacing auto-run scheduler. Inert unless scheduler.json enables a process.
47
+ const scheduler = new Scheduler({
48
+ launch: (name) => launchRun(name, {}, []),
49
+ isLive: (name) => isProcessRunning(name),
50
+ ownerOf: (name) => (app.currentSnapshot()?.processes ?? []).find((p) => p.spec.name === name)?.ownerNodeId ?? null,
51
+ memory: app.memory,
52
+ events,
53
+ configPath: path.join(app.runtimeDir, "scheduler.json"),
54
+ });
55
+
56
+ const server = http.createServer((req, res) => {
57
+ void handle(req, res).catch((err) => {
58
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
59
+ });
60
+ });
61
+ server.on("listening", () => void scheduler.start());
62
+ server.on("close", () => scheduler.stop());
63
+ return server;
64
+
65
+ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
66
+ cors(req, res);
67
+ if (req.method === "OPTIONS") return void res.writeHead(204).end();
68
+
69
+ const url = new URL(req.url ?? "/", "http://localhost");
70
+ const seg = url.pathname.replace(/^\/api\/?/, "").split("/").filter(Boolean);
71
+ const m = req.method ?? "GET";
72
+
73
+ // --- read-only state -----------------------------------------------------
74
+ if (m === "GET" && url.pathname === "/api/health") return sendJson(res, 200, { ok: true });
75
+ if (m === "GET" && url.pathname === "/api/org") return sendJson(res, 200, serializeOrg(app));
76
+ if (m === "GET" && url.pathname === "/api/dashboard") return sendJson(res, 200, app.dashboard());
77
+ if (m === "GET" && url.pathname === "/api/processes") {
78
+ const procs = (app.currentSnapshot()?.processes ?? []).map((p) => ({
79
+ name: p.spec.name,
80
+ owner: p.ownerNodeId,
81
+ definitionOfDone: p.spec.definitionOfDone,
82
+ }));
83
+ return sendJson(res, 200, { processes: procs });
84
+ }
85
+ if (m === "GET" && url.pathname === "/api/proposals") {
86
+ const status = url.searchParams.get("status");
87
+ const list = status === "pending" || status === null ? app.pendingProposals() : app.proposals.list(status as never);
88
+ return sendJson(res, 200, { proposals: list });
89
+ }
90
+
91
+ // --- live event stream (SSE) --------------------------------------------
92
+ if (m === "GET" && url.pathname === "/api/events") return streamEvents(res);
93
+
94
+ // --- mutations -----------------------------------------------------------
95
+ if (m === "POST" && url.pathname === "/api/chat") {
96
+ const body = await readJson(req);
97
+ const reply = await app.chat(String(body["nodeId"] ?? ""), String(body["message"] ?? ""));
98
+ return sendJson(res, 200, { reply });
99
+ }
100
+
101
+ if (m === "POST" && seg[0] === "processes" && seg[2] === "run") {
102
+ return startRun(req, res, decodeURIComponent(seg[1]!));
103
+ }
104
+
105
+ if (m === "POST" && seg[0] === "proposals" && seg[1]) {
106
+ const body = await readJson(req);
107
+ const decision = body["decision"] === "approve" ? "approve" : "reject";
108
+ const updated = await app.resolveProposal(seg[1], decision);
109
+ if (!updated) return sendJson(res, 404, { error: "no pending proposal with that id" });
110
+ return sendJson(res, 200, { proposal: updated });
111
+ }
112
+
113
+ if (m === "POST" && url.pathname === "/api/kill") {
114
+ const body = await readJson(req);
115
+ return sendJson(res, 200, { aborted: app.kill(String(body["scope"] ?? "*")) });
116
+ }
117
+
118
+ // --- runs + artifacts ----------------------------------------------------
119
+ if (m === "GET" && seg[0] === "runs" && seg[1] && seg[2] === "files") {
120
+ return seg[3] ? sendRunFile(res, seg[1], decodeURIComponent(seg[3])) : listRunFiles(res, seg[1]);
121
+ }
122
+ if (m === "GET" && seg[0] === "runs" && seg[1] && seg[2] === "events") {
123
+ const runId = seg[1];
124
+ const list = events.all().filter((e) => e.runId === runId);
125
+ return sendJson(res, 200, { events: list });
126
+ }
127
+ if (m === "GET" && url.pathname === "/api/runs") {
128
+ return sendJson(res, 200, { runs: listRuns() });
129
+ }
130
+
131
+ // --- auto-run scheduler (self-pacing) ------------------------------------
132
+ if (m === "GET" && url.pathname === "/api/scheduler") {
133
+ return sendJson(res, 200, scheduler.snapshot());
134
+ }
135
+ if (m === "PUT" && url.pathname === "/api/scheduler") {
136
+ const body = await readJson(req);
137
+ const name = String(body["name"] ?? "");
138
+ if (!name) return sendJson(res, 400, { error: "name required" });
139
+ const patch: Record<string, unknown> = {};
140
+ if (body["enabled"] !== undefined) patch["enabled"] = Boolean(body["enabled"]);
141
+ if (body["mode"] !== undefined) patch["mode"] = body["mode"] === "cron" ? "cron" : "adaptive";
142
+ if (body["cron"] !== undefined) patch["cron"] = String(body["cron"]);
143
+ if (body["minMinutes"] !== undefined) patch["minMinutes"] = Number(body["minMinutes"]);
144
+ if (body["maxMinutes"] !== undefined) patch["maxMinutes"] = Number(body["maxMinutes"]);
145
+ if (body["maxUsdPerDay"] !== undefined) patch["maxUsdPerDay"] = Number(body["maxUsdPerDay"]);
146
+ try {
147
+ await scheduler.setProcess(name, patch);
148
+ } catch (err) {
149
+ return sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
150
+ }
151
+ return sendJson(res, 200, scheduler.snapshot());
152
+ }
153
+ if (m === "DELETE" && url.pathname === "/api/scheduler") {
154
+ const name = url.searchParams.get("name") ?? "";
155
+ if (!name) return sendJson(res, 400, { error: "name required" });
156
+ await scheduler.removeProcess(name);
157
+ return sendJson(res, 200, scheduler.snapshot());
158
+ }
159
+ if (m === "POST" && seg[0] === "runs" && seg[1] && seg[2] === "dismiss") {
160
+ runs.delete(seg[1]);
161
+ await events.append("run.dismissed", { runId: seg[1], data: {} });
162
+ return sendJson(res, 200, { ok: true });
163
+ }
164
+ if (m === "POST" && seg[0] === "runs" && seg[1] && seg[2] === "stop") {
165
+ const rec = runs.get(seg[1]);
166
+ if (!rec) return sendJson(res, 404, { error: "no live run with that id" });
167
+ // Halt the run by killing its owner subtree: the in-flight agent aborts and
168
+ // the orchestrator winds down. The owner scope is cleared when the run settles
169
+ // (see startRun) so the team can run again.
170
+ const aborted = rec.owner !== undefined ? app.kill(rec.owner) : app.kill("*");
171
+ rec.status = "stopped";
172
+ await events.append("run.stopped", { runId: seg[1], nodeId: rec.owner, data: { aborted } });
173
+ return sendJson(res, 200, { stopped: true, aborted });
174
+ }
175
+ if (m === "GET" && url.pathname === "/api/chats") {
176
+ const nodeId = url.searchParams.get("nodeId") ?? "";
177
+ return sendJson(res, 200, { turns: chatHistory(nodeId) });
178
+ }
179
+ // --- memory as a file tree (read-only) -----------------------------------
180
+ if (m === "GET" && url.pathname === "/api/mem/tree") {
181
+ return sendJson(res, 200, { tree: await walkMem(memRoot, "") });
182
+ }
183
+ if (m === "GET" && url.pathname === "/api/mem/file") {
184
+ const rel = url.searchParams.get("path") ?? "";
185
+ const abs = memSafe(rel);
186
+ if (!abs) return sendJson(res, 400, { error: "path escapes memory root" });
187
+ const st = await fs.stat(abs).catch(() => null);
188
+ if (!st || !st.isFile()) return sendJson(res, 404, { error: "not found" });
189
+ const content = await fs.readFile(abs, "utf8");
190
+ return sendJson(res, 200, { path: rel, content, mtimeMs: st.mtimeMs, size: st.size });
191
+ }
192
+ if (m === "GET" && seg[0] === "runs" && seg[1]) {
193
+ const rec = runs.get(seg[1]);
194
+ return rec ? sendJson(res, 200, rec) : sendJson(res, 404, { error: "unknown run" });
195
+ }
196
+
197
+ // --- per-agent secrets (masked: names only; values never returned) -------
198
+ if (m === "GET" && url.pathname === "/api/secrets") {
199
+ const dir = nodeDir(url.searchParams.get("nodeId") ?? "");
200
+ if (!dir) return sendJson(res, 404, { error: "unknown node" });
201
+ return sendJson(res, 200, { keys: await app.secrets.listKeys(dir) });
202
+ }
203
+ if (m === "PUT" && url.pathname === "/api/secrets") {
204
+ if (readOnlyConfig) return sendJson(res, 403, { error: "config is read-only on this worker" });
205
+ const body = await readJson(req);
206
+ const dir = nodeDir(String(body["nodeId"] ?? ""));
207
+ if (!dir) return sendJson(res, 404, { error: "unknown node" });
208
+ const key = String(body["key"] ?? "");
209
+ const action = body["action"] === "delete" ? "delete" : "set";
210
+ if (!ENV_KEY_RE.test(key)) return sendJson(res, 400, { error: "invalid key (use A-Z, 0-9, _)" });
211
+ if (action === "delete") await app.secrets.deleteKey(dir, key);
212
+ else await app.secrets.setKey(dir, key, String(body["value"] ?? ""));
213
+ return sendJson(res, 200, { keys: await app.secrets.listKeys(dir) });
214
+ }
215
+
216
+ // --- config authoring ----------------------------------------------------
217
+ if (m === "GET" && url.pathname === "/api/validate") {
218
+ const result = await compileRegistry(orgRoot, ++compileVersion);
219
+ return sendJson(res, 200, { ok: result.ok, diagnostics: result.diagnostics });
220
+ }
221
+ if (m === "GET" && url.pathname === "/api/files") {
222
+ const rel = url.searchParams.get("path") ?? "";
223
+ const abs = safePath(rel);
224
+ if (!abs) return sendJson(res, 400, { error: "path escapes org root" });
225
+ const content = await fs.readFile(abs, "utf8").catch(() => null);
226
+ return content === null ? sendJson(res, 404, { error: "not found" }) : sendJson(res, 200, { path: rel, content });
227
+ }
228
+ if (m === "PUT" && url.pathname === "/api/files") {
229
+ if (readOnlyConfig) return sendJson(res, 403, { error: "config is read-only on this worker" });
230
+ const body = await readJson(req);
231
+ const rel = String(body["path"] ?? "");
232
+ const abs = safePath(rel);
233
+ if (!abs || !isAuthoringFile(rel)) {
234
+ return sendJson(res, 400, { error: "path must be agent.md, tools.json, or processes/*.process.md within the org" });
235
+ }
236
+ await fs.mkdir(path.dirname(abs), { recursive: true });
237
+ await fs.writeFile(abs, String(body["content"] ?? ""), "utf8");
238
+ // Validate synchronously; the watcher hot-reloads the live org separately.
239
+ const result = await compileRegistry(orgRoot, ++compileVersion);
240
+ return sendJson(res, 200, { ok: result.ok, diagnostics: result.diagnostics });
241
+ }
242
+
243
+ // --- operator console (static, same origin as the API) -------------------
244
+ if (m === "GET" && uiDir && !url.pathname.startsWith("/api")) {
245
+ return sendUiFile(res, uiDir, url.pathname);
246
+ }
247
+
248
+ sendJson(res, 404, { error: `no route for ${m} ${url.pathname}` });
249
+ }
250
+
251
+ // --- helpers ---------------------------------------------------------------
252
+
253
+ interface RunSummary {
254
+ runId: string;
255
+ process: string;
256
+ owner: string;
257
+ status: string;
258
+ startedAt: string;
259
+ finishedAt?: string;
260
+ turns?: number;
261
+ usd?: number;
262
+ error?: string;
263
+ inputs?: Record<string, unknown>;
264
+ }
265
+
266
+ /**
267
+ * All runs — live and past — built from the durable audit trail (so it
268
+ * survives navigation and restarts).
269
+ *
270
+ * A run that the audit shows as started-but-not-finished is only really
271
+ * "running" if THIS process is executing it (it's in the in-memory `runs`
272
+ * map). Otherwise it was interrupted by a crash/restart and is marked
273
+ * "interrupted" rather than falsely "running". Dismissed runs are hidden.
274
+ */
275
+ function listRuns(): RunSummary[] {
276
+ const byId = new Map<string, RunSummary>();
277
+ const dismissed = new Set<string>();
278
+ for (const e of events.all()) {
279
+ if (!e.runId) continue;
280
+ if (e.type === "process.started") {
281
+ byId.set(e.runId, {
282
+ runId: e.runId,
283
+ process: String(e.data["process"] ?? ""),
284
+ owner: e.nodeId ?? "",
285
+ status: "running",
286
+ startedAt: e.at,
287
+ ...(e.data["inputs"] ? { inputs: e.data["inputs"] as Record<string, unknown> } : {}),
288
+ });
289
+ } else if (e.type === "process.finished") {
290
+ const r = byId.get(e.runId);
291
+ if (r) {
292
+ r.status = String(e.data["status"] ?? r.status);
293
+ r.finishedAt = e.at;
294
+ r.turns = Number(e.data["turns"] ?? 0);
295
+ const usage = e.data["usage"] as { usd?: number } | undefined;
296
+ if (usage) r.usd = usage.usd;
297
+ }
298
+ } else if (e.type === "run.dismissed") {
299
+ dismissed.add(e.runId);
300
+ }
301
+ }
302
+ for (const id of dismissed) byId.delete(id);
303
+ // An operator stop wins over whatever the audit recorded for the wind-down.
304
+ for (const [id, r] of byId) {
305
+ if (runs.get(id)?.status === "stopped") r.status = "stopped";
306
+ }
307
+ // Reconcile "running": only genuinely live if this process is executing it.
308
+ for (const [id, r] of byId) {
309
+ if (r.status !== "running") continue;
310
+ const live = runs.get(id);
311
+ if (live) {
312
+ if (live.status === "failed") {
313
+ r.status = "failed";
314
+ if (live.error) r.error = live.error;
315
+ }
316
+ // else: truly running in this process — leave as "running".
317
+ } else {
318
+ r.status = "interrupted"; // started in a prior process; never finished.
319
+ }
320
+ }
321
+ return [...byId.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
322
+ }
323
+
324
+ /** Absolute folder for a node id, or null if unknown. (For per-node secrets.) */
325
+ function nodeDir(nodeId: string): string | null {
326
+ const node = app.currentSnapshot()?.nodes.get(nodeId);
327
+ return node ? node.dir : null;
328
+ }
329
+
330
+ /** Reconstruct an agent's chat history from the audit trail (durable). */
331
+ function chatHistory(nodeId: string): Array<{ who: "me" | "agent"; text: string; at: string }> {
332
+ const turns: Array<{ who: "me" | "agent"; text: string; at: string }> = [];
333
+ for (const e of events.all()) {
334
+ if (e.nodeId !== nodeId) continue;
335
+ if (e.type === "chat.message") turns.push({ who: "me", text: String(e.data["message"] ?? ""), at: e.at });
336
+ else if (e.type === "chat.reply") turns.push({ who: "agent", text: String(e.data["reply"] ?? ""), at: e.at });
337
+ }
338
+ return turns;
339
+ }
340
+
341
+ async function startRun(req: http.IncomingMessage, res: http.ServerResponse, name: string): Promise<void> {
342
+ const body = await readJson(req);
343
+ const inputs = (body["inputs"] as Record<string, unknown> | undefined) ?? {};
344
+ const uploads = (body["files"] as Array<{ name: string; contentBase64: string }> | undefined) ?? [];
345
+
346
+ const runId = newId("run");
347
+ const files: string[] = [];
348
+ if (uploads.length) {
349
+ const dir = path.join(app.runtimeDir, ".uploads", runId);
350
+ await fs.mkdir(dir, { recursive: true });
351
+ for (const f of uploads) {
352
+ const dest = path.join(dir, path.basename(f.name));
353
+ await fs.writeFile(dest, Buffer.from(f.contentBase64, "base64"));
354
+ files.push(dest);
355
+ }
356
+ }
357
+ launchRun(name, inputs, files, runId);
358
+ sendJson(res, 202, { runId });
359
+ }
360
+
361
+ /**
362
+ * Fire a process run in the background (used by the HTTP route AND the
363
+ * scheduler). Returns the runId immediately; progress streams over SSE.
364
+ */
365
+ function launchRun(name: string, inputs: Record<string, unknown>, files: string[], runId = newId("run")): string {
366
+ // The process owner is the scope we kill to stop the run.
367
+ const owner = (app.currentSnapshot()?.processes ?? []).find((p) => p.spec.name === name)?.ownerNodeId;
368
+ runs.set(runId, { runId, processName: name, status: "running", ...(owner !== undefined ? { owner } : {}) });
369
+ const stamp = (patch: Partial<RunRecord>) => {
370
+ const prev = runs.get(runId);
371
+ // Don't overwrite an operator "stopped" with the orchestrator's wind-down status.
372
+ if (prev?.status === "stopped" && patch.status !== "stopped") patch = { ...patch, status: "stopped" };
373
+ runs.set(runId, { runId, processName: name, ...(owner !== undefined ? { owner } : {}), status: "running", ...patch });
374
+ };
375
+ void app
376
+ .runProcess(name, { runId, inputs, ...(files.length ? { files } : {}) })
377
+ .then((result) => stamp({ status: "completed", result }))
378
+ .catch((err: unknown) => stamp({ status: "failed", error: err instanceof Error ? err.message : String(err) }))
379
+ .finally(() => {
380
+ // Unblock the team for future runs (no-op if it was never killed).
381
+ if (owner !== undefined) app.clearKill(owner);
382
+ });
383
+ return runId;
384
+ }
385
+
386
+ /** True if a run of this process is currently executing in this process (single-flight). */
387
+ function isProcessRunning(name: string): boolean {
388
+ for (const r of runs.values()) if (r.processName === name && r.status === "running") return true;
389
+ return false;
390
+ }
391
+
392
+ async function listRunFiles(res: http.ServerResponse, runId: string): Promise<void> {
393
+ const dir = path.join(app.runtimeDir, "runs", runId, "shared");
394
+ const entries = await fs.readdir(dir).catch(() => [] as string[]);
395
+ sendJson(res, 200, { files: entries.sort() });
396
+ }
397
+
398
+ async function sendRunFile(res: http.ServerResponse, runId: string, name: string): Promise<void> {
399
+ const file = path.join(app.runtimeDir, "runs", runId, "shared", path.basename(name));
400
+ const content = await fs.readFile(file, "utf8").catch(() => null);
401
+ if (content === null) return sendJson(res, 404, { error: "not found" });
402
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }).end(content);
403
+ }
404
+
405
+ function streamEvents(res: http.ServerResponse): void {
406
+ // CORS was already decided per-origin in cors(); don't re-widen it here.
407
+ res.writeHead(200, {
408
+ "Content-Type": "text/event-stream",
409
+ "Cache-Control": "no-cache",
410
+ Connection: "keep-alive",
411
+ });
412
+ const send = (kind: string, data: unknown) => res.write(`event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`);
413
+ // Backfill recent history so a fresh client has context.
414
+ for (const e of events.all().slice(-200)) send("audit", e);
415
+ const onAudit = (e: unknown) => send("audit", e);
416
+ const onProposal = (p: unknown) => send("proposal", p);
417
+ events.on("event", onAudit);
418
+ app.proposals.on("created", onProposal);
419
+ app.proposals.on("updated", onProposal);
420
+ const ping = setInterval(() => res.write(": ping\n\n"), 15000);
421
+ res.on("close", () => {
422
+ clearInterval(ping);
423
+ events.off("event", onAudit);
424
+ app.proposals.off("created", onProposal);
425
+ app.proposals.off("updated", onProposal);
426
+ });
427
+ }
428
+
429
+ function safePath(rel: string): string | null {
430
+ const abs = path.resolve(orgRoot, rel);
431
+ return abs === orgRoot || abs.startsWith(orgRoot + path.sep) ? abs : null;
432
+ }
433
+
434
+ // --- memory file tree (read-only browser) ----------------------------------
435
+ function memSafe(rel: string): string | null {
436
+ const abs = path.resolve(memRoot, rel);
437
+ return abs === memRoot || abs.startsWith(memRoot + path.sep) ? abs : null;
438
+ }
439
+ async function walkMem(absDir: string, relDir: string): Promise<unknown[]> {
440
+ const entries = await fs.readdir(absDir, { withFileTypes: true }).catch(() => [] as import("node:fs").Dirent[]);
441
+ const nodes: unknown[] = [];
442
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
443
+ const rel = relDir ? `${relDir}/${e.name}` : e.name;
444
+ const abs = path.join(absDir, e.name);
445
+ if (e.isDirectory()) {
446
+ nodes.push({ name: e.name, path: rel, type: "dir", children: await walkMem(abs, rel) });
447
+ } else {
448
+ const st = await fs.stat(abs).catch(() => null);
449
+ nodes.push({ name: e.name, path: rel, type: "file", size: st?.size ?? 0, mtimeMs: st?.mtimeMs ?? 0 });
450
+ }
451
+ }
452
+ return nodes;
453
+ }
454
+
455
+ async function readJson(req: http.IncomingMessage): Promise<Record<string, unknown>> {
456
+ const chunks: Buffer[] = [];
457
+ let size = 0;
458
+ for await (const chunk of req) {
459
+ size += (chunk as Buffer).length;
460
+ if (size > MAX_BODY) throw new Error("request body too large");
461
+ chunks.push(chunk as Buffer);
462
+ }
463
+ if (!chunks.length) return {};
464
+ return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
465
+ }
466
+ }
467
+
468
+ const UI_CONTENT_TYPES: Record<string, string> = {
469
+ ".html": "text/html; charset=utf-8",
470
+ ".js": "text/javascript; charset=utf-8",
471
+ ".css": "text/css; charset=utf-8",
472
+ ".svg": "image/svg+xml",
473
+ ".json": "application/json",
474
+ ".map": "application/json",
475
+ ".png": "image/png",
476
+ ".ico": "image/x-icon",
477
+ ".woff2": "font/woff2",
478
+ };
479
+
480
+ /**
481
+ * Serve the built operator console. Extensionless misses fall back to the app
482
+ * shell (client-side routes); missing assets are a real 404.
483
+ */
484
+ async function sendUiFile(res: http.ServerResponse, uiDir: string, pathname: string): Promise<void> {
485
+ const root = path.resolve(uiDir);
486
+ const rel = pathname === "/" ? "index.html" : decodeURIComponent(pathname.slice(1));
487
+ const abs = path.resolve(root, rel);
488
+ if (abs !== root && !abs.startsWith(root + path.sep)) return sendJson(res, 400, { error: "path escapes ui root" });
489
+ let file = abs;
490
+ let content = await fs.readFile(file).catch(() => null);
491
+ if (content === null && !path.extname(rel)) {
492
+ file = path.join(root, "index.html");
493
+ content = await fs.readFile(file).catch(() => null);
494
+ }
495
+ if (content === null) return sendJson(res, 404, { error: "not found" });
496
+ const type = UI_CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
497
+ res.writeHead(200, { "Content-Type": type }).end(content);
498
+ }
499
+
500
+ /** Authoring files the config editor is allowed to write. */
501
+ function isAuthoringFile(rel: string): boolean {
502
+ const base = path.basename(rel);
503
+ return base === "agent.md" || base === "tools.json" || rel.replace(/\\/g, "/").includes("processes/") && base.endsWith(".process.md");
504
+ }
505
+
506
+ function serializeOrg(app: App): unknown {
507
+ const snap = app.currentSnapshot();
508
+ if (!snap) return { nodes: [], processes: [] };
509
+ return {
510
+ rootId: snap.rootId,
511
+ version: snap.version,
512
+ nodes: [...snap.nodes.values()].map((n) => ({
513
+ id: n.id,
514
+ name: n.spec.name,
515
+ role: n.spec.role ?? n.spec.name,
516
+ parentId: n.parentId,
517
+ childIds: n.childIds,
518
+ autonomy: n.spec.autonomy,
519
+ model: n.spec.model ?? null,
520
+ tools: n.tools.tools.map((t) => ({ name: t.name, policy: t.policy })),
521
+ processCount: n.processes.length,
522
+ })),
523
+ processes: snap.processes.map((p) => ({ name: p.spec.name, owner: p.ownerNodeId, path: p.path })),
524
+ };
525
+ }
526
+
527
+ /**
528
+ * CORS: grant only loopback origins (the console or a dev server on this
529
+ * machine). The API is auth-free by design, so a permissive "*" would let any
530
+ * website the operator's browser visits drive it cross-origin (CSRF /
531
+ * DNS-rebinding); non-local origins get no CORS grant at all.
532
+ */
533
+ function cors(req: http.IncomingMessage, res: http.ServerResponse): void {
534
+ const origin = req.headers.origin;
535
+ if (origin && isLoopbackOrigin(origin)) {
536
+ res.setHeader("Access-Control-Allow-Origin", origin);
537
+ res.setHeader("Vary", "Origin");
538
+ }
539
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
540
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
541
+ }
542
+
543
+ function isLoopbackOrigin(origin: string): boolean {
544
+ try {
545
+ const host = new URL(origin).hostname;
546
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
547
+ } catch {
548
+ return false;
549
+ }
550
+ }
551
+
552
+ function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
553
+ res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
554
+ }