@baanish/hydra-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +338 -0
- package/dist/config.js.map +1 -0
- package/dist/db/client.d.ts +10 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +93 -0
- package/dist/db/client.js.map +1 -0
- package/dist/db/queries.d.ts +67 -0
- package/dist/db/queries.d.ts.map +1 -0
- package/dist/db/queries.js +336 -0
- package/dist/db/queries.js.map +1 -0
- package/dist/engine/concurrency.d.ts +3 -0
- package/dist/engine/concurrency.d.ts.map +1 -0
- package/dist/engine/concurrency.js +42 -0
- package/dist/engine/concurrency.js.map +1 -0
- package/dist/engine/eta.d.ts +16 -0
- package/dist/engine/eta.d.ts.map +1 -0
- package/dist/engine/eta.js +54 -0
- package/dist/engine/eta.js.map +1 -0
- package/dist/engine/model.d.ts +57 -0
- package/dist/engine/model.d.ts.map +1 -0
- package/dist/engine/model.js +445 -0
- package/dist/engine/model.js.map +1 -0
- package/dist/engine/personas.d.ts +30 -0
- package/dist/engine/personas.d.ts.map +1 -0
- package/dist/engine/personas.js +336 -0
- package/dist/engine/personas.js.map +1 -0
- package/dist/engine/pipeline.d.ts +61 -0
- package/dist/engine/pipeline.d.ts.map +1 -0
- package/dist/engine/pipeline.js +638 -0
- package/dist/engine/pipeline.js.map +1 -0
- package/dist/engine/prompts.d.ts +10 -0
- package/dist/engine/prompts.d.ts.map +1 -0
- package/dist/engine/prompts.js +49 -0
- package/dist/engine/prompts.js.map +1 -0
- package/dist/engine/search.d.ts +46 -0
- package/dist/engine/search.d.ts.map +1 -0
- package/dist/engine/search.js +159 -0
- package/dist/engine/search.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +648 -0
- package/dist/index.js.map +1 -0
- package/dist/security.d.ts +18 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +168 -0
- package/dist/security.js.map +1 -0
- package/dist/types.d.ts +143 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/agent-mode.d.ts +8 -0
- package/dist/ui/agent-mode.d.ts.map +1 -0
- package/dist/ui/agent-mode.js +138 -0
- package/dist/ui/agent-mode.js.map +1 -0
- package/dist/ui/animations.d.ts +8 -0
- package/dist/ui/animations.d.ts.map +1 -0
- package/dist/ui/animations.js +19 -0
- package/dist/ui/animations.js.map +1 -0
- package/dist/ui/components/agent-list.d.ts +2 -0
- package/dist/ui/components/agent-list.d.ts.map +1 -0
- package/dist/ui/components/agent-list.js +2 -0
- package/dist/ui/components/agent-list.js.map +1 -0
- package/dist/ui/components/header.d.ts +2 -0
- package/dist/ui/components/header.d.ts.map +1 -0
- package/dist/ui/components/header.js +2 -0
- package/dist/ui/components/header.js.map +1 -0
- package/dist/ui/components/phase-bar.d.ts +2 -0
- package/dist/ui/components/phase-bar.d.ts.map +1 -0
- package/dist/ui/components/phase-bar.js +2 -0
- package/dist/ui/components/phase-bar.js.map +1 -0
- package/dist/ui/components/stats-bar.d.ts +2 -0
- package/dist/ui/components/stats-bar.d.ts.map +1 -0
- package/dist/ui/components/stats-bar.js +2 -0
- package/dist/ui/components/stats-bar.js.map +1 -0
- package/dist/ui/tui.d.ts +18 -0
- package/dist/ui/tui.d.ts.map +1 -0
- package/dist/ui/tui.js +464 -0
- package/dist/ui/tui.js.map +1 -0
- package/dist/web/app.html +1352 -0
- package/dist/web/index.d.ts +2 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +2 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +864 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createServer, } from "node:http";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { clampInt, loadConfig, maskConfigValue } from "../config.js";
|
|
8
|
+
import { getRun, getRunAgentRuns, listRuns, removeRun } from "../db/queries.js";
|
|
9
|
+
import { HydraPipeline } from "../engine/pipeline.js";
|
|
10
|
+
import { formatErrorMessage, isLoopbackHostname } from "../security.js";
|
|
11
|
+
const APP_HTML_TEMPLATE = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "app.html"), "utf8");
|
|
12
|
+
const API_SESSION_HEADER = "x-hydra-session";
|
|
13
|
+
const SESSION_TOKEN_PLACEHOLDER = "__HYDRA_WEB_TOKEN__";
|
|
14
|
+
const MAX_WEB_QUERY_CHARS = 20_000;
|
|
15
|
+
const activeRunPipelines = new Map();
|
|
16
|
+
const activeRunClients = new Map();
|
|
17
|
+
const pipelineEventTypes = [
|
|
18
|
+
"run-created",
|
|
19
|
+
"run-status-changed",
|
|
20
|
+
"agent-progress",
|
|
21
|
+
"agent-complete",
|
|
22
|
+
"run-complete",
|
|
23
|
+
];
|
|
24
|
+
function createSecurityHeaders(contentType) {
|
|
25
|
+
const headers = new Headers();
|
|
26
|
+
headers.set("Cache-Control", "no-store");
|
|
27
|
+
headers.set("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; object-src 'none'");
|
|
28
|
+
headers.set("Cross-Origin-Resource-Policy", "same-origin");
|
|
29
|
+
headers.set("Referrer-Policy", "no-referrer");
|
|
30
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
31
|
+
headers.set("X-Frame-Options", "DENY");
|
|
32
|
+
if (contentType) {
|
|
33
|
+
headers.set("Content-Type", contentType);
|
|
34
|
+
}
|
|
35
|
+
return headers;
|
|
36
|
+
}
|
|
37
|
+
function buildAppHtml(sessionToken) {
|
|
38
|
+
return APP_HTML_TEMPLATE.replaceAll(SESSION_TOKEN_PLACEHOLDER, sessionToken);
|
|
39
|
+
}
|
|
40
|
+
function createSseResponse() {
|
|
41
|
+
const encoder = new TextEncoder();
|
|
42
|
+
const stream = new TransformStream();
|
|
43
|
+
const writer = stream.writable.getWriter();
|
|
44
|
+
let closed = false;
|
|
45
|
+
const send = async (event) => {
|
|
46
|
+
if (closed) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const payload = `data: ${JSON.stringify(event)}\n\n`;
|
|
50
|
+
await writer.ready;
|
|
51
|
+
await writer.write(encoder.encode(payload));
|
|
52
|
+
};
|
|
53
|
+
const close = async () => {
|
|
54
|
+
if (closed) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
closed = true;
|
|
58
|
+
try {
|
|
59
|
+
await writer.close();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// stream already closed.
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
response: new Response(stream.readable, {
|
|
67
|
+
headers: createSecurityHeaders("text/event-stream"),
|
|
68
|
+
}),
|
|
69
|
+
send,
|
|
70
|
+
close,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function addRunClient(runId, client) {
|
|
74
|
+
let clients = activeRunClients.get(runId);
|
|
75
|
+
if (!clients) {
|
|
76
|
+
clients = new Set();
|
|
77
|
+
activeRunClients.set(runId, clients);
|
|
78
|
+
}
|
|
79
|
+
clients.add(client);
|
|
80
|
+
return () => {
|
|
81
|
+
const current = activeRunClients.get(runId);
|
|
82
|
+
if (!current) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
current.delete(client);
|
|
86
|
+
if (current.size === 0) {
|
|
87
|
+
activeRunClients.delete(runId);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function broadcastRunEvent(runId, event) {
|
|
92
|
+
const clients = activeRunClients.get(runId);
|
|
93
|
+
if (!clients || clients.size === 0) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
for (const client of [...clients]) {
|
|
97
|
+
void client.send(event).catch(() => {
|
|
98
|
+
const current = activeRunClients.get(runId);
|
|
99
|
+
if (!current) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
current.delete(client);
|
|
103
|
+
if (current.size === 0) {
|
|
104
|
+
activeRunClients.delete(runId);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function closeRunSubscribers(runId) {
|
|
110
|
+
const clients = activeRunClients.get(runId);
|
|
111
|
+
if (!clients || clients.size === 0) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
activeRunClients.delete(runId);
|
|
115
|
+
await Promise.allSettled([...clients].map(async (client) => {
|
|
116
|
+
try {
|
|
117
|
+
await client.send({ type: "done", runId });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// ignore failed stream writes while closing.
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await client.close();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// ignore failed stream closes while closing.
|
|
127
|
+
}
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
function resolveLlmApiKey(config) {
|
|
131
|
+
return config.apiKey || config.syntheticApiKey || "";
|
|
132
|
+
}
|
|
133
|
+
function resolveSearchConfig(config) {
|
|
134
|
+
return {
|
|
135
|
+
provider: config.searchProvider,
|
|
136
|
+
syntheticApiKey: config.syntheticApiKey || "",
|
|
137
|
+
exaApiKey: config.exaApiKey || "",
|
|
138
|
+
braveApiKey: config.braveApiKey || "",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function toMaskedConfig(config) {
|
|
142
|
+
return {
|
|
143
|
+
...config,
|
|
144
|
+
apiKey: maskConfigValue(config.apiKey),
|
|
145
|
+
syntheticApiKey: maskConfigValue(config.syntheticApiKey),
|
|
146
|
+
exaApiKey: maskConfigValue(config.exaApiKey),
|
|
147
|
+
braveApiKey: maskConfigValue(config.braveApiKey),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function phaseProgress(agentRuns) {
|
|
151
|
+
const counts = {
|
|
152
|
+
decompose: { completed: 0, total: 0 },
|
|
153
|
+
research: { completed: 0, total: 0 },
|
|
154
|
+
debate: { completed: 0, total: 0 },
|
|
155
|
+
synthesis: { completed: 0, total: 0 },
|
|
156
|
+
};
|
|
157
|
+
for (const item of agentRuns) {
|
|
158
|
+
if (!counts[item.phase]) {
|
|
159
|
+
counts[item.phase] = { completed: 0, total: 0 };
|
|
160
|
+
}
|
|
161
|
+
counts[item.phase].total += 1;
|
|
162
|
+
if (item.status === "complete" || item.status === "error") {
|
|
163
|
+
counts[item.phase].completed += 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return counts;
|
|
167
|
+
}
|
|
168
|
+
function toState(agentRun) {
|
|
169
|
+
return {
|
|
170
|
+
runId: agentRun.runId,
|
|
171
|
+
phase: agentRun.phase,
|
|
172
|
+
persona: agentRun.persona,
|
|
173
|
+
status: agentRun.status,
|
|
174
|
+
startedAt: agentRun.startedAt,
|
|
175
|
+
completedAt: agentRun.completedAt,
|
|
176
|
+
promptTokens: agentRun.promptTokens,
|
|
177
|
+
completionTokens: agentRun.completionTokens,
|
|
178
|
+
output: agentRun.output,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function toPublicAgentRun(agentRun) {
|
|
182
|
+
return {
|
|
183
|
+
id: agentRun.id,
|
|
184
|
+
runId: agentRun.runId,
|
|
185
|
+
phase: agentRun.phase,
|
|
186
|
+
persona: agentRun.persona,
|
|
187
|
+
status: agentRun.status,
|
|
188
|
+
promptTokens: agentRun.promptTokens,
|
|
189
|
+
completionTokens: agentRun.completionTokens,
|
|
190
|
+
startedAt: agentRun.startedAt,
|
|
191
|
+
completedAt: agentRun.completedAt,
|
|
192
|
+
output: agentRun.output,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
async function replayFromDb(runId, send) {
|
|
196
|
+
const run = getRun(runId);
|
|
197
|
+
if (!run) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
await send({
|
|
201
|
+
type: "run-created",
|
|
202
|
+
runId: run.id,
|
|
203
|
+
query: run.query,
|
|
204
|
+
agentCount: run.agentCount,
|
|
205
|
+
timestamp: run.createdAt,
|
|
206
|
+
});
|
|
207
|
+
const records = getRunAgentRuns(run.id).sort((left, right) => left.startedAt - right.startedAt);
|
|
208
|
+
const stats = phaseProgress(records);
|
|
209
|
+
for (const [phase, summary] of Object.entries(stats)) {
|
|
210
|
+
if (summary.total === 0) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
await send({
|
|
214
|
+
type: "agent-progress",
|
|
215
|
+
runId: run.id,
|
|
216
|
+
phase: phase,
|
|
217
|
+
completedAgents: summary.completed,
|
|
218
|
+
totalAgents: summary.total,
|
|
219
|
+
timestamp: run.createdAt,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
for (const item of records) {
|
|
223
|
+
if (item.status !== "complete" && item.status !== "error") {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
await send({
|
|
227
|
+
type: "agent-complete",
|
|
228
|
+
runId: run.id,
|
|
229
|
+
agentRunId: item.id,
|
|
230
|
+
persona: item.persona,
|
|
231
|
+
phase: item.phase,
|
|
232
|
+
state: toState(item),
|
|
233
|
+
timestamp: item.completedAt ?? item.startedAt,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
await send({
|
|
237
|
+
type: "run-status-changed",
|
|
238
|
+
runId: run.id,
|
|
239
|
+
status: run.status,
|
|
240
|
+
timestamp: run.completedAt ?? run.createdAt,
|
|
241
|
+
});
|
|
242
|
+
if (run.status === "complete") {
|
|
243
|
+
await send({
|
|
244
|
+
type: "run-complete",
|
|
245
|
+
runId: run.id,
|
|
246
|
+
elapsedMs: run.elapsedMs ?? 0,
|
|
247
|
+
totalPromptTokens: run.totalPromptTokens,
|
|
248
|
+
totalCompletionTokens: run.totalCompletionTokens,
|
|
249
|
+
timestamp: run.completedAt ?? run.createdAt,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function parseJsonBody(body) {
|
|
254
|
+
if (typeof body !== "object" || body === null) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const payload = body;
|
|
258
|
+
if (typeof payload.query !== "string") {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const query = payload.query.trim();
|
|
262
|
+
if (!query || query.length > MAX_WEB_QUERY_CHARS) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
query,
|
|
267
|
+
agentCount: typeof payload.agentCount === "number" ||
|
|
268
|
+
typeof payload.agentCount === "string"
|
|
269
|
+
? Number(payload.agentCount)
|
|
270
|
+
: undefined,
|
|
271
|
+
searchEnabled: typeof payload.searchEnabled === "boolean"
|
|
272
|
+
? payload.searchEnabled
|
|
273
|
+
: undefined,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function jsonResponse(payload, status = 200) {
|
|
277
|
+
return new Response(JSON.stringify(payload), {
|
|
278
|
+
status,
|
|
279
|
+
headers: createSecurityHeaders("application/json"),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function textResponse(message, status = 200) {
|
|
283
|
+
return new Response(message, {
|
|
284
|
+
status,
|
|
285
|
+
headers: createSecurityHeaders("text/plain; charset=utf-8"),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
function isEventStreamRequest(req) {
|
|
289
|
+
return req.headers.get("accept")?.includes("text/event-stream") ?? false;
|
|
290
|
+
}
|
|
291
|
+
function isSseEventsRoute(pathname) {
|
|
292
|
+
return pathname.endsWith("/events") && parseRunId(pathname) !== null;
|
|
293
|
+
}
|
|
294
|
+
function readProvidedSessionToken(req, url, pathname) {
|
|
295
|
+
const headerToken = req.headers.get(API_SESSION_HEADER)?.trim();
|
|
296
|
+
if (headerToken) {
|
|
297
|
+
return headerToken;
|
|
298
|
+
}
|
|
299
|
+
if (!isEventStreamRequest(req) || !isSseEventsRoute(pathname)) {
|
|
300
|
+
return "";
|
|
301
|
+
}
|
|
302
|
+
return url.searchParams.get("session")?.trim() ?? "";
|
|
303
|
+
}
|
|
304
|
+
function isAuthorizedSessionToken(expectedToken, providedToken) {
|
|
305
|
+
if (!providedToken) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
const expected = Buffer.from(expectedToken);
|
|
309
|
+
const provided = Buffer.from(providedToken);
|
|
310
|
+
if (expected.length !== provided.length) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
return timingSafeEqual(expected, provided);
|
|
314
|
+
}
|
|
315
|
+
function authorizeApiRequest(req, url, pathname, sessionToken) {
|
|
316
|
+
if (!isLoopbackHostname(url.hostname)) {
|
|
317
|
+
return jsonResponse({ error: "forbidden host" }, 403);
|
|
318
|
+
}
|
|
319
|
+
const secFetchSite = req.headers.get("sec-fetch-site");
|
|
320
|
+
if (secFetchSite &&
|
|
321
|
+
secFetchSite !== "same-origin" &&
|
|
322
|
+
secFetchSite !== "same-site" &&
|
|
323
|
+
secFetchSite !== "none") {
|
|
324
|
+
return jsonResponse({ error: "forbidden request origin" }, 403);
|
|
325
|
+
}
|
|
326
|
+
const origin = req.headers.get("origin");
|
|
327
|
+
if (origin && origin !== url.origin) {
|
|
328
|
+
return jsonResponse({ error: "forbidden request origin" }, 403);
|
|
329
|
+
}
|
|
330
|
+
const providedToken = readProvidedSessionToken(req, url, pathname);
|
|
331
|
+
if (!isAuthorizedSessionToken(sessionToken, providedToken)) {
|
|
332
|
+
return jsonResponse({ error: "unauthorized" }, 401);
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
function parseRunId(pathname) {
|
|
337
|
+
const match = /^\/api\/runs\/([^/]+)(?:\/events)?$/.exec(pathname);
|
|
338
|
+
if (!match) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
return decodeURIComponent(match[1]);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function isRunFinal(status) {
|
|
349
|
+
return status === "complete" || status === "error";
|
|
350
|
+
}
|
|
351
|
+
function routeRunEvents(runId, req) {
|
|
352
|
+
const run = getRun(runId);
|
|
353
|
+
if (!run) {
|
|
354
|
+
return jsonResponse({ error: "run not found" }, 404);
|
|
355
|
+
}
|
|
356
|
+
const { response, send, close } = createSseResponse();
|
|
357
|
+
const bufferedEvents = [];
|
|
358
|
+
const client = { send, close };
|
|
359
|
+
const bufferedClient = {
|
|
360
|
+
send: async (event) => {
|
|
361
|
+
bufferedEvents.push(event);
|
|
362
|
+
},
|
|
363
|
+
close: async () => { },
|
|
364
|
+
};
|
|
365
|
+
let removeClient = () => { };
|
|
366
|
+
const activePipeline = activeRunPipelines.get(runId);
|
|
367
|
+
const onTerminalEvent = (event) => {
|
|
368
|
+
if (event.type === "run-complete" ||
|
|
369
|
+
(event.type === "run-status-changed" && event.status === "error")) {
|
|
370
|
+
void closeRunSubscribers(runId);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
const closeSafely = async () => {
|
|
374
|
+
try {
|
|
375
|
+
await close();
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// ignore close failures when stream is already closed.
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
let finalized = false;
|
|
382
|
+
const finalize = () => {
|
|
383
|
+
if (finalized) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
finalized = true;
|
|
387
|
+
if (activePipeline) {
|
|
388
|
+
activePipeline.off("run-complete", onTerminalEvent);
|
|
389
|
+
activePipeline.off("run-status-changed", onTerminalEvent);
|
|
390
|
+
}
|
|
391
|
+
removeClient();
|
|
392
|
+
};
|
|
393
|
+
const sendDone = async () => {
|
|
394
|
+
try {
|
|
395
|
+
await send({ type: "done", runId });
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// ignore failed terminal done sends during disconnects.
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
const flushBufferedEvents = async () => {
|
|
402
|
+
let hadDone = false;
|
|
403
|
+
for (const event of bufferedEvents) {
|
|
404
|
+
if (event.type === "done") {
|
|
405
|
+
hadDone = true;
|
|
406
|
+
}
|
|
407
|
+
await send(event);
|
|
408
|
+
}
|
|
409
|
+
bufferedEvents.length = 0;
|
|
410
|
+
return hadDone;
|
|
411
|
+
};
|
|
412
|
+
if (activePipeline) {
|
|
413
|
+
activePipeline.on("run-complete", onTerminalEvent);
|
|
414
|
+
activePipeline.on("run-status-changed", onTerminalEvent);
|
|
415
|
+
}
|
|
416
|
+
req.signal.addEventListener("abort", () => {
|
|
417
|
+
finalize();
|
|
418
|
+
void closeSafely();
|
|
419
|
+
}, { once: true });
|
|
420
|
+
void (async () => {
|
|
421
|
+
try {
|
|
422
|
+
if (req.signal.aborted) {
|
|
423
|
+
finalize();
|
|
424
|
+
await closeSafely();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
removeClient = addRunClient(runId, bufferedClient);
|
|
428
|
+
await replayFromDb(runId, send);
|
|
429
|
+
if (req.signal.aborted) {
|
|
430
|
+
finalize();
|
|
431
|
+
await closeSafely();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const latestRun = getRun(runId);
|
|
435
|
+
if (!latestRun) {
|
|
436
|
+
finalize();
|
|
437
|
+
await closeSafely();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (isRunFinal(latestRun.status)) {
|
|
441
|
+
const hadDone = await flushBufferedEvents();
|
|
442
|
+
if (!hadDone) {
|
|
443
|
+
await sendDone();
|
|
444
|
+
}
|
|
445
|
+
finalize();
|
|
446
|
+
await closeSafely();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
removeClient();
|
|
450
|
+
removeClient = addRunClient(runId, client);
|
|
451
|
+
for (const event of bufferedEvents) {
|
|
452
|
+
await send(event);
|
|
453
|
+
}
|
|
454
|
+
bufferedEvents.length = 0;
|
|
455
|
+
if (req.signal.aborted) {
|
|
456
|
+
finalize();
|
|
457
|
+
await closeSafely();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (!activeRunPipelines.has(runId)) {
|
|
461
|
+
const initialAgentRuns = getRunAgentRuns(runId);
|
|
462
|
+
const completedAgentRuns = new Set(initialAgentRuns
|
|
463
|
+
.filter((agentRun) => agentRun.status === "complete" || agentRun.status === "error")
|
|
464
|
+
.map((agentRun) => agentRun.id));
|
|
465
|
+
const lastProgress = new Map();
|
|
466
|
+
for (const [phase, summary] of Object.entries(phaseProgress(initialAgentRuns))) {
|
|
467
|
+
lastProgress.set(phase, {
|
|
468
|
+
completedAgents: summary.completed,
|
|
469
|
+
totalAgents: summary.total,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
let lastRunStatus = latestRun.status;
|
|
473
|
+
while (!req.signal.aborted) {
|
|
474
|
+
const polledRun = getRun(runId);
|
|
475
|
+
if (!polledRun) {
|
|
476
|
+
finalize();
|
|
477
|
+
await closeSafely();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (activeRunPipelines.has(runId)) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (polledRun.status !== lastRunStatus) {
|
|
484
|
+
lastRunStatus = polledRun.status;
|
|
485
|
+
await send({
|
|
486
|
+
type: "run-status-changed",
|
|
487
|
+
runId: polledRun.id,
|
|
488
|
+
status: polledRun.status,
|
|
489
|
+
timestamp: polledRun.completedAt ?? polledRun.createdAt,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const polledAgentRuns = getRunAgentRuns(runId);
|
|
493
|
+
for (const [phase, summary] of Object.entries(phaseProgress(polledAgentRuns))) {
|
|
494
|
+
const previous = lastProgress.get(phase);
|
|
495
|
+
if (!previous ||
|
|
496
|
+
previous.completedAgents !== summary.completed ||
|
|
497
|
+
previous.totalAgents !== summary.total) {
|
|
498
|
+
lastProgress.set(phase, {
|
|
499
|
+
completedAgents: summary.completed,
|
|
500
|
+
totalAgents: summary.total,
|
|
501
|
+
});
|
|
502
|
+
await send({
|
|
503
|
+
type: "agent-progress",
|
|
504
|
+
runId: polledRun.id,
|
|
505
|
+
phase: phase,
|
|
506
|
+
completedAgents: summary.completed,
|
|
507
|
+
totalAgents: summary.total,
|
|
508
|
+
timestamp: polledRun.completedAt ?? polledRun.createdAt,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
for (const agentRun of polledAgentRuns) {
|
|
513
|
+
if (agentRun.status !== "complete" && agentRun.status !== "error") {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (completedAgentRuns.has(agentRun.id)) {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
completedAgentRuns.add(agentRun.id);
|
|
520
|
+
await send({
|
|
521
|
+
type: "agent-complete",
|
|
522
|
+
runId: polledRun.id,
|
|
523
|
+
agentRunId: agentRun.id,
|
|
524
|
+
persona: agentRun.persona,
|
|
525
|
+
phase: agentRun.phase,
|
|
526
|
+
state: toState(agentRun),
|
|
527
|
+
timestamp: agentRun.completedAt ?? agentRun.startedAt,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
if (isRunFinal(polledRun.status)) {
|
|
531
|
+
if (polledRun.status === "complete") {
|
|
532
|
+
await send({
|
|
533
|
+
type: "run-complete",
|
|
534
|
+
runId: polledRun.id,
|
|
535
|
+
elapsedMs: polledRun.elapsedMs ?? 0,
|
|
536
|
+
totalPromptTokens: polledRun.totalPromptTokens,
|
|
537
|
+
totalCompletionTokens: polledRun.totalCompletionTokens,
|
|
538
|
+
timestamp: polledRun.completedAt ?? polledRun.createdAt,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
await sendDone();
|
|
542
|
+
finalize();
|
|
543
|
+
await closeSafely();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
finalize();
|
|
552
|
+
await closeSafely();
|
|
553
|
+
}
|
|
554
|
+
})();
|
|
555
|
+
return response;
|
|
556
|
+
}
|
|
557
|
+
async function handleWebRequest(req, sessionToken) {
|
|
558
|
+
const method = req.method;
|
|
559
|
+
const url = new URL(req.url);
|
|
560
|
+
const pathname = url.pathname;
|
|
561
|
+
const apiRequest = pathname.startsWith("/api/");
|
|
562
|
+
if (!isLoopbackHostname(url.hostname)) {
|
|
563
|
+
return textResponse("forbidden host", 403);
|
|
564
|
+
}
|
|
565
|
+
if (method === "OPTIONS") {
|
|
566
|
+
return new Response(null, {
|
|
567
|
+
status: 204,
|
|
568
|
+
headers: createSecurityHeaders("text/plain; charset=utf-8"),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (apiRequest) {
|
|
572
|
+
const authFailure = authorizeApiRequest(req, url, pathname, sessionToken);
|
|
573
|
+
if (authFailure) {
|
|
574
|
+
return authFailure;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
578
|
+
return new Response(buildAppHtml(sessionToken), {
|
|
579
|
+
headers: createSecurityHeaders("text/html; charset=utf-8"),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (pathname === "/api/config") {
|
|
583
|
+
if (method !== "GET") {
|
|
584
|
+
return jsonResponse({ error: "method not allowed" }, 405);
|
|
585
|
+
}
|
|
586
|
+
return jsonResponse(toMaskedConfig(loadConfig()));
|
|
587
|
+
}
|
|
588
|
+
if (pathname === "/api/runs") {
|
|
589
|
+
if (method !== "GET") {
|
|
590
|
+
return jsonResponse({ error: "method not allowed" }, 405);
|
|
591
|
+
}
|
|
592
|
+
return jsonResponse(listRuns(50));
|
|
593
|
+
}
|
|
594
|
+
const runId = parseRunId(pathname);
|
|
595
|
+
if (runId) {
|
|
596
|
+
if (pathname.endsWith("/events")) {
|
|
597
|
+
if (method !== "GET") {
|
|
598
|
+
return jsonResponse({ error: "method not allowed" }, 405);
|
|
599
|
+
}
|
|
600
|
+
return routeRunEvents(runId, req);
|
|
601
|
+
}
|
|
602
|
+
if (method === "GET") {
|
|
603
|
+
const run = getRun(runId);
|
|
604
|
+
if (!run) {
|
|
605
|
+
return jsonResponse({ error: "run not found" }, 404);
|
|
606
|
+
}
|
|
607
|
+
return jsonResponse({
|
|
608
|
+
run,
|
|
609
|
+
agentRuns: getRunAgentRuns(runId).map(toPublicAgentRun),
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
if (method === "DELETE") {
|
|
613
|
+
const run = getRun(runId);
|
|
614
|
+
if (!run) {
|
|
615
|
+
return jsonResponse({ error: "run not found" }, 404);
|
|
616
|
+
}
|
|
617
|
+
if (activeRunPipelines.has(runId)) {
|
|
618
|
+
return jsonResponse({ error: "run is still executing" }, 409);
|
|
619
|
+
}
|
|
620
|
+
if (!isRunFinal(run.status)) {
|
|
621
|
+
return jsonResponse({ error: "run is still executing" }, 409);
|
|
622
|
+
}
|
|
623
|
+
const removed = removeRun(runId);
|
|
624
|
+
if (!removed) {
|
|
625
|
+
return jsonResponse({ error: "run not found" }, 404);
|
|
626
|
+
}
|
|
627
|
+
return jsonResponse({ ok: true });
|
|
628
|
+
}
|
|
629
|
+
return jsonResponse({ error: "method not allowed" }, 405);
|
|
630
|
+
}
|
|
631
|
+
if (pathname === "/api/run") {
|
|
632
|
+
if (method !== "POST") {
|
|
633
|
+
return jsonResponse({ error: "method not allowed" }, 405);
|
|
634
|
+
}
|
|
635
|
+
let parsedPayload;
|
|
636
|
+
try {
|
|
637
|
+
parsedPayload = parseJsonBody(await req.json());
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
parsedPayload = null;
|
|
641
|
+
}
|
|
642
|
+
if (!parsedPayload) {
|
|
643
|
+
return jsonResponse({ error: "invalid run payload" }, 400);
|
|
644
|
+
}
|
|
645
|
+
const config = loadConfig();
|
|
646
|
+
const resolvedAgentCount = clampInt(parsedPayload.agentCount ?? config.defaultAgentCount, 1, 20, config.defaultAgentCount);
|
|
647
|
+
const resolvedSearchEnabled = parsedPayload.searchEnabled ?? config.searchEnabled;
|
|
648
|
+
const pipelineConfig = {
|
|
649
|
+
apiKey: resolveLlmApiKey(config),
|
|
650
|
+
baseUrl: config.baseUrl,
|
|
651
|
+
model: config.model,
|
|
652
|
+
orchestratorModel: config.orchestratorModel ?? config.model,
|
|
653
|
+
researchModel: config.researchModel ?? config.model,
|
|
654
|
+
searchConfig: resolveSearchConfig(config),
|
|
655
|
+
agentCount: resolvedAgentCount,
|
|
656
|
+
maxConcurrency: config.maxConcurrency,
|
|
657
|
+
debateRounds: config.debateRounds,
|
|
658
|
+
searchEnabled: resolvedSearchEnabled,
|
|
659
|
+
customPersonasOnly: config.customPersonasOnly,
|
|
660
|
+
};
|
|
661
|
+
const pipeline = new HydraPipeline(pipelineConfig);
|
|
662
|
+
const { response, send, close } = createSseResponse();
|
|
663
|
+
const client = { send, close };
|
|
664
|
+
let runId = null;
|
|
665
|
+
let removeClient = () => { };
|
|
666
|
+
let terminalEventHandled = false;
|
|
667
|
+
let terminalClose = null;
|
|
668
|
+
const onPipelineEvent = (event) => {
|
|
669
|
+
if (event.type === "run-created") {
|
|
670
|
+
runId = event.runId;
|
|
671
|
+
activeRunPipelines.set(runId, pipeline);
|
|
672
|
+
removeClient();
|
|
673
|
+
removeClient = addRunClient(runId, client);
|
|
674
|
+
}
|
|
675
|
+
broadcastRunEvent(event.runId, event);
|
|
676
|
+
if (event.type === "run-complete") {
|
|
677
|
+
if (event.runId && !terminalEventHandled) {
|
|
678
|
+
terminalEventHandled = true;
|
|
679
|
+
activeRunPipelines.delete(event.runId);
|
|
680
|
+
terminalClose = closeRunSubscribers(event.runId).catch(() => {
|
|
681
|
+
// ignore terminal cleanup failures while stream is closing.
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
else if (event.type === "run-status-changed" &&
|
|
686
|
+
event.status === "error") {
|
|
687
|
+
if (!terminalEventHandled) {
|
|
688
|
+
terminalEventHandled = true;
|
|
689
|
+
activeRunPipelines.delete(event.runId);
|
|
690
|
+
terminalClose = closeRunSubscribers(event.runId).catch(() => {
|
|
691
|
+
// ignore terminal cleanup failures while stream is closing.
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
for (const eventType of pipelineEventTypes) {
|
|
697
|
+
pipeline.on(eventType, onPipelineEvent);
|
|
698
|
+
}
|
|
699
|
+
const cleanupListeners = () => {
|
|
700
|
+
for (const eventType of pipelineEventTypes) {
|
|
701
|
+
pipeline.off(eventType, onPipelineEvent);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
req.signal.addEventListener("abort", () => {
|
|
705
|
+
removeClient();
|
|
706
|
+
void close();
|
|
707
|
+
}, { once: true });
|
|
708
|
+
void (async () => {
|
|
709
|
+
try {
|
|
710
|
+
await pipeline.run(parsedPayload.query);
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
console.error(`[hydra] pipeline failed to start run ${runId ?? "unknown"}: ${formatErrorMessage(error)}`);
|
|
714
|
+
if (runId && !terminalEventHandled) {
|
|
715
|
+
activeRunPipelines.delete(runId);
|
|
716
|
+
try {
|
|
717
|
+
await send({
|
|
718
|
+
type: "run-status-changed",
|
|
719
|
+
runId,
|
|
720
|
+
status: "error",
|
|
721
|
+
timestamp: Date.now(),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
// ignore write failures for aborted bootstrap streams.
|
|
726
|
+
}
|
|
727
|
+
terminalClose = closeRunSubscribers(runId).catch(() => {
|
|
728
|
+
// ignore terminal cleanup failures while stream is closing.
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
if (!runId) {
|
|
732
|
+
try {
|
|
733
|
+
await send({ type: "done", runId: "" });
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// ignore done writes for interrupted streams.
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (terminalClose) {
|
|
741
|
+
await terminalClose;
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
await send({ type: "done", runId });
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
// ignore done writes for interrupted streams.
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
finally {
|
|
752
|
+
cleanupListeners();
|
|
753
|
+
removeClient();
|
|
754
|
+
await close();
|
|
755
|
+
}
|
|
756
|
+
})();
|
|
757
|
+
return response;
|
|
758
|
+
}
|
|
759
|
+
return new Response("not found", {
|
|
760
|
+
status: 404,
|
|
761
|
+
headers: createSecurityHeaders("text/plain; charset=utf-8"),
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
async function readRequestBody(req) {
|
|
765
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
766
|
+
return undefined;
|
|
767
|
+
}
|
|
768
|
+
const chunks = [];
|
|
769
|
+
for await (const chunk of req) {
|
|
770
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
771
|
+
}
|
|
772
|
+
return chunks.length > 0 ? Buffer.concat(chunks) : undefined;
|
|
773
|
+
}
|
|
774
|
+
async function toWebRequest(req, port) {
|
|
775
|
+
const body = await readRequestBody(req);
|
|
776
|
+
const headers = new Headers();
|
|
777
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
778
|
+
if (value === undefined) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (Array.isArray(value)) {
|
|
782
|
+
for (const item of value) {
|
|
783
|
+
headers.append(name, item);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
headers.set(name, value);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const abortController = new AbortController();
|
|
791
|
+
req.once("close", () => {
|
|
792
|
+
if (!req.complete) {
|
|
793
|
+
abortController.abort();
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
const host = headers.get("host") ?? `127.0.0.1:${port}`;
|
|
797
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
798
|
+
const requestInit = {
|
|
799
|
+
method: req.method ?? "GET",
|
|
800
|
+
headers,
|
|
801
|
+
body: body ? new Blob([new Uint8Array(body)]) : undefined,
|
|
802
|
+
duplex: body ? "half" : undefined,
|
|
803
|
+
signal: abortController.signal,
|
|
804
|
+
};
|
|
805
|
+
return new Request(url, requestInit);
|
|
806
|
+
}
|
|
807
|
+
async function sendNodeResponse(response, res) {
|
|
808
|
+
res.statusCode = response.status;
|
|
809
|
+
if (response.statusText) {
|
|
810
|
+
res.statusMessage = response.statusText;
|
|
811
|
+
}
|
|
812
|
+
for (const [name, value] of response.headers) {
|
|
813
|
+
res.setHeader(name, value);
|
|
814
|
+
}
|
|
815
|
+
if (!response.body) {
|
|
816
|
+
res.end();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const body = Readable.fromWeb(response.body);
|
|
820
|
+
await new Promise((resolve, reject) => {
|
|
821
|
+
body.once("error", reject);
|
|
822
|
+
res.once("finish", resolve);
|
|
823
|
+
res.once("close", resolve);
|
|
824
|
+
body.pipe(res);
|
|
825
|
+
}).catch(() => {
|
|
826
|
+
if (!res.writableEnded) {
|
|
827
|
+
res.end();
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
export async function startWebServer(port) {
|
|
832
|
+
const sessionToken = randomBytes(24).toString("base64url");
|
|
833
|
+
const server = createServer(async (nodeReq, nodeRes) => {
|
|
834
|
+
try {
|
|
835
|
+
const request = await toWebRequest(nodeReq, port);
|
|
836
|
+
const response = await handleWebRequest(request, sessionToken);
|
|
837
|
+
await sendNodeResponse(response, nodeRes);
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
console.error(`[hydra] web request failed: ${formatErrorMessage(error)}`);
|
|
841
|
+
if (!nodeRes.headersSent) {
|
|
842
|
+
nodeRes.statusCode = 500;
|
|
843
|
+
const headers = createSecurityHeaders("text/plain; charset=utf-8");
|
|
844
|
+
for (const [name, value] of headers) {
|
|
845
|
+
nodeRes.setHeader(name, value);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (!nodeRes.writableEnded) {
|
|
849
|
+
nodeRes.end("internal server error");
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
server.requestTimeout = 0;
|
|
854
|
+
server.keepAliveTimeout = 0;
|
|
855
|
+
server.timeout = 0;
|
|
856
|
+
await new Promise((resolve, reject) => {
|
|
857
|
+
server.once("error", reject);
|
|
858
|
+
server.listen(port, "127.0.0.1", resolve);
|
|
859
|
+
});
|
|
860
|
+
console.log(`[hydra] web UI starting at http://localhost:${port}`);
|
|
861
|
+
console.log(`[hydra] open http://localhost:${port} in your browser`);
|
|
862
|
+
await new Promise(() => { });
|
|
863
|
+
}
|
|
864
|
+
//# sourceMappingURL=server.js.map
|