agent-relay-orchestrator 0.11.8 → 0.12.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/package.json +2 -2
- package/src/api.ts +162 -46
- package/src/control.ts +18 -2
- package/src/index.ts +1 -1
- package/src/relay.ts +18 -0
- package/src/workspace-probe.ts +50 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-sdk": "0.2.6"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/api.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync,
|
|
2
|
-
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
1
|
+
import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import type { ServerWebSocket } from "bun";
|
|
4
4
|
import { proxyArtifactRequest } from "./artifact-proxy";
|
|
5
5
|
import type { OrchestratorConfig } from "./config";
|
|
6
6
|
import type { ProviderProbeCache } from "./provider-probe";
|
|
7
|
+
import type { RelayClient } from "./relay";
|
|
7
8
|
import { captureSession, captureTerminal, createTerminalGuest, listSessions, sendTerminalInput, resizeTerminal, stopTerminalGuest } from "./spawn";
|
|
8
9
|
import { acquireTerminalStream, type TerminalStreamHandle, type TerminalStreamSubscriber } from "./terminal-stream";
|
|
9
10
|
import { VERSION, runtimeMetadata } from "./version";
|
|
@@ -21,10 +22,16 @@ interface DirectoryListing {
|
|
|
21
22
|
entries: DirectoryEntry[];
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
export type GitFileStatus = "staged" | "unstaged" | "staged-unstaged" | "untracked";
|
|
26
|
+
|
|
24
27
|
export interface FileEntry {
|
|
25
28
|
name: string;
|
|
26
29
|
path: string;
|
|
27
30
|
type: "file" | "directory" | "symlink";
|
|
31
|
+
/** True when the directory entry is itself a symlink (resolved type is reported in `type`). */
|
|
32
|
+
symlink?: boolean;
|
|
33
|
+
/** Working-tree git status for this entry (or aggregate, for directories). */
|
|
34
|
+
gitStatus?: GitFileStatus;
|
|
28
35
|
size?: number;
|
|
29
36
|
modifiedAt?: number;
|
|
30
37
|
}
|
|
@@ -40,7 +47,7 @@ export interface FileReadResult {
|
|
|
40
47
|
path: string;
|
|
41
48
|
name: string;
|
|
42
49
|
mediaType: string;
|
|
43
|
-
encoding: "utf8" | "binary";
|
|
50
|
+
encoding: "utf8" | "binary" | "base64";
|
|
44
51
|
size: number;
|
|
45
52
|
modifiedAt?: number;
|
|
46
53
|
truncated: boolean;
|
|
@@ -57,6 +64,7 @@ export interface FileStatResult {
|
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
const MAX_FILE_PREVIEW_BYTES = 1024 * 1024;
|
|
67
|
+
const MAX_IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024;
|
|
60
68
|
|
|
61
69
|
interface TerminalSocketData {
|
|
62
70
|
kind: "terminal";
|
|
@@ -117,19 +125,23 @@ function createDirectory(parentPath: string, name: string, baseDir: string): Dir
|
|
|
117
125
|
return listDirectories(target, baseDir);
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
// Containment is lexical (resolve + relative), per the project's path-containment
|
|
129
|
+
// rule. This intentionally follows symlinks wherever they point: the `path` query
|
|
130
|
+
// param itself can't escape baseDir with `../`, but symlinks placed inside baseDir
|
|
131
|
+
// by the operator are treated as first-class entries (same trust level as the
|
|
132
|
+
// authenticated dashboard, which can already open a host terminal).
|
|
120
133
|
function resolveInsideBase(requestedPath: string | undefined, baseDir: string): { base: string; target: string } {
|
|
121
|
-
const base =
|
|
122
|
-
const
|
|
123
|
-
let target: string;
|
|
124
|
-
try {
|
|
125
|
-
target = realpathSync(candidate);
|
|
126
|
-
} catch {
|
|
127
|
-
throw new Error(`Path does not exist: ${candidate}`);
|
|
128
|
-
}
|
|
134
|
+
const base = resolve(baseDir);
|
|
135
|
+
const target = resolve(requestedPath || base);
|
|
129
136
|
const rel = relative(base, target);
|
|
130
|
-
if (rel && (rel.startsWith("..") || rel
|
|
137
|
+
if (rel && (rel.startsWith("..") || isAbsolute(rel))) {
|
|
131
138
|
throw new Error(`Path must be within baseDir: ${baseDir}`);
|
|
132
139
|
}
|
|
140
|
+
try {
|
|
141
|
+
statSync(target);
|
|
142
|
+
} catch {
|
|
143
|
+
throw new Error(`Path does not exist: ${target}`);
|
|
144
|
+
}
|
|
133
145
|
return { base, target };
|
|
134
146
|
}
|
|
135
147
|
|
|
@@ -139,42 +151,51 @@ function parentInsideBase(target: string, base: string): string | undefined {
|
|
|
139
151
|
return parentRel && !parentRel.startsWith("..") && !parentRel.startsWith("/") && parent !== target ? parent : undefined;
|
|
140
152
|
}
|
|
141
153
|
|
|
142
|
-
export function listFiles(requestedPath: string | undefined, baseDir: string): FileListing {
|
|
154
|
+
export function listFiles(requestedPath: string | undefined, baseDir: string, options: { git?: boolean } = {}): FileListing {
|
|
143
155
|
const { base, target } = resolveInsideBase(requestedPath, baseDir);
|
|
144
156
|
const stat = statSync(target);
|
|
145
157
|
if (!stat.isDirectory()) throw new Error(`Not a directory: ${target}`);
|
|
146
158
|
|
|
159
|
+
// Perf: directories need no stat at all (Dirent already tells us the kind).
|
|
160
|
+
// Files need one statSync for size/mtime; symlinks need one statSync (which
|
|
161
|
+
// follows the link) to resolve their target kind. This replaces the previous
|
|
162
|
+
// lstat + realpath + stat triple per entry.
|
|
147
163
|
const entries = readdirSync(target, { withFileTypes: true })
|
|
148
|
-
.map((entry): FileEntry
|
|
164
|
+
.map((entry): FileEntry => {
|
|
149
165
|
const entryPath = join(target, entry.name);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
166
|
+
const isLink = entry.isSymbolicLink();
|
|
167
|
+
const result: FileEntry = { name: entry.name, path: entryPath, type: "file" };
|
|
168
|
+
if (isLink) result.symlink = true;
|
|
169
|
+
|
|
170
|
+
if (isLink) {
|
|
171
|
+
try {
|
|
172
|
+
const st = statSync(entryPath); // follows the link
|
|
173
|
+
result.type = st.isDirectory() ? "directory" : "file";
|
|
174
|
+
if (st.isFile()) result.size = st.size;
|
|
175
|
+
result.modifiedAt = st.mtimeMs;
|
|
176
|
+
} catch {
|
|
177
|
+
result.type = "symlink"; // broken link — surface it rather than hide it
|
|
178
|
+
try { result.modifiedAt = lstatSync(entryPath).mtimeMs; } catch { /* ignore */ }
|
|
179
|
+
}
|
|
180
|
+
} else if (entry.isDirectory()) {
|
|
181
|
+
result.type = "directory";
|
|
182
|
+
} else {
|
|
183
|
+
try {
|
|
184
|
+
const st = statSync(entryPath);
|
|
185
|
+
result.size = st.size;
|
|
186
|
+
result.modifiedAt = st.mtimeMs;
|
|
187
|
+
} catch { /* entry vanished mid-listing */ }
|
|
162
188
|
}
|
|
163
|
-
return
|
|
164
|
-
name: entry.name,
|
|
165
|
-
path: entryPath,
|
|
166
|
-
type,
|
|
167
|
-
...(statForSize.isFile() ? { size: statForSize.size } : {}),
|
|
168
|
-
modifiedAt: statForSize.mtimeMs,
|
|
169
|
-
};
|
|
189
|
+
return result;
|
|
170
190
|
})
|
|
171
|
-
.filter((entry): entry is FileEntry => Boolean(entry))
|
|
172
191
|
.sort((a, b) => {
|
|
173
192
|
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
174
193
|
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
175
194
|
return a.name.localeCompare(b.name);
|
|
176
195
|
});
|
|
177
196
|
|
|
197
|
+
if (options.git) applyGitStatus(target, entries);
|
|
198
|
+
|
|
178
199
|
return {
|
|
179
200
|
path: target,
|
|
180
201
|
parent: parentInsideBase(target, base),
|
|
@@ -183,6 +204,59 @@ export function listFiles(requestedPath: string | undefined, baseDir: string): F
|
|
|
183
204
|
};
|
|
184
205
|
}
|
|
185
206
|
|
|
207
|
+
/** Overlay working-tree git status onto directory entries (best-effort, never throws). */
|
|
208
|
+
function applyGitStatus(target: string, entries: FileEntry[]): void {
|
|
209
|
+
const root = runGitIn(["rev-parse", "--show-toplevel"], target);
|
|
210
|
+
if (root === null) return; // not a git repo
|
|
211
|
+
|
|
212
|
+
// `--no-renames` reports renames as delete+add, sidestepping the two-path -z
|
|
213
|
+
// parse. The `.` pathspec scopes output to this directory's subtree.
|
|
214
|
+
const raw = runGitIn(["status", "--porcelain", "-z", "--no-renames", "--", "."], target);
|
|
215
|
+
if (!raw) return;
|
|
216
|
+
|
|
217
|
+
// name -> { staged, unstaged, untracked }, aggregated across descendants so a
|
|
218
|
+
// directory inherits the status of files changed beneath it.
|
|
219
|
+
const agg = new Map<string, { staged: boolean; unstaged: boolean; untracked: boolean }>();
|
|
220
|
+
for (const token of raw.split("\0")) {
|
|
221
|
+
if (token.length < 4) continue;
|
|
222
|
+
const x = token[0];
|
|
223
|
+
const y = token[1];
|
|
224
|
+
const p = token.slice(3);
|
|
225
|
+
const abs = resolve(root, p);
|
|
226
|
+
const rel = relative(target, abs);
|
|
227
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) continue;
|
|
228
|
+
const name = (rel.split("/")[0] ?? "").replace(/\/$/, "");
|
|
229
|
+
if (!name) continue;
|
|
230
|
+
const cur = agg.get(name) ?? { staged: false, unstaged: false, untracked: false };
|
|
231
|
+
if (x === "?" && y === "?") cur.untracked = true;
|
|
232
|
+
else {
|
|
233
|
+
if (x !== " " && x !== "?") cur.staged = true;
|
|
234
|
+
if (y !== " " && y !== "?") cur.unstaged = true;
|
|
235
|
+
}
|
|
236
|
+
agg.set(name, cur);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
const a = agg.get(entry.name);
|
|
241
|
+
if (!a) continue;
|
|
242
|
+
if (a.staged && a.unstaged) entry.gitStatus = "staged-unstaged";
|
|
243
|
+
else if (a.staged) entry.gitStatus = "staged";
|
|
244
|
+
else if (a.unstaged) entry.gitStatus = "unstaged";
|
|
245
|
+
else if (a.untracked) entry.gitStatus = "untracked";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Run git in `cwd`, returning trimmed stdout, or null on failure. */
|
|
250
|
+
function runGitIn(args: string[], cwd: string): string | null {
|
|
251
|
+
try {
|
|
252
|
+
const proc = Bun.spawnSync(["git", "-C", cwd, ...args], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
|
|
253
|
+
if (proc.exitCode !== 0) return null;
|
|
254
|
+
return proc.stdout.toString().replace(/\n+$/, "");
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
186
260
|
export function statFilePath(requestedPath: string | undefined, baseDir: string): FileStatResult {
|
|
187
261
|
if (!requestedPath) throw new Error("path is required");
|
|
188
262
|
const { base, target } = resolveInsideBase(requestedPath, baseDir);
|
|
@@ -198,20 +272,23 @@ export function statFilePath(requestedPath: string | undefined, baseDir: string)
|
|
|
198
272
|
};
|
|
199
273
|
}
|
|
200
274
|
|
|
275
|
+
function readBytes(target: string, count: number): Buffer {
|
|
276
|
+
const buffer = Buffer.alloc(count);
|
|
277
|
+
const fd = openSync(target, "r");
|
|
278
|
+
try {
|
|
279
|
+
readSync(fd, buffer, 0, count, 0);
|
|
280
|
+
} finally {
|
|
281
|
+
closeSync(fd);
|
|
282
|
+
}
|
|
283
|
+
return buffer;
|
|
284
|
+
}
|
|
285
|
+
|
|
201
286
|
export function readFilePreview(requestedPath: string | undefined, baseDir: string): FileReadResult {
|
|
202
287
|
if (!requestedPath) throw new Error("path is required");
|
|
203
288
|
const { target } = resolveInsideBase(requestedPath, baseDir);
|
|
204
289
|
const stat = statSync(target);
|
|
205
290
|
if (!stat.isFile()) throw new Error(`Not a file: ${target}`);
|
|
206
291
|
|
|
207
|
-
const bytesToRead = Math.min(stat.size, MAX_FILE_PREVIEW_BYTES);
|
|
208
|
-
const buffer = Buffer.alloc(bytesToRead);
|
|
209
|
-
const fd = openSync(target, "r");
|
|
210
|
-
try {
|
|
211
|
-
readSync(fd, buffer, 0, bytesToRead, 0);
|
|
212
|
-
} finally {
|
|
213
|
-
closeSync(fd);
|
|
214
|
-
}
|
|
215
292
|
const mediaType = mediaTypeForPath(target);
|
|
216
293
|
const resultBase = {
|
|
217
294
|
path: target,
|
|
@@ -219,15 +296,28 @@ export function readFilePreview(requestedPath: string | undefined, baseDir: stri
|
|
|
219
296
|
mediaType,
|
|
220
297
|
size: stat.size,
|
|
221
298
|
modifiedAt: stat.mtimeMs,
|
|
222
|
-
truncated: stat.size > MAX_FILE_PREVIEW_BYTES,
|
|
223
299
|
};
|
|
224
300
|
|
|
301
|
+
// Images are returned as base64 so the dashboard can render them inline. They
|
|
302
|
+
// must be read whole — a truncated image would be corrupt — so they get a
|
|
303
|
+
// higher cap and fall back to a plain binary marker when too large.
|
|
304
|
+
if (mediaType.startsWith("image/")) {
|
|
305
|
+
if (stat.size > MAX_IMAGE_PREVIEW_BYTES) {
|
|
306
|
+
return { ...resultBase, truncated: true, encoding: "binary" };
|
|
307
|
+
}
|
|
308
|
+
return { ...resultBase, truncated: false, encoding: "base64", content: readBytes(target, stat.size).toString("base64") };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const buffer = readBytes(target, Math.min(stat.size, MAX_FILE_PREVIEW_BYTES));
|
|
312
|
+
const truncated = stat.size > MAX_FILE_PREVIEW_BYTES;
|
|
313
|
+
|
|
225
314
|
if (isBinaryBuffer(buffer)) {
|
|
226
|
-
return { ...resultBase, encoding: "binary" };
|
|
315
|
+
return { ...resultBase, truncated, encoding: "binary" };
|
|
227
316
|
}
|
|
228
317
|
|
|
229
318
|
return {
|
|
230
319
|
...resultBase,
|
|
320
|
+
truncated,
|
|
231
321
|
encoding: "utf8",
|
|
232
322
|
content: new TextDecoder("utf-8", { fatal: true }).decode(buffer),
|
|
233
323
|
};
|
|
@@ -255,6 +345,14 @@ function mediaTypeForPath(path: string): string {
|
|
|
255
345
|
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return "text/javascript";
|
|
256
346
|
if (ext === ".ts" || ext === ".tsx" || ext === ".jsx") return "text/typescript";
|
|
257
347
|
if (ext === ".txt" || ext === ".log" || ext === ".sh" || name.startsWith(".env")) return "text/plain";
|
|
348
|
+
if (ext === ".png") return "image/png";
|
|
349
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
350
|
+
if (ext === ".gif") return "image/gif";
|
|
351
|
+
if (ext === ".webp") return "image/webp";
|
|
352
|
+
if (ext === ".svg") return "image/svg+xml";
|
|
353
|
+
if (ext === ".bmp") return "image/bmp";
|
|
354
|
+
if (ext === ".ico") return "image/x-icon";
|
|
355
|
+
if (ext === ".avif") return "image/avif";
|
|
258
356
|
return "application/octet-stream";
|
|
259
357
|
}
|
|
260
358
|
|
|
@@ -276,7 +374,7 @@ function authorized(req: Request, config: OrchestratorConfig): boolean {
|
|
|
276
374
|
return req.headers.get("x-agent-relay-token") === config.token;
|
|
277
375
|
}
|
|
278
376
|
|
|
279
|
-
export function startApiServer(config: OrchestratorConfig, probeCache: ProviderProbeCache): { stop(): void; url: string } {
|
|
377
|
+
export function startApiServer(config: OrchestratorConfig, probeCache: ProviderProbeCache, relay?: RelayClient): { stop(): void; url: string } {
|
|
280
378
|
const server = Bun.serve<TerminalSocketData>({
|
|
281
379
|
port: config.apiPort,
|
|
282
380
|
hostname: "0.0.0.0",
|
|
@@ -293,6 +391,22 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
|
|
|
293
391
|
});
|
|
294
392
|
}
|
|
295
393
|
|
|
394
|
+
// Runner self-heal: a runner whose runtime token expired proxies it here to
|
|
395
|
+
// get a fresh one. No orchestrator-token auth (the runner doesn't hold it) —
|
|
396
|
+
// the relay is the gate: it only re-mints a genuine, non-revoked runner token
|
|
397
|
+
// owned by this orchestrator. The signed (even expired) token is the identity.
|
|
398
|
+
if (req.method === "POST" && url.pathname === "/api/runtime-tokens/runner-renew") {
|
|
399
|
+
if (!relay) return error("re-mint unavailable", 503);
|
|
400
|
+
const body = await req.json().catch(() => null);
|
|
401
|
+
const token = body && typeof body === "object" && typeof (body as { token?: unknown }).token === "string"
|
|
402
|
+
? (body as { token: string }).token
|
|
403
|
+
: "";
|
|
404
|
+
if (!token) return error("token required", 400);
|
|
405
|
+
const reminted = await relay.remintRunnerToken(token);
|
|
406
|
+
if (!reminted) return error("runner token re-mint failed", 502);
|
|
407
|
+
return json(reminted);
|
|
408
|
+
}
|
|
409
|
+
|
|
296
410
|
if (url.pathname === "/api/artifacts" || url.pathname.startsWith("/api/artifacts/")) {
|
|
297
411
|
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
298
412
|
return proxyArtifactRequest(req, config).catch((e) => error((e as Error).message, 502));
|
|
@@ -321,7 +435,8 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
|
|
|
321
435
|
if (req.method === "GET" && url.pathname === "/api/files/list") {
|
|
322
436
|
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
323
437
|
try {
|
|
324
|
-
|
|
438
|
+
const git = url.searchParams.get("git") === "1";
|
|
439
|
+
return json(listFiles(url.searchParams.get("path") || undefined, config.baseDir, { git }));
|
|
325
440
|
} catch (e) {
|
|
326
441
|
return error((e as Error).message);
|
|
327
442
|
}
|
|
@@ -394,6 +509,7 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
|
|
|
394
509
|
baseRef: url.searchParams.get("baseRef") || undefined,
|
|
395
510
|
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
396
511
|
strategy: strategy === "pr" || strategy === "rebase-ff" || strategy === "auto" ? strategy : undefined,
|
|
512
|
+
checkPr: url.searchParams.get("checkPr") === "1",
|
|
397
513
|
}));
|
|
398
514
|
} catch (e) {
|
|
399
515
|
return error((e as Error).message);
|
package/src/control.ts
CHANGED
|
@@ -33,10 +33,26 @@ export function createControlHandler(
|
|
|
33
33
|
async function handleShutdown(ctrl: Record<string, any>, restart = false): Promise<Record<string, unknown>> {
|
|
34
34
|
const current = managedAgentShutdownTarget(managedAgents, ctrl);
|
|
35
35
|
const session = current?.sessionName ?? current?.tmuxSession;
|
|
36
|
-
|
|
36
|
+
const restartSpawn = isRecord(ctrl.restartSpawn) ? ctrl.restartSpawn : undefined;
|
|
37
|
+
if (!session) {
|
|
38
|
+
let restarted: ManagedAgentReport | undefined;
|
|
39
|
+
if (restart && restartSpawn) {
|
|
40
|
+
restarted = await spawnAgent(spawnOptionsFromRestartSource(restartSpawn, config), config);
|
|
41
|
+
managedAgents.push(restarted);
|
|
42
|
+
console.error(`[orchestrator] Restarted ${restarted.provider} agent without prior live session: ${restarted.tmuxSession}`);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
stopped: false,
|
|
46
|
+
wasRunning: false,
|
|
47
|
+
restart,
|
|
48
|
+
restarted: Boolean(restarted),
|
|
49
|
+
...(restarted ? { agent: restarted } : {}),
|
|
50
|
+
policyName: ctrl.policyName,
|
|
51
|
+
spawnRequestId: ctrl.spawnRequestId,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
37
54
|
const result = await stopSession(session, config, typeof ctrl.reason === "string" ? ctrl.reason : restart ? "restart" : "shutdown", ctrl.graceful !== false, shutdownTimeoutMs(ctrl));
|
|
38
55
|
managedAgents = managedAgents.filter((agent) => (agent.sessionName ?? agent.tmuxSession) !== session);
|
|
39
|
-
const restartSpawn = isRecord(ctrl.restartSpawn) ? ctrl.restartSpawn : undefined;
|
|
40
56
|
// A managed restart carries a fresh spawnRequestId in restartSpawn — keep it.
|
|
41
57
|
// Falling back to the live agent's params would reuse the stale id and break
|
|
42
58
|
// relay correlation, so drop it and let spawnAgent assign a new identity.
|
package/src/index.ts
CHANGED
|
@@ -63,7 +63,7 @@ async function startup(): Promise<void> {
|
|
|
63
63
|
console.error(`[orchestrator] env keys: ${Object.keys(config.env).length}`);
|
|
64
64
|
|
|
65
65
|
// Start API server before registration so we can advertise the URL
|
|
66
|
-
apiServer = startApiServer(config, probeCache);
|
|
66
|
+
apiServer = startApiServer(config, probeCache, relay);
|
|
67
67
|
console.error(`[orchestrator] apiUrl: ${apiServer.url}`);
|
|
68
68
|
|
|
69
69
|
// Register with relay. The server and orchestrator are often restarted
|
package/src/relay.ts
CHANGED
|
@@ -13,9 +13,15 @@ export interface RelayClient {
|
|
|
13
13
|
setApiUrl(url: string): void;
|
|
14
14
|
startHeartbeatLoop(): void;
|
|
15
15
|
stopHeartbeatLoop(): void;
|
|
16
|
+
remintRunnerToken(currentToken: string): Promise<RunnerTokenRemint | null>;
|
|
16
17
|
connected: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
export interface RunnerTokenRemint {
|
|
21
|
+
token: string;
|
|
22
|
+
record: { jti: string; profileId?: string; expiresAt?: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
export interface ManagedAgentReport {
|
|
20
26
|
agentId: string;
|
|
21
27
|
provider: "claude" | "codex";
|
|
@@ -268,6 +274,18 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
268
274
|
setApiUrl(url: string) { apiUrl = url; },
|
|
269
275
|
startHeartbeatLoop,
|
|
270
276
|
stopHeartbeatLoop,
|
|
277
|
+
// Proxy a runner's expired token to the relay using our (long-lived)
|
|
278
|
+
// orchestrator credential, returning a freshly minted runner token. Lets a
|
|
279
|
+
// live runner self-heal an expired token instead of stranding the session.
|
|
280
|
+
async remintRunnerToken(currentToken: string): Promise<RunnerTokenRemint | null> {
|
|
281
|
+
try {
|
|
282
|
+
const res = await apiCall("POST", `/orchestrators/${config.id}/runner-token`, { token: currentToken });
|
|
283
|
+
if (!res.ok) return null;
|
|
284
|
+
return await res.json() as RunnerTokenRemint;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
},
|
|
271
289
|
get connected() { return connected; },
|
|
272
290
|
};
|
|
273
291
|
}
|
package/src/workspace-probe.ts
CHANGED
|
@@ -490,6 +490,34 @@ function ghAvailable(): boolean {
|
|
|
490
490
|
return Boolean(Bun.which("gh"));
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Ground-truth merge state for the branch checked out at `worktreePath`, via gh.
|
|
495
|
+
* This is the only reliable signal once a PR is squash-merged AND base has moved
|
|
496
|
+
* on: the squash re-creates the work as one new commit (patch ids no longer
|
|
497
|
+
* match, so `git cherry` can't see it) and the trees diverge (so tree-equality
|
|
498
|
+
* can't either), leaving local git convinced the branch is still unmerged.
|
|
499
|
+
* Returns undefined when there's no PR, no detached/unknown branch, or gh fails —
|
|
500
|
+
* we never invent a merge.
|
|
501
|
+
*/
|
|
502
|
+
function prMergedState(worktreePath: string): { state: "merged" | "open"; url?: string } | undefined {
|
|
503
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
|
|
504
|
+
if (!branch.ok || !branch.stdout || branch.stdout === "HEAD") return undefined;
|
|
505
|
+
const proc = Bun.spawnSync(["gh", "pr", "view", branch.stdout, "--json", "state,url"], {
|
|
506
|
+
cwd: worktreePath,
|
|
507
|
+
stdin: "ignore",
|
|
508
|
+
stdout: "pipe",
|
|
509
|
+
stderr: "pipe",
|
|
510
|
+
});
|
|
511
|
+
if (proc.exitCode !== 0) return undefined; // no PR for the branch, or gh unavailable/unauthed
|
|
512
|
+
try {
|
|
513
|
+
const data = JSON.parse(proc.stdout.toString()) as { state?: string; url?: string };
|
|
514
|
+
if (!data.state) return undefined;
|
|
515
|
+
return { state: data.state === "MERGED" ? "merged" : "open", url: data.url };
|
|
516
|
+
} catch {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
493
521
|
/**
|
|
494
522
|
* Predict whether merging the branch's commits into base would conflict, using
|
|
495
523
|
* git's three-way merge-tree (no working-tree changes). Exit 0 = clean, exit 1
|
|
@@ -524,7 +552,7 @@ function worktreeForBranch(repoRoot: string, branch: string): { path: string; di
|
|
|
524
552
|
* `auto` would pick plus whether the merge is clean, would conflict, or is a
|
|
525
553
|
* no-op — so the dashboard can warn before the user commits to it.
|
|
526
554
|
*/
|
|
527
|
-
export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto" }): WorkspaceMergePreview {
|
|
555
|
+
export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto"; checkPr?: boolean }): WorkspaceMergePreview {
|
|
528
556
|
const gitState = workspaceGitState(input);
|
|
529
557
|
const remote = input.worktreePath ? hasOriginRemote(resolve(input.worktreePath)) : false;
|
|
530
558
|
const gh = ghAvailable();
|
|
@@ -543,9 +571,28 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
|
|
|
543
571
|
base.behind = gitState.behind;
|
|
544
572
|
base.dirtyCount = gitState.dirtyCount;
|
|
545
573
|
if ((gitState.dirtyCount ?? 0) > 0) return { ...base, reason: "worktree has uncommitted changes" };
|
|
546
|
-
|
|
574
|
+
let effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0);
|
|
575
|
+
// Local git still thinks there's unmerged work, but a squash-merged PR whose
|
|
576
|
+
// base has moved on is invisible to it. When the caller opts in (checkPr) and
|
|
577
|
+
// the strategy targets a PR, ask gh for the truth — a MERGED PR means landed.
|
|
578
|
+
if (effectiveAhead > 0 && input.checkPr && strategy === "pr" && input.worktreePath) {
|
|
579
|
+
const pr = prMergedState(resolve(input.worktreePath));
|
|
580
|
+
if (pr) {
|
|
581
|
+
base.prState = pr.state;
|
|
582
|
+
if (pr.url) base.prUrl = pr.url;
|
|
583
|
+
if (pr.state === "merged") {
|
|
584
|
+
base.prMerged = true;
|
|
585
|
+
effectiveAhead = 0;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
547
589
|
if (effectiveAhead === 0) {
|
|
548
|
-
|
|
590
|
+
const reason = base.prMerged
|
|
591
|
+
? "PR merged on remote"
|
|
592
|
+
: gitState.landed
|
|
593
|
+
? "already merged into base (squash/cherry-pick)"
|
|
594
|
+
: "no commits to merge";
|
|
595
|
+
return { ...base, reason };
|
|
549
596
|
}
|
|
550
597
|
if (gitState.baseRef && input.worktreePath) {
|
|
551
598
|
const conflict = predictConflict(resolve(input.worktreePath), gitState.baseRef);
|