agent-relay-orchestrator 0.78.9 → 0.79.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.78.9",
3
+ "version": "0.79.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.58"
19
+ "agent-relay-sdk": "0.2.59"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/api.ts CHANGED
@@ -9,7 +9,7 @@ import type { RelayClient } from "./relay";
9
9
  import { captureSession, captureSessionMirror, captureTerminal, createTerminalGuest, listSessions, sendTerminalInput, resizeTerminal, stopTerminalGuest, validateTerminalInputData, validateTerminalResize } from "./spawn";
10
10
  import { acquireTerminalStream, type TerminalStreamHandle, type TerminalStreamSubscriber } from "./terminal-stream";
11
11
  import { VERSION, runtimeMetadata } from "./version";
12
- import { previewBranchMerge, previewWorkspaceMerge, probeWorkspace, workspaceDiff, workspaceGitState } from "./workspace-probe";
12
+ import { branchMergePreviewResponse, mergePreviewResponse, recoveryBranchesResponse, probeWorkspace, workspaceDiff, workspaceGitState } from "./workspace-probe";
13
13
 
14
14
  interface DirectoryEntry {
15
15
  name: string;
@@ -506,39 +506,17 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
506
506
 
507
507
  if (req.method === "GET" && url.pathname === "/api/workspace/merge-preview") {
508
508
  if (!authorized(req, config)) return error("unauthorized", 401);
509
- try {
510
- const { target } = resolveInsideBase(url.searchParams.get("path") || undefined, config.baseDir);
511
- const strategy = url.searchParams.get("strategy");
512
- return json(previewWorkspaceMerge({
513
- worktreePath: target,
514
- baseRef: url.searchParams.get("baseRef") || undefined,
515
- baseSha: url.searchParams.get("baseSha") || undefined,
516
- strategy: strategy === "pr" || strategy === "rebase-ff" || strategy === "auto" ? strategy : undefined,
517
- checkPr: url.searchParams.get("checkPr") === "1",
518
- }));
519
- } catch (e) {
520
- return error((e as Error).message);
521
- }
509
+ return mergePreviewResponse(url, config.baseDir);
522
510
  }
523
511
 
524
512
  if (req.method === "GET" && url.pathname === "/api/workspace/branch-merge-preview") {
525
513
  if (!authorized(req, config)) return error("unauthorized", 401);
526
- try {
527
- const { target } = resolveInsideBase(url.searchParams.get("repoRoot") || undefined, config.baseDir);
528
- const strategy = url.searchParams.get("strategy");
529
- const preview = previewBranchMerge({
530
- repoRoot: target,
531
- branch: url.searchParams.get("branch") || undefined,
532
- baseRef: url.searchParams.get("baseRef") || undefined,
533
- baseSha: url.searchParams.get("baseSha") || undefined,
534
- strategy: strategy === "pr" || strategy === "rebase-ff" || strategy === "auto" ? strategy : undefined,
535
- checkPr: url.searchParams.get("checkPr") === "1",
536
- });
537
- if (preview === null) return error("branch not found", 404);
538
- return json(preview);
539
- } catch (e) {
540
- return error((e as Error).message);
541
- }
514
+ return branchMergePreviewResponse(url, config.baseDir);
515
+ }
516
+
517
+ if (req.method === "GET" && url.pathname === "/api/workspace/recovery-branches") {
518
+ if (!authorized(req, config)) return error("unauthorized", 401);
519
+ return recoveryBranchesResponse(url, config.baseDir);
542
520
  }
543
521
 
544
522
  if (req.method === "GET" && url.pathname === "/api/providers") {
package/src/control.ts CHANGED
@@ -4,7 +4,7 @@ import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
4
4
  import { handleSelfUpgrade } from "./self-upgrade";
5
5
  import { readLocalProviderConfigs } from "./provider-config-migration";
6
6
  import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
7
- import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
7
+ import { cleanupWorkspace, discardRecoveryBranch, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
8
8
  import { armWorkspacePrAutoMerge, mergeWorkspacePr, refreshWorkspacePrBranch } from "./workspace-pr";
9
9
 
10
10
  interface ControlHandler {
@@ -173,6 +173,15 @@ export function createControlHandler(
173
173
  { checkOnly: command.params.checkOnly === true },
174
174
  );
175
175
  await relay.updateCommand(command.id, "succeeded", { workspaceId: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined, ...result });
176
+ } else if (command.type === "workspace.recovery-branch-discard") {
177
+ const result = discardRecoveryBranch({
178
+ repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
179
+ branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
180
+ baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
181
+ baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
182
+ force: command.params.force === true,
183
+ });
184
+ await relay.updateCommand(command.id, "succeeded", result as unknown as Record<string, unknown>);
176
185
  } else if (command.type === "workspace.prune") {
177
186
  const result = pruneWorktrees({
178
187
  repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
@@ -5,4 +5,6 @@ export * from "./merge";
5
5
  export * from "./names";
6
6
  export * from "./parse";
7
7
  export * from "./probe";
8
+ export * from "./recovery-branches";
9
+ export * from "./request";
8
10
  export type { WorkspaceMergeInput, WorkspaceResolution, WorkspaceResolutionInput } from "./types";
@@ -7,6 +7,7 @@ import { refreshWorkspaceDeps } from "./deps";
7
7
  import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
8
8
  import { nextBranchName } from "./names";
9
9
  import { parseWorktrees, shortBranch } from "./parse";
10
+ import { json, resolveRequestedPath } from "./request";
10
11
  import type { WorkspaceMergeInput } from "./types";
11
12
  import { workspacePushEnabled } from "../config";
12
13
 
@@ -128,6 +129,38 @@ export function previewBranchMerge(input: {
128
129
  return base;
129
130
  }
130
131
 
132
+ export function mergePreviewResponse(url: URL, baseDir: string): Response {
133
+ try {
134
+ const strategy = url.searchParams.get("strategy");
135
+ return json(previewWorkspaceMerge({
136
+ worktreePath: resolveRequestedPath(url.searchParams.get("path") || undefined, baseDir),
137
+ baseRef: url.searchParams.get("baseRef") || undefined,
138
+ baseSha: url.searchParams.get("baseSha") || undefined,
139
+ strategy: validPreviewStrategy(strategy),
140
+ checkPr: url.searchParams.get("checkPr") === "1",
141
+ }));
142
+ } catch (e) {
143
+ return json({ error: (e as Error).message }, 400);
144
+ }
145
+ }
146
+
147
+ export function branchMergePreviewResponse(url: URL, baseDir: string): Response {
148
+ try {
149
+ const strategy = url.searchParams.get("strategy");
150
+ const preview = previewBranchMerge({
151
+ repoRoot: resolveRequestedPath(url.searchParams.get("repoRoot") || undefined, baseDir),
152
+ branch: url.searchParams.get("branch") || undefined,
153
+ baseRef: url.searchParams.get("baseRef") || undefined,
154
+ baseSha: url.searchParams.get("baseSha") || undefined,
155
+ strategy: validPreviewStrategy(strategy),
156
+ checkPr: url.searchParams.get("checkPr") === "1",
157
+ });
158
+ return preview === null ? json({ error: "branch not found" }, 404) : json(preview);
159
+ } catch (e) {
160
+ return json({ error: (e as Error).message }, 400);
161
+ }
162
+ }
163
+
131
164
  /**
132
165
  * Read-only pre-flight for integrating a workspace's work. Reports the strategy
133
166
  * `auto` would pick plus whether the merge is clean, would conflict, or is a
@@ -177,6 +210,10 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
177
210
  return base;
178
211
  }
179
212
 
213
+ function validPreviewStrategy(strategy: string | null): "pr" | "rebase-ff" | "auto" | undefined {
214
+ return strategy === "pr" || strategy === "rebase-ff" || strategy === "auto" ? strategy : undefined;
215
+ }
216
+
180
217
  /**
181
218
  * Integrate a workspace's work back into its base branch. Two strategies:
182
219
  * - rebase-ff: rebase the agent branch onto base, fast-forward base to it,
@@ -0,0 +1,124 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { WorkspaceRecoveryBranch, WorkspaceRecoveryBranchDiscardResult } from "agent-relay-sdk";
4
+ import { git } from "../git";
5
+ import { populateMergeState } from "./git-state";
6
+ import { json, resolveRequestedPath } from "./request";
7
+
8
+ interface BranchSafety {
9
+ safe: boolean;
10
+ reason?: string;
11
+ ahead?: number;
12
+ unmergedAhead?: number;
13
+ landed?: boolean;
14
+ baseRef?: string;
15
+ }
16
+
17
+ export function listRecoveryBranches(input: { repoRoot?: string; baseRef?: string; baseSha?: string; now?: number }): { repoRoot?: string; branches: WorkspaceRecoveryBranch[]; error?: string } {
18
+ if (!input.repoRoot) return { branches: [], error: "repoRoot required" };
19
+ const repoRoot = resolve(input.repoRoot);
20
+ if (!existsSync(repoRoot)) return { repoRoot, branches: [], error: `repoRoot does not exist: ${repoRoot}` };
21
+
22
+ const refs = git(["for-each-ref", "--format=%(refname:short)%00%(objectname)%00%(committerdate:unix)%00%(subject)", "refs/heads/agent"], repoRoot);
23
+ if (!refs.ok) return { repoRoot, branches: [], error: refs.stderr || "branch listing failed" };
24
+
25
+ const now = input.now ?? Date.now();
26
+ const branches = refs.stdout.split("\n").filter(Boolean).map((line): WorkspaceRecoveryBranch | null => {
27
+ const [branch, headSha, unix, ...subjectParts] = line.split("\0");
28
+ if (!branch?.startsWith("agent/")) return null;
29
+ const safety = recoveryBranchSafety(repoRoot, branch, input.baseRef, input.baseSha);
30
+ const at = Number(unix) * 1000;
31
+ const lastCommit = headSha
32
+ ? { sha: headSha, message: subjectParts.join("\0"), ...(Number.isFinite(at) ? { at } : {}) }
33
+ : undefined;
34
+ return {
35
+ repoRoot,
36
+ branch,
37
+ headSha,
38
+ ...(safety.baseRef ? { baseRef: safety.baseRef } : {}),
39
+ ...(input.baseSha ? { baseSha: input.baseSha } : {}),
40
+ ...(safety.ahead !== undefined ? { ahead: safety.ahead } : {}),
41
+ ...(safety.unmergedAhead !== undefined ? { unmergedAhead: safety.unmergedAhead } : {}),
42
+ ...(safety.landed !== undefined ? { landed: safety.landed } : {}),
43
+ ...(lastCommit ? { lastCommit } : {}),
44
+ ...(lastCommit?.at ? { ageMs: Math.max(0, now - lastCommit.at) } : {}),
45
+ safeToDelete: safety.safe,
46
+ ...(safety.reason ? { preserveReason: safety.reason } : {}),
47
+ };
48
+ }).filter((branch): branch is WorkspaceRecoveryBranch => Boolean(branch));
49
+
50
+ branches.sort((a, b) => (b.ageMs ?? 0) - (a.ageMs ?? 0) || a.branch.localeCompare(b.branch));
51
+ return { repoRoot, branches };
52
+ }
53
+
54
+ export function recoveryBranchesResponse(url: URL, baseDir: string): Response {
55
+ try {
56
+ return json(listRecoveryBranches({
57
+ repoRoot: resolveRequestedPath(url.searchParams.get("repoRoot") || undefined, baseDir),
58
+ baseRef: url.searchParams.get("baseRef") || undefined,
59
+ baseSha: url.searchParams.get("baseSha") || undefined,
60
+ }));
61
+ } catch (e) {
62
+ return json({ error: (e as Error).message }, 400);
63
+ }
64
+ }
65
+
66
+ export function discardRecoveryBranch(input: { repoRoot?: string; branch?: string; baseRef?: string; baseSha?: string; force?: boolean }): WorkspaceRecoveryBranchDiscardResult {
67
+ if (!input.repoRoot) throw new Error("repoRoot required");
68
+ if (!input.branch) throw new Error("branch required");
69
+ if (!input.branch.startsWith("agent/")) throw new Error("only agent/* branches can be discarded through recovery cleanup");
70
+ const repoRoot = resolve(input.repoRoot);
71
+ const safety = recoveryBranchSafety(repoRoot, input.branch, input.baseRef, input.baseSha);
72
+ if (!safety.safe && input.force !== true) {
73
+ throw new Error(`branch is not safe to delete: ${safety.reason ?? "land-state unavailable"}`);
74
+ }
75
+ const head = git(["rev-parse", "--verify", "--quiet", `${input.branch}^{commit}`], repoRoot);
76
+ const deleted = git(["branch", "-D", input.branch], repoRoot);
77
+ if (!deleted.ok) throw new Error(deleted.stderr || "branch delete failed");
78
+ return {
79
+ repoRoot,
80
+ branch: input.branch,
81
+ branchDeleted: true,
82
+ ...(input.force === true ? { forced: true } : {}),
83
+ ...(safety.reason ? { preserveReason: safety.reason } : {}),
84
+ ...(head.stdout ? { headSha: head.stdout } : {}),
85
+ };
86
+ }
87
+
88
+ function recoveryBranchSafety(repoRoot: string, branch: string, baseRef?: string, baseSha?: string): BranchSafety {
89
+ const branchRef = resolveBranchRef(repoRoot, branch);
90
+ if (!branchRef) return { safe: true, reason: "branch ref already gone" };
91
+ const base = resolveRecoveryBase(repoRoot, baseRef, baseSha);
92
+ if (!base) return { safe: false, reason: "base ref unavailable" };
93
+
94
+ const gitState = populateMergeState(repoRoot, branchRef, { dirty: false, dirtyCount: 0, branch }, base, baseSha);
95
+ const ahead = gitState.ahead;
96
+ const unmergedAhead = gitState.unmergedAhead;
97
+ if (gitState.error) return { safe: false, reason: gitState.error, baseRef: gitState.baseRef ?? base };
98
+ if (!gitState.baseRef) return { safe: false, reason: "base ref unavailable", baseRef: base };
99
+ const effectiveAhead = gitState.landed ? 0 : (unmergedAhead ?? ahead);
100
+ if (effectiveAhead === undefined) return { safe: false, reason: "ahead count unavailable", baseRef: gitState.baseRef };
101
+ if (effectiveAhead === 0) return { safe: true, ahead, unmergedAhead, landed: gitState.landed, baseRef: gitState.baseRef };
102
+ return {
103
+ safe: false,
104
+ reason: `${effectiveAhead} unlanded commit(s)`,
105
+ ahead,
106
+ unmergedAhead,
107
+ landed: gitState.landed,
108
+ baseRef: gitState.baseRef,
109
+ };
110
+ }
111
+
112
+ function resolveRecoveryBase(repoRoot: string, baseRef?: string, baseSha?: string): string | undefined {
113
+ for (const candidate of [baseRef, baseSha, "main", "master"]) {
114
+ if (candidate && git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], repoRoot).ok) return candidate;
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function resolveBranchRef(repoRoot: string, branch: string): string | undefined {
120
+ for (const candidate of [branch, `refs/heads/${branch}`]) {
121
+ if (git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], repoRoot).ok) return candidate;
122
+ }
123
+ return undefined;
124
+ }
@@ -0,0 +1,22 @@
1
+ import { statSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+
4
+ export function resolveRequestedPath(requestedPath: string | undefined, baseDir: string): string {
5
+ const base = resolve(baseDir);
6
+ const target = resolve(requestedPath || base);
7
+ const rel = relative(base, target);
8
+ if (rel && (rel.startsWith("..") || isAbsolute(rel))) throw new Error(`Path must be within baseDir: ${baseDir}`);
9
+ try {
10
+ statSync(target);
11
+ } catch {
12
+ throw new Error(`Path does not exist: ${target}`);
13
+ }
14
+ return target;
15
+ }
16
+
17
+ export function json(data: unknown, status = 200): Response {
18
+ return new Response(JSON.stringify(data), {
19
+ status,
20
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
21
+ });
22
+ }