agent-relay-orchestrator 0.26.0 → 0.27.1

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.26.0",
3
+ "version": "0.27.1",
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.15"
19
+ "agent-relay-sdk": "0.2.16"
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 { previewWorkspaceMerge, probeWorkspace, workspaceDiff, workspaceGitState } from "./workspace-probe";
12
+ import { previewBranchMerge, previewWorkspaceMerge, probeWorkspace, workspaceDiff, workspaceGitState } from "./workspace-probe";
13
13
 
14
14
  interface DirectoryEntry {
15
15
  name: string;
@@ -517,6 +517,26 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
517
517
  }
518
518
  }
519
519
 
520
+ if (req.method === "GET" && url.pathname === "/api/workspace/branch-merge-preview") {
521
+ if (!authorized(req, config)) return error("unauthorized", 401);
522
+ try {
523
+ const { target } = resolveInsideBase(url.searchParams.get("repoRoot") || undefined, config.baseDir);
524
+ const strategy = url.searchParams.get("strategy");
525
+ const preview = previewBranchMerge({
526
+ repoRoot: target,
527
+ branch: url.searchParams.get("branch") || undefined,
528
+ baseRef: url.searchParams.get("baseRef") || undefined,
529
+ baseSha: url.searchParams.get("baseSha") || undefined,
530
+ strategy: strategy === "pr" || strategy === "rebase-ff" || strategy === "auto" ? strategy : undefined,
531
+ checkPr: url.searchParams.get("checkPr") === "1",
532
+ });
533
+ if (preview === null) return error("branch not found", 404);
534
+ return json(preview);
535
+ } catch (e) {
536
+ return error((e as Error).message);
537
+ }
538
+ }
539
+
520
540
  if (req.method === "GET" && url.pathname === "/api/providers") {
521
541
  return (async () => {
522
542
  const snapshot = await probeCache.getSnapshot(url.searchParams.get("refresh") === "1");
@@ -515,38 +515,38 @@ export function workspaceGitState(input: { worktreePath?: string; baseRef?: stri
515
515
  }
516
516
  }
517
517
 
518
- // Resolve a base to diff against: prefer the named branch (gives real behind
519
- // counts), fall back to the fork SHA (behind is then always 0, ahead is the
520
- // agent's new commits — still the signal we care about for cleanup).
521
- const base = resolveBaseRef(path, input.baseRef, input.baseSha);
522
- if (base) {
523
- state.baseRef = base;
524
- const counts = git(["rev-list", "--left-right", "--count", `${base}...HEAD`], path);
525
- if (counts.ok && counts.stdout) {
526
- const [behind, ahead] = counts.stdout.split(/\s+/).map((n) => Number(n));
527
- if (Number.isFinite(behind)) state.behind = behind;
528
- if (Number.isFinite(ahead)) state.ahead = ahead;
529
- }
530
- if ((state.ahead ?? 0) > 0) {
531
- // A squash or cherry-pick merge re-creates the work as a NEW commit on
532
- // base, so the branch tip is never an ancestor and raw `ahead` stays
533
- // positive even though the content has already landed. Discount that:
534
- // compare against the base branch's upstream when it has one (squash PRs
535
- // land on the remote), treat an identical tree as fully landed (covers a
536
- // multi-commit squash), else count only commits whose patch isn't already
537
- // present in base (git cherry '+'). Staleness of the compare ref can only
538
- // under-count landings, never invent one so `landed` is safe to act on.
539
- const cherryBase = upstreamRef(path, base) ?? base;
540
- if (git(["diff", "--quiet", cherryBase, "HEAD"], path).ok) {
541
- state.unmergedAhead = 0;
542
- } else {
543
- const cherry = git(["cherry", cherryBase, "HEAD"], path);
544
- if (cherry.ok) {
545
- state.unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0;
546
- }
518
+ return populateMergeState(path, "HEAD", state, input.baseRef, input.baseSha);
519
+ }
520
+
521
+ function populateMergeState(cwd: string, targetRef: string, state: WorkspaceGitState, baseRef?: string, baseSha?: string): WorkspaceGitState {
522
+ const base = resolveBaseRef(cwd, baseRef, baseSha);
523
+ if (!base) return state;
524
+ state.baseRef = base;
525
+ const counts = git(["rev-list", "--left-right", "--count", `${base}...${targetRef}`], cwd);
526
+ if (counts.ok && counts.stdout) {
527
+ const [behind, ahead] = counts.stdout.split(/\s+/).map((n) => Number(n));
528
+ if (Number.isFinite(behind)) state.behind = behind;
529
+ if (Number.isFinite(ahead)) state.ahead = ahead;
530
+ }
531
+ if ((state.ahead ?? 0) > 0) {
532
+ // A squash or cherry-pick merge re-creates the work as a NEW commit on
533
+ // base, so the branch tip is never an ancestor and raw `ahead` stays
534
+ // positive even though the content has already landed. Discount that:
535
+ // compare against the base branch's upstream when it has one (squash PRs
536
+ // land on the remote), treat an identical tree as fully landed (covers a
537
+ // multi-commit squash), else count only commits whose patch isn't already
538
+ // present in base (git cherry '+'). Staleness of the compare ref can only
539
+ // under-count landings, never invent one — so `landed` is safe to act on.
540
+ const cherryBase = upstreamRef(cwd, base) ?? base;
541
+ if (git(["diff", "--quiet", cherryBase, targetRef], cwd).ok) {
542
+ state.unmergedAhead = 0;
543
+ } else {
544
+ const cherry = git(["cherry", cherryBase, targetRef], cwd);
545
+ if (cherry.ok) {
546
+ state.unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0;
547
547
  }
548
- if (state.unmergedAhead === 0) state.landed = true;
549
548
  }
549
+ if (state.unmergedAhead === 0) state.landed = true;
550
550
  }
551
551
 
552
552
  return state;
@@ -569,6 +569,14 @@ function resolveBaseRef(worktreePath: string, baseRef?: string, baseSha?: string
569
569
  return undefined;
570
570
  }
571
571
 
572
+ function resolveBranchRef(repoRoot: string, branch?: string): string | undefined {
573
+ if (!branch) return undefined;
574
+ for (const candidate of [branch, `refs/heads/${branch}`]) {
575
+ if (git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], repoRoot).ok) return candidate;
576
+ }
577
+ return undefined;
578
+ }
579
+
572
580
  /**
573
581
  * Exit-time decision for an orphaned worktree (owner agent disappeared). Probe
574
582
  * the worktree and either remove it (genuinely empty — clean tree, no commits
@@ -688,19 +696,18 @@ function ghAvailable(): boolean {
688
696
  }
689
697
 
690
698
  /**
691
- * Ground-truth merge state for the branch checked out at `worktreePath`, via gh.
699
+ * Ground-truth merge state for `branch`, via gh.
692
700
  * This is the only reliable signal once a PR is squash-merged AND base has moved
693
701
  * on: the squash re-creates the work as one new commit (patch ids no longer
694
702
  * match, so `git cherry` can't see it) and the trees diverge (so tree-equality
695
703
  * can't either), leaving local git convinced the branch is still unmerged.
696
- * Returns undefined when there's no PR, no detached/unknown branch, or gh fails —
704
+ * Returns undefined when there's no PR, no branch, or gh fails —
697
705
  * we never invent a merge.
698
706
  */
699
- function prMergedState(worktreePath: string): { state: "merged" | "open"; url?: string } | undefined {
700
- const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
701
- if (!branch.ok || !branch.stdout || branch.stdout === "HEAD") return undefined;
702
- const proc = Bun.spawnSync(["gh", "pr", "view", branch.stdout, "--json", "state,url"], {
703
- cwd: worktreePath,
707
+ function prMergedState(cwd: string, branch: string | undefined): { state: "merged" | "open"; url?: string } | undefined {
708
+ if (!branch) return undefined;
709
+ const proc = Bun.spawnSync(["gh", "pr", "view", branch, "--json", "state,url"], {
710
+ cwd,
704
711
  stdin: "ignore",
705
712
  stdout: "pipe",
706
713
  stderr: "pipe",
@@ -744,6 +751,60 @@ function worktreeForBranch(repoRoot: string, branch: string): { path: string; di
744
751
  return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
745
752
  }
746
753
 
754
+ export function previewBranchMerge(input: {
755
+ repoRoot?: string;
756
+ branch?: string;
757
+ baseRef?: string;
758
+ baseSha?: string;
759
+ strategy?: "pr" | "rebase-ff" | "auto";
760
+ checkPr?: boolean;
761
+ }): WorkspaceMergePreview | null {
762
+ if (!input.repoRoot) return { strategy: "rebase-ff", error: "repoRoot required" };
763
+ if (!input.branch) return { strategy: "rebase-ff", error: "branch required" };
764
+ const repoRoot = resolve(input.repoRoot);
765
+ if (!existsSync(repoRoot)) return { strategy: "rebase-ff", error: `repoRoot does not exist: ${repoRoot}` };
766
+
767
+ const remote = hasOriginRemote(repoRoot);
768
+ const gh = ghAvailable();
769
+ const baseBranch = baseBranchName(repoRoot, input.baseRef);
770
+ const strategy: "pr" | "rebase-ff" = input.strategy === "pr" || input.strategy === "rebase-ff"
771
+ ? input.strategy
772
+ : remote && gh && baseBranch ? "pr" : "rebase-ff";
773
+ const branchRef = resolveBranchRef(repoRoot, input.branch);
774
+ if (!branchRef) return null;
775
+
776
+ const gitState = populateMergeState(repoRoot, branchRef, { dirty: false, dirtyCount: 0, branch: input.branch }, input.baseRef, input.baseSha);
777
+ const base: WorkspaceMergePreview = { strategy, hasRemote: remote, ghAvailable: gh, baseRef: baseBranch ?? gitState.baseRef };
778
+ if (!gitState.baseRef) return { ...base, error: "base ref unavailable" };
779
+ base.ahead = gitState.ahead;
780
+ base.unmergedAhead = gitState.unmergedAhead;
781
+ base.landed = gitState.landed;
782
+ base.behind = gitState.behind;
783
+ base.dirtyCount = 0;
784
+
785
+ let effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0);
786
+ if (effectiveAhead > 0 && input.checkPr && strategy === "pr") {
787
+ const pr = prMergedState(repoRoot, input.branch);
788
+ if (pr) {
789
+ base.prState = pr.state;
790
+ if (pr.url) base.prUrl = pr.url;
791
+ if (pr.state === "merged") {
792
+ base.prMerged = true;
793
+ effectiveAhead = 0;
794
+ }
795
+ }
796
+ }
797
+ if (effectiveAhead === 0) {
798
+ const reason = base.prMerged
799
+ ? "PR merged on remote"
800
+ : gitState.landed
801
+ ? "already merged into base (squash/cherry-pick)"
802
+ : "no commits to merge";
803
+ return { ...base, reason, noop: true };
804
+ }
805
+ return base;
806
+ }
807
+
747
808
  /**
748
809
  * Read-only pre-flight for integrating a workspace's work. Reports the strategy
749
810
  * `auto` would pick plus whether the merge is clean, would conflict, or is a
@@ -773,7 +834,7 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
773
834
  // base has moved on is invisible to it. When the caller opts in (checkPr) and
774
835
  // the strategy targets a PR, ask gh for the truth — a MERGED PR means landed.
775
836
  if (effectiveAhead > 0 && input.checkPr && strategy === "pr" && input.worktreePath) {
776
- const pr = prMergedState(resolve(input.worktreePath));
837
+ const pr = prMergedState(resolve(input.worktreePath), gitState.branch);
777
838
  if (pr) {
778
839
  base.prState = pr.state;
779
840
  if (pr.url) base.prUrl = pr.url;