agent-relay-orchestrator 0.26.0 → 0.27.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 +21 -1
- package/src/workspace-probe.ts +99 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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.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");
|
package/src/workspace-probe.ts
CHANGED
|
@@ -515,38 +515,38 @@ export function workspaceGitState(input: { worktreePath?: string; baseRef?: stri
|
|
|
515
515
|
}
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
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
|
|
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(
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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;
|