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/src/version.ts
CHANGED
|
@@ -3,8 +3,37 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
5
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { version?: string };
|
|
6
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
|
|
7
7
|
|
|
8
|
+
export const PACKAGE_NAME = pkg.name || "agent-relay-orchestrator";
|
|
8
9
|
export const VERSION = pkg.version || "0.0.0";
|
|
9
10
|
export const ORCHESTRATOR_PROTOCOL_VERSION = 3;
|
|
11
|
+
export const RUNNER_PROTOCOL_VERSION = 1;
|
|
12
|
+
export const PROVIDER_PLUGIN_PROTOCOL_VERSION = 1;
|
|
10
13
|
export const GIT_SHA = process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;
|
|
14
|
+
|
|
15
|
+
export const CONTRACTS = {
|
|
16
|
+
orchestratorProtocol: ORCHESTRATOR_PROTOCOL_VERSION,
|
|
17
|
+
runnerProtocol: RUNNER_PROTOCOL_VERSION,
|
|
18
|
+
providerPluginProtocol: PROVIDER_PLUGIN_PROTOCOL_VERSION,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const RUNTIME_CAPABILITIES = {
|
|
22
|
+
directoryBrowse: true,
|
|
23
|
+
relayCommandBus: true,
|
|
24
|
+
authenticatedLogProxy: true,
|
|
25
|
+
artifactProxy: true,
|
|
26
|
+
managedAgentReports: true,
|
|
27
|
+
durableRunnerSupervisor: true,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export function runtimeMetadata() {
|
|
31
|
+
return {
|
|
32
|
+
package: {
|
|
33
|
+
name: PACKAGE_NAME,
|
|
34
|
+
version: VERSION,
|
|
35
|
+
},
|
|
36
|
+
contracts: CONTRACTS,
|
|
37
|
+
capabilities: RUNTIME_CAPABILITIES,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import type { WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus } from "agent-relay-sdk";
|
|
6
|
+
|
|
7
|
+
const MAX_DIFF_PATCH_BYTES = 200_000;
|
|
8
|
+
|
|
9
|
+
interface WorkspaceResolutionInput {
|
|
10
|
+
cwd: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
policyName?: string;
|
|
13
|
+
spawnRequestId?: string;
|
|
14
|
+
workspaceMode?: WorkspaceMode;
|
|
15
|
+
workspaceRoot?: string;
|
|
16
|
+
automationId?: string;
|
|
17
|
+
automationRunId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface WorkspaceResolution {
|
|
21
|
+
cwd: string;
|
|
22
|
+
workspace: WorkspaceMetadata;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface GitResult {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function git(args: string[], cwd: string): GitResult {
|
|
32
|
+
const proc = Bun.spawnSync(["git", "-C", cwd, ...args], {
|
|
33
|
+
stdin: "ignore",
|
|
34
|
+
stdout: "pipe",
|
|
35
|
+
stderr: "pipe",
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
ok: proc.exitCode === 0,
|
|
39
|
+
stdout: proc.stdout.toString().trim(),
|
|
40
|
+
stderr: proc.stderr.toString().trim(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireGit(args: string[], cwd: string): string {
|
|
45
|
+
const result = git(args, cwd);
|
|
46
|
+
if (!result.ok) throw new Error(result.stderr || `git ${args.join(" ")} failed`);
|
|
47
|
+
return result.stdout;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function shortBranch(ref: string | undefined): string | undefined {
|
|
51
|
+
if (!ref || ref === "HEAD") return undefined;
|
|
52
|
+
return ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseWorktrees(output: string): WorkspaceProbeWorktree[] {
|
|
56
|
+
const blocks = output.split(/\n\s*\n/).map((block) => block.trim()).filter(Boolean);
|
|
57
|
+
return blocks.map((block) => {
|
|
58
|
+
const worktree: WorkspaceProbeWorktree = { path: "" };
|
|
59
|
+
for (const line of block.split("\n")) {
|
|
60
|
+
if (line.startsWith("worktree ")) worktree.path = line.slice("worktree ".length);
|
|
61
|
+
else if (line.startsWith("HEAD ")) worktree.headSha = line.slice("HEAD ".length);
|
|
62
|
+
else if (line.startsWith("branch ")) worktree.branch = shortBranch(line.slice("branch ".length));
|
|
63
|
+
else if (line === "bare") worktree.bare = true;
|
|
64
|
+
else if (line === "detached") worktree.detached = true;
|
|
65
|
+
else if (line.startsWith("prunable")) {
|
|
66
|
+
worktree.prunable = true;
|
|
67
|
+
const reason = line.slice("prunable".length).trim();
|
|
68
|
+
if (reason) worktree.reason = reason;
|
|
69
|
+
} else if (line.startsWith("locked")) {
|
|
70
|
+
worktree.locked = true;
|
|
71
|
+
const reason = line.slice("locked".length).trim();
|
|
72
|
+
if (reason) worktree.reason = reason;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return worktree;
|
|
76
|
+
}).filter((worktree) => worktree.path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function probeWorkspace(requestedPath: string): Promise<WorkspaceProbe> {
|
|
80
|
+
const path = resolve(requestedPath);
|
|
81
|
+
try {
|
|
82
|
+
const stat = statSync(path);
|
|
83
|
+
if (!stat.isDirectory()) return { path, isGitRepo: false, error: `Not a directory: ${path}` };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { path, isGitRepo: false, error: error instanceof Error ? error.message : String(error) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const root = git(["rev-parse", "--show-toplevel"], path);
|
|
89
|
+
if (!root.ok || !root.stdout) return { path, isGitRepo: false };
|
|
90
|
+
|
|
91
|
+
const repoRoot = root.stdout;
|
|
92
|
+
const branchRef = git(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot);
|
|
93
|
+
const head = git(["rev-parse", "HEAD"], repoRoot);
|
|
94
|
+
const status = git(["status", "--porcelain"], repoRoot);
|
|
95
|
+
const worktrees = git(["worktree", "list", "--porcelain"], repoRoot);
|
|
96
|
+
const branch = shortBranch(branchRef.stdout);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
path,
|
|
100
|
+
isGitRepo: true,
|
|
101
|
+
repoRoot,
|
|
102
|
+
...(branch ? { branch } : {}),
|
|
103
|
+
...(head.ok && head.stdout ? { headSha: head.stdout } : {}),
|
|
104
|
+
dirty: status.ok ? status.stdout.length > 0 : undefined,
|
|
105
|
+
detached: !branch,
|
|
106
|
+
...(worktrees.ok ? { worktrees: parseWorktrees(worktrees.stdout) } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Promise<WorkspaceResolution> {
|
|
111
|
+
const requestedMode = input.workspaceMode ?? "inherit";
|
|
112
|
+
const sourceCwd = resolve(input.cwd);
|
|
113
|
+
const probe = await probeWorkspace(sourceCwd);
|
|
114
|
+
const inheritedMode = input.policyName || input.automationRunId ? "isolated" : "shared";
|
|
115
|
+
const mode: WorkspaceMode = requestedMode === "inherit" ? inheritedMode : requestedMode;
|
|
116
|
+
if (mode !== "isolated" || !probe.isGitRepo || !probe.repoRoot) {
|
|
117
|
+
return {
|
|
118
|
+
cwd: sourceCwd,
|
|
119
|
+
workspace: { mode: "shared", requestedMode, sourceCwd, probe },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const id = workspaceId(input);
|
|
124
|
+
const repoRoot = probe.repoRoot;
|
|
125
|
+
const baseSha = probe.headSha ?? requireGit(["rev-parse", "HEAD"], repoRoot);
|
|
126
|
+
const branch = await availableBranch(repoRoot, branchName(input, id));
|
|
127
|
+
const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : join(homedir(), ".agent-relay", "workspaces");
|
|
128
|
+
const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id);
|
|
129
|
+
mkdirSync(join(worktreePath, ".."), { recursive: true });
|
|
130
|
+
requireGit(["worktree", "add", "-b", branch, worktreePath, baseSha], repoRoot);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
cwd: worktreePath,
|
|
134
|
+
workspace: {
|
|
135
|
+
id,
|
|
136
|
+
mode: "isolated",
|
|
137
|
+
requestedMode,
|
|
138
|
+
repoRoot,
|
|
139
|
+
sourceCwd,
|
|
140
|
+
worktreePath,
|
|
141
|
+
branch,
|
|
142
|
+
baseRef: probe.branch,
|
|
143
|
+
baseSha,
|
|
144
|
+
status: "active",
|
|
145
|
+
probe,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; deleteBranch?: boolean }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean } {
|
|
151
|
+
if (!workspace.worktreePath) throw new Error("worktreePath required");
|
|
152
|
+
const path = resolve(workspace.worktreePath);
|
|
153
|
+
const repo = workspace.repoRoot ? resolve(workspace.repoRoot) : path;
|
|
154
|
+
const result = git(["worktree", "remove", "--force", path], repo);
|
|
155
|
+
if (!result.ok) throw new Error(result.stderr || `failed to remove workspace ${path}`);
|
|
156
|
+
// Once the worktree is gone the agent/... branch is litter — delete it so
|
|
157
|
+
// branches don't accumulate. Best-effort: don't fail cleanup if it can't
|
|
158
|
+
// (e.g. branch already gone, or checked out elsewhere).
|
|
159
|
+
let branchDeleted = false;
|
|
160
|
+
if (workspace.branch && workspace.deleteBranch !== false) {
|
|
161
|
+
branchDeleted = git(["branch", "-D", workspace.branch], repo).ok;
|
|
162
|
+
}
|
|
163
|
+
return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read-only git interrogation of a worktree: how much work it holds (commits
|
|
168
|
+
* ahead/behind base, dirty files, last commit). Computed on the host because
|
|
169
|
+
* that is where git and the worktree live. Never throws — failures land in the
|
|
170
|
+
* returned `error`/`missing` fields so callers can degrade gracefully.
|
|
171
|
+
*/
|
|
172
|
+
export function workspaceGitState(input: { worktreePath?: string; baseRef?: string; baseSha?: string }): WorkspaceGitState {
|
|
173
|
+
if (!input.worktreePath) return { error: "worktreePath required" };
|
|
174
|
+
const path = resolve(input.worktreePath);
|
|
175
|
+
if (!existsSync(path)) return { missing: true };
|
|
176
|
+
|
|
177
|
+
const status = git(["status", "--porcelain"], path);
|
|
178
|
+
if (!status.ok) return { error: status.stderr || "git status failed" };
|
|
179
|
+
const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
|
|
180
|
+
|
|
181
|
+
const state: WorkspaceGitState = { dirty: dirtyCount > 0, dirtyCount };
|
|
182
|
+
|
|
183
|
+
const log = git(["log", "-1", "--format=%H%x1f%ct%x1f%s"], path);
|
|
184
|
+
if (log.ok && log.stdout) {
|
|
185
|
+
const [sha, ct, ...rest] = log.stdout.split("\x1f");
|
|
186
|
+
if (sha) {
|
|
187
|
+
const at = Number(ct) * 1000;
|
|
188
|
+
state.lastCommit = { sha, message: rest.join("\x1f"), ...(Number.isFinite(at) ? { at } : {}) };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Resolve a base to diff against: prefer the named branch (gives real behind
|
|
193
|
+
// counts), fall back to the fork SHA (behind is then always 0, ahead is the
|
|
194
|
+
// agent's new commits — still the signal we care about for cleanup).
|
|
195
|
+
const base = resolveBaseRef(path, input.baseRef, input.baseSha);
|
|
196
|
+
if (base) {
|
|
197
|
+
state.baseRef = base;
|
|
198
|
+
const counts = git(["rev-list", "--left-right", "--count", `${base}...HEAD`], path);
|
|
199
|
+
if (counts.ok && counts.stdout) {
|
|
200
|
+
const [behind, ahead] = counts.stdout.split(/\s+/).map((n) => Number(n));
|
|
201
|
+
if (Number.isFinite(behind)) state.behind = behind;
|
|
202
|
+
if (Number.isFinite(ahead)) state.ahead = ahead;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return state;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveBaseRef(worktreePath: string, baseRef?: string, baseSha?: string): string | undefined {
|
|
210
|
+
for (const candidate of [baseRef, baseSha]) {
|
|
211
|
+
if (candidate && git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], worktreePath).ok) {
|
|
212
|
+
return candidate;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Exit-time decision for an orphaned worktree (owner agent disappeared). Probe
|
|
220
|
+
* the worktree and either remove it (genuinely empty — clean tree, no commits
|
|
221
|
+
* ahead of base) or leave it intact and report a status the relay can flag for
|
|
222
|
+
* review. Never destroys work on uncertainty: any error or unknown ahead-count
|
|
223
|
+
* results in a flag, not a delete.
|
|
224
|
+
*/
|
|
225
|
+
export function reconcileWorkspace(workspace: { id?: string; repoRoot?: string; worktreePath?: string; branch?: string; baseRef?: string; baseSha?: string }): {
|
|
226
|
+
workspaceId?: string;
|
|
227
|
+
removed: boolean;
|
|
228
|
+
status: WorkspaceStatus;
|
|
229
|
+
gitState: WorkspaceGitState;
|
|
230
|
+
} {
|
|
231
|
+
const gitState = workspaceGitState(workspace);
|
|
232
|
+
if (gitState.missing) {
|
|
233
|
+
return { workspaceId: workspace.id, removed: false, status: "cleaned", gitState };
|
|
234
|
+
}
|
|
235
|
+
const empty = gitState.error === undefined && gitState.dirtyCount === 0 && gitState.ahead === 0;
|
|
236
|
+
if (empty) {
|
|
237
|
+
cleanupWorkspace({ id: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, branch: workspace.branch });
|
|
238
|
+
return { workspaceId: workspace.id, removed: true, status: "cleaned", gitState };
|
|
239
|
+
}
|
|
240
|
+
return { workspaceId: workspace.id, removed: false, status: "review_requested", gitState };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Diff a worktree's committed work against its base (base...HEAD): per-file
|
|
245
|
+
* line counts plus a size-capped unified patch, so the dashboard can show what
|
|
246
|
+
* an agent produced without an SSH session. Read-only; degrades into fields.
|
|
247
|
+
*/
|
|
248
|
+
export function workspaceDiff(input: { worktreePath?: string; baseRef?: string; baseSha?: string; includePatch?: boolean }): WorkspaceDiff {
|
|
249
|
+
if (!input.worktreePath) return { files: [], error: "worktreePath required" };
|
|
250
|
+
const path = resolve(input.worktreePath);
|
|
251
|
+
if (!existsSync(path)) return { files: [], missing: true };
|
|
252
|
+
|
|
253
|
+
const base = resolveBaseRef(path, input.baseRef, input.baseSha);
|
|
254
|
+
const range = base ? `${base}...HEAD` : "HEAD";
|
|
255
|
+
const result: WorkspaceDiff = { files: [], baseRef: base };
|
|
256
|
+
|
|
257
|
+
const status = git(["status", "--porcelain"], path);
|
|
258
|
+
if (status.ok) result.dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
|
|
259
|
+
|
|
260
|
+
if (base) {
|
|
261
|
+
const counts = git(["rev-list", "--count", `${base}..HEAD`], path);
|
|
262
|
+
if (counts.ok && counts.stdout) {
|
|
263
|
+
const ahead = Number(counts.stdout);
|
|
264
|
+
if (Number.isFinite(ahead)) result.ahead = ahead;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const numstat = git(["diff", "--numstat", range], path);
|
|
269
|
+
if (!numstat.ok) return { ...result, error: numstat.stderr || "git diff failed" };
|
|
270
|
+
result.files = parseNumstat(numstat.stdout);
|
|
271
|
+
|
|
272
|
+
if (input.includePatch !== false) {
|
|
273
|
+
const patch = git(["diff", range], path);
|
|
274
|
+
if (patch.ok && patch.stdout) {
|
|
275
|
+
if (patch.stdout.length > MAX_DIFF_PATCH_BYTES) {
|
|
276
|
+
result.patch = patch.stdout.slice(0, MAX_DIFF_PATCH_BYTES);
|
|
277
|
+
result.truncated = true;
|
|
278
|
+
} else {
|
|
279
|
+
result.patch = patch.stdout;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseNumstat(output: string): WorkspaceDiffFile[] {
|
|
287
|
+
if (!output) return [];
|
|
288
|
+
return output.split("\n").filter(Boolean).map((line) => {
|
|
289
|
+
const [add, del, ...rest] = line.split("\t");
|
|
290
|
+
const path = rest.join("\t");
|
|
291
|
+
const binary = add === "-" && del === "-";
|
|
292
|
+
return {
|
|
293
|
+
path,
|
|
294
|
+
binary,
|
|
295
|
+
...(binary ? {} : { additions: Number(add) || 0, deletions: Number(del) || 0 }),
|
|
296
|
+
};
|
|
297
|
+
}).filter((file) => file.path);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface WorkspaceMergeInput {
|
|
301
|
+
id?: string;
|
|
302
|
+
repoRoot?: string;
|
|
303
|
+
worktreePath?: string;
|
|
304
|
+
branch?: string;
|
|
305
|
+
baseRef?: string;
|
|
306
|
+
baseSha?: string;
|
|
307
|
+
strategy?: "pr" | "rebase-ff" | "auto";
|
|
308
|
+
deleteBranch?: boolean;
|
|
309
|
+
prTitle?: string;
|
|
310
|
+
prBody?: string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function hasOriginRemote(cwd: string): boolean {
|
|
314
|
+
return git(["remote", "get-url", "origin"], cwd).ok;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function ghAvailable(): boolean {
|
|
318
|
+
return Boolean(Bun.which("gh"));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Predict whether merging the branch's commits into base would conflict, using
|
|
323
|
+
* git's three-way merge-tree (no working-tree changes). Exit 0 = clean, exit 1
|
|
324
|
+
* = conflicts. Anything else is treated as "unknown" (undefined).
|
|
325
|
+
*/
|
|
326
|
+
function predictConflict(worktreePath: string, base: string): boolean | undefined {
|
|
327
|
+
const result = git(["merge-tree", "--write-tree", "--name-only", base, "HEAD"], worktreePath);
|
|
328
|
+
if (result.ok) return false;
|
|
329
|
+
// git exits 1 specifically for merge conflicts; other failures are unknown.
|
|
330
|
+
return /CONFLICT|conflict/.test(result.stdout + result.stderr) || result.stdout.length > 0 ? true : undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Branch name a base ref points at (only meaningful for refs/heads). */
|
|
334
|
+
function baseBranchName(worktreePath: string, baseRef?: string): string | undefined {
|
|
335
|
+
if (!baseRef) return undefined;
|
|
336
|
+
const ref = baseRef.startsWith("refs/heads/") ? baseRef.slice("refs/heads/".length) : baseRef;
|
|
337
|
+
return git(["show-ref", "--verify", "--quiet", `refs/heads/${ref}`], worktreePath).ok ? ref : undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Locate the worktree (if any) that currently has `branch` checked out. */
|
|
341
|
+
function worktreeForBranch(repoRoot: string, branch: string): { path: string; dirty: boolean } | undefined {
|
|
342
|
+
const list = git(["worktree", "list", "--porcelain"], repoRoot);
|
|
343
|
+
if (!list.ok) return undefined;
|
|
344
|
+
const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch);
|
|
345
|
+
if (!match) return undefined;
|
|
346
|
+
const status = git(["status", "--porcelain"], match.path);
|
|
347
|
+
return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Read-only pre-flight for integrating a workspace's work. Reports the strategy
|
|
352
|
+
* `auto` would pick plus whether the merge is clean, would conflict, or is a
|
|
353
|
+
* no-op — so the dashboard can warn before the user commits to it.
|
|
354
|
+
*/
|
|
355
|
+
export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto" }): WorkspaceMergePreview {
|
|
356
|
+
const gitState = workspaceGitState(input);
|
|
357
|
+
const remote = input.worktreePath ? hasOriginRemote(resolve(input.worktreePath)) : false;
|
|
358
|
+
const gh = ghAvailable();
|
|
359
|
+
const baseBranch = input.worktreePath ? baseBranchName(resolve(input.worktreePath), input.baseRef) : undefined;
|
|
360
|
+
// PR needs a remote, gh, and a real base branch to target; otherwise land locally.
|
|
361
|
+
const strategy: "pr" | "rebase-ff" = input.strategy === "pr" || input.strategy === "rebase-ff"
|
|
362
|
+
? input.strategy
|
|
363
|
+
: remote && gh && baseBranch ? "pr" : "rebase-ff";
|
|
364
|
+
const base: WorkspaceMergePreview = { strategy, hasRemote: remote, ghAvailable: gh, baseRef: baseBranch ?? gitState.baseRef };
|
|
365
|
+
|
|
366
|
+
if (gitState.missing) return { ...base, missing: true, reason: "worktree no longer exists" };
|
|
367
|
+
if (gitState.error) return { ...base, error: gitState.error };
|
|
368
|
+
base.ahead = gitState.ahead;
|
|
369
|
+
base.behind = gitState.behind;
|
|
370
|
+
base.dirtyCount = gitState.dirtyCount;
|
|
371
|
+
if ((gitState.dirtyCount ?? 0) > 0) return { ...base, reason: "worktree has uncommitted changes" };
|
|
372
|
+
if ((gitState.ahead ?? 0) === 0) return { ...base, reason: "no commits to merge" };
|
|
373
|
+
if (gitState.baseRef && input.worktreePath) {
|
|
374
|
+
const conflict = predictConflict(resolve(input.worktreePath), gitState.baseRef);
|
|
375
|
+
base.conflict = conflict;
|
|
376
|
+
base.cleanFastForward = conflict === false && (gitState.behind ?? 0) === 0;
|
|
377
|
+
}
|
|
378
|
+
return base;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Integrate a workspace's work back into its base branch. Two strategies:
|
|
383
|
+
* - rebase-ff: rebase the agent branch onto base, fast-forward base to it,
|
|
384
|
+
* then remove the worktree and delete the branch. Lands work locally.
|
|
385
|
+
* - pr: push the branch to origin and open a PR via gh. Leaves the worktree
|
|
386
|
+
* and branch intact (the PR needs them).
|
|
387
|
+
* Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never
|
|
388
|
+
* destroys work on uncertainty.
|
|
389
|
+
*/
|
|
390
|
+
export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult {
|
|
391
|
+
if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
|
|
392
|
+
const worktreePath = resolve(input.worktreePath);
|
|
393
|
+
const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
|
|
394
|
+
const branch = input.branch ?? shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath).stdout || undefined);
|
|
395
|
+
const preview = previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy });
|
|
396
|
+
const strategy = preview.strategy;
|
|
397
|
+
const head = (field: Partial<WorkspaceMergeResult>): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field });
|
|
398
|
+
|
|
399
|
+
if (preview.missing) return head({ status: "cleaned", error: preview.reason });
|
|
400
|
+
if (preview.error) return head({ status: "review_requested", error: preview.error });
|
|
401
|
+
if (preview.reason) return head({ status: "review_requested", error: preview.reason });
|
|
402
|
+
if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
|
|
403
|
+
|
|
404
|
+
if (strategy === "pr") return mergePr(input, worktreePath, branch, preview, head);
|
|
405
|
+
return mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function mergePr(
|
|
409
|
+
input: WorkspaceMergeInput,
|
|
410
|
+
worktreePath: string,
|
|
411
|
+
branch: string | undefined,
|
|
412
|
+
preview: WorkspaceMergePreview,
|
|
413
|
+
head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
|
|
414
|
+
): WorkspaceMergeResult {
|
|
415
|
+
if (!branch) return head({ status: "review_requested", error: "cannot determine branch to push" });
|
|
416
|
+
const base = preview.baseRef;
|
|
417
|
+
const push = git(["push", "-u", "origin", branch], worktreePath);
|
|
418
|
+
if (!push.ok) return head({ status: "review_requested", error: push.stderr || "git push failed" });
|
|
419
|
+
|
|
420
|
+
const title = input.prTitle || git(["log", "-1", "--format=%s"], worktreePath).stdout || `Merge ${branch}`;
|
|
421
|
+
const body = input.prBody || `Automated PR for agent workspace branch \`${branch}\`.`;
|
|
422
|
+
const args = ["pr", "create", "--head", branch, "--title", title, "--body", body];
|
|
423
|
+
if (base) args.push("--base", base);
|
|
424
|
+
const proc = Bun.spawnSync(["gh", ...args], { cwd: worktreePath, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
425
|
+
const stdout = proc.stdout.toString().trim();
|
|
426
|
+
if (proc.exitCode !== 0) {
|
|
427
|
+
return head({ status: "review_requested", error: proc.stderr.toString().trim() || "gh pr create failed" });
|
|
428
|
+
}
|
|
429
|
+
const prUrl = stdout.split("\n").map((line) => line.trim()).find((line) => /^https?:\/\//.test(line));
|
|
430
|
+
// PR opened: work is on its way out but not landed. Keep the worktree/branch
|
|
431
|
+
// alive for the PR; record the plan with the URL.
|
|
432
|
+
return head({ status: "merge_planned", prUrl, error: undefined });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function mergeRebaseFf(
|
|
436
|
+
input: WorkspaceMergeInput,
|
|
437
|
+
worktreePath: string,
|
|
438
|
+
repoRoot: string,
|
|
439
|
+
branch: string | undefined,
|
|
440
|
+
preview: WorkspaceMergePreview,
|
|
441
|
+
head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
|
|
442
|
+
): WorkspaceMergeResult {
|
|
443
|
+
const base = preview.baseRef;
|
|
444
|
+
if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
|
|
445
|
+
if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
|
|
446
|
+
|
|
447
|
+
// Rebase the agent branch onto base so base can fast-forward to it.
|
|
448
|
+
if ((preview.behind ?? 0) > 0) {
|
|
449
|
+
const rebase = git(["rebase", base], worktreePath);
|
|
450
|
+
if (!rebase.ok) {
|
|
451
|
+
git(["rebase", "--abort"], worktreePath);
|
|
452
|
+
return head({ conflict: true, status: "conflict", error: rebase.stderr || "rebase onto base failed" });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout;
|
|
456
|
+
|
|
457
|
+
// Advance base to the rebased branch. If base is checked out somewhere, do a
|
|
458
|
+
// real ff-only merge there so its working tree stays consistent; otherwise
|
|
459
|
+
// just move the ref. Refuse if the base worktree has uncommitted changes.
|
|
460
|
+
const baseWorktree = worktreeForBranch(repoRoot, base);
|
|
461
|
+
if (baseWorktree) {
|
|
462
|
+
if (baseWorktree.dirty) return head({ status: "review_requested", error: `base branch '${base}' has uncommitted changes in ${baseWorktree.path}` });
|
|
463
|
+
const ff = git(["merge", "--ff-only", branch], baseWorktree.path);
|
|
464
|
+
if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
|
|
465
|
+
} else {
|
|
466
|
+
const update = git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
|
|
467
|
+
if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Work is landed. Tear down the worktree and delete the agent branch.
|
|
471
|
+
const deleteBranch = input.deleteBranch !== false;
|
|
472
|
+
let worktreeRemoved = false;
|
|
473
|
+
let branchDeleted = false;
|
|
474
|
+
if (deleteBranch) {
|
|
475
|
+
const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
|
|
476
|
+
worktreeRemoved = removed.ok;
|
|
477
|
+
if (worktreeRemoved) branchDeleted = git(["branch", "-D", branch], repoRoot).ok;
|
|
478
|
+
}
|
|
479
|
+
return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, error: undefined });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function availableBranch(repoRoot: string, base: string): Promise<string> {
|
|
483
|
+
for (let i = 0; i < 50; i++) {
|
|
484
|
+
const candidate = i === 0 ? base : `${base}-${i + 1}`;
|
|
485
|
+
const existing = git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot);
|
|
486
|
+
if (!existing.ok) return candidate;
|
|
487
|
+
}
|
|
488
|
+
throw new Error(`could not find available branch name for ${base}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function workspaceId(input: WorkspaceResolutionInput): string {
|
|
492
|
+
const raw = input.spawnRequestId || input.automationRunId || crypto.randomUUID();
|
|
493
|
+
return safeSegment(raw, 80);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function branchName(input: WorkspaceResolutionInput, id: string): string {
|
|
497
|
+
const owner = input.policyName || input.label || input.automationId || "manual";
|
|
498
|
+
return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 24)}`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function repoSlug(repoRoot: string): string {
|
|
502
|
+
const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
|
|
503
|
+
return `${safeSegment(basename(repoRoot), 60)}-${hash}`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function safeSegment(value: string, max: number): string {
|
|
507
|
+
return value
|
|
508
|
+
.trim()
|
|
509
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
510
|
+
.replace(/^-+|-+$/g, "")
|
|
511
|
+
.slice(0, max)
|
|
512
|
+
|| "workspace";
|
|
513
|
+
}
|