agent-relay-orchestrator 0.10.19 → 0.10.21
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/package.json +4 -2
- package/src/api.ts +542 -40
- package/src/artifact-proxy.ts +173 -0
- package/src/control.ts +156 -18
- package/src/index.ts +53 -7
- package/src/provider-probe.ts +184 -0
- package/src/recovery.ts +1 -1
- package/src/relay.ts +106 -15
- package/src/self-supervision.ts +82 -0
- package/src/self-upgrade.ts +143 -0
- package/src/spawn.ts +1267 -0
- package/src/version.ts +30 -1
- package/src/workspace-probe.ts +513 -0
- package/src/tmux.ts +0 -298
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.21",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"start": "bun run src/index.ts",
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
|
-
"dependencies": {
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"agent-relay-sdk": "0.2.2"
|
|
20
|
+
},
|
|
19
21
|
"devDependencies": {
|
|
20
22
|
"@types/bun": "latest"
|
|
21
23
|
},
|
package/src/api.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { readdirSync, statSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
4
|
+
import type { ServerWebSocket } from "bun";
|
|
5
|
+
import { proxyArtifactRequest } from "./artifact-proxy";
|
|
3
6
|
import type { OrchestratorConfig } from "./config";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
7
|
+
import type { ProviderProbeCache } from "./provider-probe";
|
|
8
|
+
import { captureSession, captureTerminal, createTerminalGuest, listSessions, sendTerminalInput, resizeTerminal, stopTerminalGuest } from "./spawn";
|
|
9
|
+
import type { TerminalSnapshot } from "./spawn";
|
|
10
|
+
import { VERSION, runtimeMetadata } from "./version";
|
|
11
|
+
import { previewWorkspaceMerge, probeWorkspace, workspaceDiff, workspaceGitState } from "./workspace-probe";
|
|
6
12
|
|
|
7
13
|
interface DirectoryEntry {
|
|
8
14
|
name: string;
|
|
@@ -16,6 +22,76 @@ interface DirectoryListing {
|
|
|
16
22
|
entries: DirectoryEntry[];
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
export interface FileEntry {
|
|
26
|
+
name: string;
|
|
27
|
+
path: string;
|
|
28
|
+
type: "file" | "directory" | "symlink";
|
|
29
|
+
size?: number;
|
|
30
|
+
modifiedAt?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FileListing {
|
|
34
|
+
path: string;
|
|
35
|
+
parent?: string;
|
|
36
|
+
baseDir: string;
|
|
37
|
+
entries: FileEntry[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FileReadResult {
|
|
41
|
+
path: string;
|
|
42
|
+
name: string;
|
|
43
|
+
mediaType: string;
|
|
44
|
+
encoding: "utf8" | "binary";
|
|
45
|
+
size: number;
|
|
46
|
+
modifiedAt?: number;
|
|
47
|
+
truncated: boolean;
|
|
48
|
+
content?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface FileStatResult {
|
|
52
|
+
path: string;
|
|
53
|
+
name: string;
|
|
54
|
+
type: "file" | "directory";
|
|
55
|
+
baseDir: string;
|
|
56
|
+
size?: number;
|
|
57
|
+
modifiedAt?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MAX_FILE_PREVIEW_BYTES = 1024 * 1024;
|
|
61
|
+
const DEFAULT_TERMINAL_STREAM_HEARTBEAT_MS = 5_000;
|
|
62
|
+
|
|
63
|
+
interface TerminalSocketData {
|
|
64
|
+
kind: "terminal";
|
|
65
|
+
config: OrchestratorConfig;
|
|
66
|
+
session: string;
|
|
67
|
+
timer?: ReturnType<typeof setInterval>;
|
|
68
|
+
lastSnapshotSignature?: string;
|
|
69
|
+
lastHeartbeatAt?: number;
|
|
70
|
+
paused?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type TerminalSocket = ServerWebSocket<TerminalSocketData>;
|
|
74
|
+
|
|
75
|
+
export function terminalSnapshotSignature(snapshot: TerminalSnapshot): string {
|
|
76
|
+
const hash = createHash("sha1");
|
|
77
|
+
hash.update(snapshot.session);
|
|
78
|
+
hash.update("\0");
|
|
79
|
+
hash.update(snapshot.running ? "1" : "0");
|
|
80
|
+
hash.update("\0");
|
|
81
|
+
hash.update(snapshot.agentAlive ? "1" : "0");
|
|
82
|
+
hash.update("\0");
|
|
83
|
+
hash.update(String(snapshot.cols ?? ""));
|
|
84
|
+
hash.update("\0");
|
|
85
|
+
hash.update(String(snapshot.rows ?? ""));
|
|
86
|
+
hash.update("\0");
|
|
87
|
+
hash.update(String(snapshot.cursorX ?? ""));
|
|
88
|
+
hash.update("\0");
|
|
89
|
+
hash.update(String(snapshot.cursorY ?? ""));
|
|
90
|
+
hash.update("\0");
|
|
91
|
+
hash.update(snapshot.content);
|
|
92
|
+
return hash.digest("hex");
|
|
93
|
+
}
|
|
94
|
+
|
|
19
95
|
function listDirectories(requestedPath: string | undefined, baseDir: string): DirectoryListing {
|
|
20
96
|
const base = resolve(baseDir);
|
|
21
97
|
const target = resolve(requestedPath || base);
|
|
@@ -44,6 +120,162 @@ function listDirectories(requestedPath: string | undefined, baseDir: string): Di
|
|
|
44
120
|
};
|
|
45
121
|
}
|
|
46
122
|
|
|
123
|
+
function createDirectory(parentPath: string, name: string, baseDir: string): DirectoryListing {
|
|
124
|
+
const base = resolve(baseDir);
|
|
125
|
+
const parent = resolve(parentPath);
|
|
126
|
+
const parentRel = relative(base, parent);
|
|
127
|
+
if (parentRel && (parentRel.startsWith("..") || parentRel.startsWith("/"))) {
|
|
128
|
+
throw new Error(`Path must be within baseDir: ${baseDir}`);
|
|
129
|
+
}
|
|
130
|
+
if (!name || name.includes("/") || name.includes("\\") || name.startsWith(".")) {
|
|
131
|
+
throw new Error(`Invalid directory name: ${name}`);
|
|
132
|
+
}
|
|
133
|
+
const target = join(parent, name);
|
|
134
|
+
mkdirSync(target, { recursive: true });
|
|
135
|
+
return listDirectories(target, baseDir);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveInsideBase(requestedPath: string | undefined, baseDir: string): { base: string; target: string } {
|
|
139
|
+
const base = realpathSync(resolve(baseDir));
|
|
140
|
+
const candidate = resolve(requestedPath || base);
|
|
141
|
+
let target: string;
|
|
142
|
+
try {
|
|
143
|
+
target = realpathSync(candidate);
|
|
144
|
+
} catch {
|
|
145
|
+
throw new Error(`Path does not exist: ${candidate}`);
|
|
146
|
+
}
|
|
147
|
+
const rel = relative(base, target);
|
|
148
|
+
if (rel && (rel.startsWith("..") || rel.startsWith("/"))) {
|
|
149
|
+
throw new Error(`Path must be within baseDir: ${baseDir}`);
|
|
150
|
+
}
|
|
151
|
+
return { base, target };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parentInsideBase(target: string, base: string): string | undefined {
|
|
155
|
+
const parent = dirname(target);
|
|
156
|
+
const parentRel = relative(base, parent);
|
|
157
|
+
return parentRel && !parentRel.startsWith("..") && !parentRel.startsWith("/") && parent !== target ? parent : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function listFiles(requestedPath: string | undefined, baseDir: string): FileListing {
|
|
161
|
+
const { base, target } = resolveInsideBase(requestedPath, baseDir);
|
|
162
|
+
const stat = statSync(target);
|
|
163
|
+
if (!stat.isDirectory()) throw new Error(`Not a directory: ${target}`);
|
|
164
|
+
|
|
165
|
+
const entries = readdirSync(target, { withFileTypes: true })
|
|
166
|
+
.map((entry): FileEntry | null => {
|
|
167
|
+
const entryPath = join(target, entry.name);
|
|
168
|
+
let type: FileEntry["type"] = entry.isDirectory() ? "directory" : "file";
|
|
169
|
+
let statForSize;
|
|
170
|
+
try {
|
|
171
|
+
const lst = lstatSync(entryPath);
|
|
172
|
+
if (lst.isSymbolicLink()) type = "symlink";
|
|
173
|
+
const resolved = realpathSync(entryPath);
|
|
174
|
+
const rel = relative(base, resolved);
|
|
175
|
+
if (rel && (rel.startsWith("..") || rel.startsWith("/"))) return null;
|
|
176
|
+
statForSize = statSync(resolved);
|
|
177
|
+
if (type !== "symlink") type = statForSize.isDirectory() ? "directory" : "file";
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
name: entry.name,
|
|
183
|
+
path: entryPath,
|
|
184
|
+
type,
|
|
185
|
+
...(statForSize.isFile() ? { size: statForSize.size } : {}),
|
|
186
|
+
modifiedAt: statForSize.mtimeMs,
|
|
187
|
+
};
|
|
188
|
+
})
|
|
189
|
+
.filter((entry): entry is FileEntry => Boolean(entry))
|
|
190
|
+
.sort((a, b) => {
|
|
191
|
+
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
192
|
+
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
193
|
+
return a.name.localeCompare(b.name);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
path: target,
|
|
198
|
+
parent: parentInsideBase(target, base),
|
|
199
|
+
baseDir: base,
|
|
200
|
+
entries,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function statFilePath(requestedPath: string | undefined, baseDir: string): FileStatResult {
|
|
205
|
+
if (!requestedPath) throw new Error("path is required");
|
|
206
|
+
const { base, target } = resolveInsideBase(requestedPath, baseDir);
|
|
207
|
+
const stat = statSync(target);
|
|
208
|
+
if (!stat.isFile() && !stat.isDirectory()) throw new Error(`Not a file or directory: ${target}`);
|
|
209
|
+
return {
|
|
210
|
+
path: target,
|
|
211
|
+
name: basename(target),
|
|
212
|
+
type: stat.isDirectory() ? "directory" : "file",
|
|
213
|
+
baseDir: base,
|
|
214
|
+
...(stat.isFile() ? { size: stat.size } : {}),
|
|
215
|
+
modifiedAt: stat.mtimeMs,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function readFilePreview(requestedPath: string | undefined, baseDir: string): FileReadResult {
|
|
220
|
+
if (!requestedPath) throw new Error("path is required");
|
|
221
|
+
const { target } = resolveInsideBase(requestedPath, baseDir);
|
|
222
|
+
const stat = statSync(target);
|
|
223
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${target}`);
|
|
224
|
+
|
|
225
|
+
const bytesToRead = Math.min(stat.size, MAX_FILE_PREVIEW_BYTES);
|
|
226
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
227
|
+
const fd = openSync(target, "r");
|
|
228
|
+
try {
|
|
229
|
+
readSync(fd, buffer, 0, bytesToRead, 0);
|
|
230
|
+
} finally {
|
|
231
|
+
closeSync(fd);
|
|
232
|
+
}
|
|
233
|
+
const mediaType = mediaTypeForPath(target);
|
|
234
|
+
const resultBase = {
|
|
235
|
+
path: target,
|
|
236
|
+
name: basename(target),
|
|
237
|
+
mediaType,
|
|
238
|
+
size: stat.size,
|
|
239
|
+
modifiedAt: stat.mtimeMs,
|
|
240
|
+
truncated: stat.size > MAX_FILE_PREVIEW_BYTES,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
if (isBinaryBuffer(buffer)) {
|
|
244
|
+
return { ...resultBase, encoding: "binary" };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
...resultBase,
|
|
249
|
+
encoding: "utf8",
|
|
250
|
+
content: new TextDecoder("utf-8", { fatal: true }).decode(buffer),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isBinaryBuffer(buffer: Buffer): boolean {
|
|
255
|
+
if (buffer.includes(0)) return true;
|
|
256
|
+
try {
|
|
257
|
+
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
258
|
+
return false;
|
|
259
|
+
} catch {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function mediaTypeForPath(path: string): string {
|
|
265
|
+
const name = basename(path).toLowerCase();
|
|
266
|
+
const ext = extname(path).toLowerCase();
|
|
267
|
+
if (ext === ".md" || ext === ".markdown") return "text/markdown";
|
|
268
|
+
if (ext === ".json" || ext === ".jsonl") return "application/json";
|
|
269
|
+
if (ext === ".yaml" || ext === ".yml") return "application/yaml";
|
|
270
|
+
if (ext === ".toml") return "application/toml";
|
|
271
|
+
if (ext === ".html") return "text/html";
|
|
272
|
+
if (ext === ".css") return "text/css";
|
|
273
|
+
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return "text/javascript";
|
|
274
|
+
if (ext === ".ts" || ext === ".tsx" || ext === ".jsx") return "text/typescript";
|
|
275
|
+
if (ext === ".txt" || ext === ".log" || ext === ".sh" || name.startsWith(".env")) return "text/plain";
|
|
276
|
+
return "application/octet-stream";
|
|
277
|
+
}
|
|
278
|
+
|
|
47
279
|
function json(data: unknown, status = 200): Response {
|
|
48
280
|
return new Response(JSON.stringify(data), {
|
|
49
281
|
status,
|
|
@@ -57,39 +289,33 @@ function error(message: string, status = 400): Response {
|
|
|
57
289
|
|
|
58
290
|
function authorized(req: Request, config: OrchestratorConfig): boolean {
|
|
59
291
|
if (!config.token) return true;
|
|
292
|
+
const url = new URL(req.url);
|
|
293
|
+
if (url.searchParams.get("token") === config.token) return true;
|
|
60
294
|
return req.headers.get("x-agent-relay-token") === config.token;
|
|
61
295
|
}
|
|
62
296
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const proc = Bun.spawn(["bash", "-lc", `command -v ${command} >/dev/null && ${command} --version | head -n 1`], {
|
|
66
|
-
stdout: "pipe",
|
|
67
|
-
stderr: "ignore",
|
|
68
|
-
});
|
|
69
|
-
const out = await new Response(proc.stdout).text();
|
|
70
|
-
return (await proc.exited) === 0 ? out.trim() || undefined : undefined;
|
|
71
|
-
} catch {
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function startApiServer(config: OrchestratorConfig): { stop(): void; url: string } {
|
|
77
|
-
const server = Bun.serve({
|
|
297
|
+
export function startApiServer(config: OrchestratorConfig, probeCache: ProviderProbeCache): { stop(): void; url: string } {
|
|
298
|
+
const server = Bun.serve<TerminalSocketData>({
|
|
78
299
|
port: config.apiPort,
|
|
79
300
|
hostname: "0.0.0.0",
|
|
80
|
-
fetch(req) {
|
|
301
|
+
async fetch(req, server) {
|
|
81
302
|
const url = new URL(req.url);
|
|
82
303
|
|
|
83
304
|
if (req.method === "OPTIONS") {
|
|
84
305
|
return new Response(null, {
|
|
85
306
|
headers: {
|
|
86
307
|
"Access-Control-Allow-Origin": "*",
|
|
87
|
-
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
88
|
-
"Access-Control-Allow-Headers": "Content-Type, X-Agent-Relay-Token",
|
|
308
|
+
"Access-Control-Allow-Methods": "GET, POST, HEAD, DELETE, OPTIONS",
|
|
309
|
+
"Access-Control-Allow-Headers": "Content-Type, X-Agent-Relay-Token, X-Artifact-Filename, X-Artifact-Digest, X-Artifact-Kind, X-Artifact-Sensitivity, X-Artifact-Expires-At",
|
|
89
310
|
},
|
|
90
311
|
});
|
|
91
312
|
}
|
|
92
313
|
|
|
314
|
+
if (url.pathname === "/api/artifacts" || url.pathname.startsWith("/api/artifacts/")) {
|
|
315
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
316
|
+
return proxyArtifactRequest(req, config).catch((e) => error((e as Error).message, 502));
|
|
317
|
+
}
|
|
318
|
+
|
|
93
319
|
if (req.method === "GET" && url.pathname === "/api/directories") {
|
|
94
320
|
try {
|
|
95
321
|
const listing = listDirectories(url.searchParams.get("path") || undefined, config.baseDir);
|
|
@@ -99,25 +325,116 @@ export function startApiServer(config: OrchestratorConfig): { stop(): void; url:
|
|
|
99
325
|
}
|
|
100
326
|
}
|
|
101
327
|
|
|
328
|
+
if (req.method === "POST" && url.pathname === "/api/directories") {
|
|
329
|
+
try {
|
|
330
|
+
const body = await req.json() as { path: string; name: string };
|
|
331
|
+
if (!body.path || !body.name) return error("path and name are required");
|
|
332
|
+
const listing = createDirectory(body.path, body.name, config.baseDir);
|
|
333
|
+
return json(listing, 201);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
return error((e as Error).message);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (req.method === "GET" && url.pathname === "/api/files/list") {
|
|
340
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
341
|
+
try {
|
|
342
|
+
return json(listFiles(url.searchParams.get("path") || undefined, config.baseDir));
|
|
343
|
+
} catch (e) {
|
|
344
|
+
return error((e as Error).message);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (req.method === "GET" && url.pathname === "/api/files/read") {
|
|
349
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
350
|
+
try {
|
|
351
|
+
return json(readFilePreview(url.searchParams.get("path") || undefined, config.baseDir));
|
|
352
|
+
} catch (e) {
|
|
353
|
+
return error((e as Error).message);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (req.method === "GET" && url.pathname === "/api/files/stat") {
|
|
358
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
359
|
+
try {
|
|
360
|
+
return json(statFilePath(url.searchParams.get("path") || undefined, config.baseDir));
|
|
361
|
+
} catch (e) {
|
|
362
|
+
return error((e as Error).message);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (req.method === "GET" && url.pathname === "/api/workspace/probe") {
|
|
367
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
368
|
+
try {
|
|
369
|
+
const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
|
|
370
|
+
return json(await probeWorkspace(target));
|
|
371
|
+
} catch (e) {
|
|
372
|
+
return error((e as Error).message);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (req.method === "GET" && url.pathname === "/api/workspace/state") {
|
|
377
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
378
|
+
try {
|
|
379
|
+
const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
|
|
380
|
+
return json(workspaceGitState({
|
|
381
|
+
worktreePath: target,
|
|
382
|
+
baseRef: url.searchParams.get("baseRef") || undefined,
|
|
383
|
+
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
384
|
+
}));
|
|
385
|
+
} catch (e) {
|
|
386
|
+
return error((e as Error).message);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (req.method === "GET" && url.pathname === "/api/workspace/diff") {
|
|
391
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
392
|
+
try {
|
|
393
|
+
const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
|
|
394
|
+
return json(workspaceDiff({
|
|
395
|
+
worktreePath: target,
|
|
396
|
+
baseRef: url.searchParams.get("baseRef") || undefined,
|
|
397
|
+
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
398
|
+
includePatch: url.searchParams.get("patch") !== "0",
|
|
399
|
+
}));
|
|
400
|
+
} catch (e) {
|
|
401
|
+
return error((e as Error).message);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (req.method === "GET" && url.pathname === "/api/workspace/merge-preview") {
|
|
406
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
407
|
+
try {
|
|
408
|
+
const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
|
|
409
|
+
const strategy = url.searchParams.get("strategy");
|
|
410
|
+
return json(previewWorkspaceMerge({
|
|
411
|
+
worktreePath: target,
|
|
412
|
+
baseRef: url.searchParams.get("baseRef") || undefined,
|
|
413
|
+
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
414
|
+
strategy: strategy === "pr" || strategy === "rebase-ff" || strategy === "auto" ? strategy : undefined,
|
|
415
|
+
}));
|
|
416
|
+
} catch (e) {
|
|
417
|
+
return error((e as Error).message);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
102
421
|
if (req.method === "GET" && url.pathname === "/api/providers") {
|
|
103
422
|
return (async () => {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
return { name: provider, available: Boolean(version), version, runnerVersion: VERSION };
|
|
107
|
-
}));
|
|
108
|
-
return json({ providers });
|
|
423
|
+
const snapshot = await probeCache.getSnapshot(url.searchParams.get("refresh") === "1");
|
|
424
|
+
return json(snapshot);
|
|
109
425
|
})();
|
|
110
426
|
}
|
|
111
427
|
|
|
112
428
|
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
|
113
|
-
return (
|
|
114
|
-
const sessions = await listTmuxSessions(config.tmuxPrefix);
|
|
115
|
-
return json({ sessions });
|
|
116
|
-
})();
|
|
429
|
+
return json({ sessions: listSessions(config.tmuxPrefix) });
|
|
117
430
|
}
|
|
118
431
|
|
|
119
432
|
if (req.method === "GET" && url.pathname === "/api/version") {
|
|
433
|
+
const runtime = runtimeMetadata();
|
|
120
434
|
return json({
|
|
435
|
+
package: runtime.package,
|
|
436
|
+
contracts: runtime.contracts,
|
|
437
|
+
capabilities: runtime.capabilities,
|
|
121
438
|
orchestrator: VERSION,
|
|
122
439
|
runner: VERSION,
|
|
123
440
|
adapters: Object.fromEntries(config.providers.map((provider) => [provider, VERSION])),
|
|
@@ -127,15 +444,87 @@ export function startApiServer(config: OrchestratorConfig): { stop(): void; url:
|
|
|
127
444
|
const logMatch = url.pathname.match(/^\/api\/logs\/([^/]+)$/);
|
|
128
445
|
if (req.method === "GET" && logMatch) {
|
|
129
446
|
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
447
|
+
try {
|
|
448
|
+
const session = decodeURIComponent(logMatch[1]!);
|
|
449
|
+
const lines = Number(url.searchParams.get("lines") || "100");
|
|
450
|
+
return json(captureSession(session, config, Number.isFinite(lines) ? lines : 100, {
|
|
451
|
+
raw: url.searchParams.get("raw") === "1",
|
|
452
|
+
}));
|
|
453
|
+
} catch (e) {
|
|
454
|
+
return error((e as Error).message, 400);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const terminalMatch = url.pathname.match(/^\/api\/terminal\/([^/]+)$/);
|
|
459
|
+
if (req.method === "GET" && terminalMatch) {
|
|
460
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
461
|
+
try {
|
|
462
|
+
const session = decodeURIComponent(terminalMatch[1]!);
|
|
463
|
+
return json(captureTerminal(session, config));
|
|
464
|
+
} catch (e) {
|
|
465
|
+
return error((e as Error).message, 400);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const terminalStreamMatch = url.pathname.match(/^\/api\/terminal\/([^/]+)\/stream$/);
|
|
470
|
+
if (req.method === "GET" && terminalStreamMatch) {
|
|
471
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
472
|
+
try {
|
|
473
|
+
const session = decodeURIComponent(terminalStreamMatch[1]!);
|
|
474
|
+
captureTerminal(session, config);
|
|
475
|
+
const upgraded = server.upgrade(req, {
|
|
476
|
+
data: { kind: "terminal", config, session },
|
|
477
|
+
});
|
|
478
|
+
if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
|
|
479
|
+
return undefined;
|
|
480
|
+
} catch (e) {
|
|
481
|
+
return error((e as Error).message, 400);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const terminalInputMatch = url.pathname.match(/^\/api\/terminal\/([^/]+)\/input$/);
|
|
486
|
+
if (req.method === "POST" && terminalInputMatch) {
|
|
487
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
488
|
+
try {
|
|
489
|
+
const session = decodeURIComponent(terminalInputMatch[1]!);
|
|
490
|
+
const body = await req.json();
|
|
491
|
+
return json(sendTerminalInput(session, config, body));
|
|
492
|
+
} catch (e) {
|
|
493
|
+
return error((e as Error).message, 400);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const terminalResizeMatch = url.pathname.match(/^\/api\/terminal\/([^/]+)\/resize$/);
|
|
498
|
+
if (req.method === "POST" && terminalResizeMatch) {
|
|
499
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
500
|
+
try {
|
|
501
|
+
const session = decodeURIComponent(terminalResizeMatch[1]!);
|
|
502
|
+
const body = await req.json();
|
|
503
|
+
return json(resizeTerminal(session, config, body));
|
|
504
|
+
} catch (e) {
|
|
505
|
+
return error((e as Error).message, 400);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (req.method === "POST" && url.pathname === "/api/terminal-guests") {
|
|
510
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
511
|
+
try {
|
|
512
|
+
const body = await req.json();
|
|
513
|
+
return json(await createTerminalGuest(cleanTerminalGuestInput(body), config), 201);
|
|
514
|
+
} catch (e) {
|
|
515
|
+
return error((e as Error).message, 400);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const terminalGuestMatch = url.pathname.match(/^\/api\/terminal-guests\/([^/]+)$/);
|
|
520
|
+
if (req.method === "DELETE" && terminalGuestMatch) {
|
|
521
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
522
|
+
try {
|
|
523
|
+
const session = decodeURIComponent(terminalGuestMatch[1]!);
|
|
524
|
+
return json(stopTerminalGuest(session, config));
|
|
525
|
+
} catch (e) {
|
|
526
|
+
return error((e as Error).message, 400);
|
|
527
|
+
}
|
|
139
528
|
}
|
|
140
529
|
|
|
141
530
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
@@ -144,9 +533,122 @@ export function startApiServer(config: OrchestratorConfig): { stop(): void; url:
|
|
|
144
533
|
|
|
145
534
|
return error("Not found", 404);
|
|
146
535
|
},
|
|
536
|
+
websocket: {
|
|
537
|
+
open(ws) {
|
|
538
|
+
const socket = ws as unknown as TerminalSocket;
|
|
539
|
+
if (socket.data?.kind === "terminal") startTerminalSocket(socket);
|
|
540
|
+
},
|
|
541
|
+
message(ws, data) {
|
|
542
|
+
const socket = ws as unknown as TerminalSocket;
|
|
543
|
+
if (socket.data?.kind === "terminal") handleTerminalSocketMessage(socket, data);
|
|
544
|
+
},
|
|
545
|
+
close(ws) {
|
|
546
|
+
const socket = ws as unknown as TerminalSocket;
|
|
547
|
+
if (socket.data?.kind === "terminal") stopTerminalSocket(socket);
|
|
548
|
+
},
|
|
549
|
+
},
|
|
147
550
|
});
|
|
148
551
|
|
|
149
552
|
const url = `http://${config.hostname}:${config.apiPort}`;
|
|
150
553
|
console.error(`[orchestrator] API server listening on :${config.apiPort}`);
|
|
151
554
|
return { stop: () => server.stop(), url };
|
|
152
555
|
}
|
|
556
|
+
|
|
557
|
+
function startTerminalSocket(ws: TerminalSocket): void {
|
|
558
|
+
sendTerminalSnapshot(ws, true);
|
|
559
|
+
const intervalMs = Number(process.env.AGENT_RELAY_TERMINAL_STREAM_INTERVAL_MS) || 125;
|
|
560
|
+
ws.data.timer = setInterval(() => {
|
|
561
|
+
if (!ws.data.paused) sendTerminalSnapshot(ws, false);
|
|
562
|
+
}, Math.max(50, intervalMs));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer): void {
|
|
566
|
+
let payload: unknown;
|
|
567
|
+
try {
|
|
568
|
+
payload = JSON.parse(typeof data === "string" ? data : data.toString("utf8"));
|
|
569
|
+
} catch {
|
|
570
|
+
ws.send(JSON.stringify({ type: "error", error: "invalid terminal socket frame" }));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
|
|
575
|
+
const frame = payload as Record<string, unknown>;
|
|
576
|
+
try {
|
|
577
|
+
if (frame.type === "input") {
|
|
578
|
+
sendTerminalInput(ws.data.session, ws.data.config, { data: typeof frame.data === "string" ? frame.data : "" });
|
|
579
|
+
sendTerminalSnapshot(ws, true);
|
|
580
|
+
} else if (frame.type === "resize") {
|
|
581
|
+
resizeTerminal(ws.data.session, ws.data.config, { cols: frame.cols, rows: frame.rows });
|
|
582
|
+
sendTerminalSnapshot(ws, true);
|
|
583
|
+
} else if (frame.type === "pause") {
|
|
584
|
+
ws.data.paused = frame.paused === true;
|
|
585
|
+
if (!ws.data.paused) sendTerminalSnapshot(ws, true);
|
|
586
|
+
} else if (frame.type === "refresh") {
|
|
587
|
+
sendTerminalSnapshot(ws, true);
|
|
588
|
+
}
|
|
589
|
+
} catch (e) {
|
|
590
|
+
ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function sendTerminalSnapshot(ws: TerminalSocket, force: boolean): void {
|
|
595
|
+
try {
|
|
596
|
+
const snapshot = captureTerminal(ws.data.session, ws.data.config);
|
|
597
|
+
const signature = terminalSnapshotSignature(snapshot);
|
|
598
|
+
const changed = force || signature !== ws.data.lastSnapshotSignature;
|
|
599
|
+
if (!changed) {
|
|
600
|
+
sendTerminalHeartbeat(ws, snapshot, signature);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
ws.data.lastSnapshotSignature = signature;
|
|
604
|
+
ws.data.lastHeartbeatAt = snapshot.capturedAt;
|
|
605
|
+
ws.send(JSON.stringify({ type: "snapshot", ...snapshot }));
|
|
606
|
+
} catch (e) {
|
|
607
|
+
ws.send(JSON.stringify({ type: "error", error: e instanceof Error ? e.message : String(e) }));
|
|
608
|
+
ws.close();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function sendTerminalHeartbeat(ws: TerminalSocket, snapshot: TerminalSnapshot, signature: string): void {
|
|
613
|
+
const heartbeatMs = Math.max(
|
|
614
|
+
1_000,
|
|
615
|
+
Number(process.env.AGENT_RELAY_TERMINAL_STREAM_HEARTBEAT_MS) || DEFAULT_TERMINAL_STREAM_HEARTBEAT_MS,
|
|
616
|
+
);
|
|
617
|
+
if (ws.data.lastHeartbeatAt && snapshot.capturedAt - ws.data.lastHeartbeatAt < heartbeatMs) return;
|
|
618
|
+
ws.data.lastHeartbeatAt = snapshot.capturedAt;
|
|
619
|
+
ws.send(JSON.stringify({
|
|
620
|
+
type: "heartbeat",
|
|
621
|
+
session: snapshot.session,
|
|
622
|
+
running: snapshot.running,
|
|
623
|
+
cols: snapshot.cols,
|
|
624
|
+
rows: snapshot.rows,
|
|
625
|
+
cursorX: snapshot.cursorX,
|
|
626
|
+
cursorY: snapshot.cursorY,
|
|
627
|
+
capturedAt: snapshot.capturedAt,
|
|
628
|
+
signature,
|
|
629
|
+
}));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function stopTerminalSocket(ws: TerminalSocket): void {
|
|
633
|
+
if (ws.data.timer) clearInterval(ws.data.timer);
|
|
634
|
+
ws.data.timer = undefined;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function cleanTerminalGuestInput(value: unknown): { agentId?: string; policyName?: string; spawnRequestId?: string; tmuxSession?: string } {
|
|
638
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("terminal guest body must be an object");
|
|
639
|
+
const body = value as Record<string, unknown>;
|
|
640
|
+
const result = {
|
|
641
|
+
agentId: stringValue(body.agentId),
|
|
642
|
+
policyName: stringValue(body.policyName),
|
|
643
|
+
spawnRequestId: stringValue(body.spawnRequestId),
|
|
644
|
+
tmuxSession: stringValue(body.tmuxSession),
|
|
645
|
+
};
|
|
646
|
+
if (!result.agentId && !result.policyName && !result.spawnRequestId && !result.tmuxSession) {
|
|
647
|
+
throw new Error("agentId, policyName, spawnRequestId, or tmuxSession required");
|
|
648
|
+
}
|
|
649
|
+
return result;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function stringValue(value: unknown): string | undefined {
|
|
653
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
654
|
+
}
|