agent-relay-server 0.28.0 → 0.30.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/public/index.html +254 -71
- package/runner/src/adapter.ts +1 -1
- package/runner/src/config.ts +1 -3
- package/src/agent-branch-state.ts +36 -0
- package/src/agent-ref.ts +21 -4
- package/src/branch-landed.ts +16 -2
- package/src/bus.ts +3 -21
- package/src/cli.ts +3 -3
- package/src/contracts.ts +1 -1
- package/src/db.ts +51 -14
- package/src/http-body.ts +1 -1
- package/src/insights-db.ts +2 -2
- package/src/lifecycle-manager.ts +4 -0
- package/src/maintenance.ts +16 -1
- package/src/managed-policy.ts +1 -1
- package/src/mcp.ts +3 -2
- package/src/notify.ts +1 -1
- package/src/orchestrator-lookup.ts +1 -1
- package/src/routes.ts +31 -9
- package/src/runtime-tokens.ts +2 -2
- package/src/security.ts +8 -0
- package/src/spawn-command.ts +2 -2
- package/src/sse.ts +15 -27
- package/src/token-db.ts +3 -3
- package/src/upgrade.ts +1 -1
- package/src/workspace-actions.ts +5 -5
- package/src/workspace-claim.ts +2 -2
- package/src/workspace-merge.ts +2 -2
- package/src/workspace-orphans.ts +1 -1
- package/src/workspace-phase.ts +71 -3
package/src/runtime-tokens.ts
CHANGED
|
@@ -118,7 +118,7 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
function issueChildRunnerRuntimeToken(input: {
|
|
122
122
|
parentAgentId: string;
|
|
123
123
|
orchestratorId: string;
|
|
124
124
|
cwd: string;
|
|
@@ -239,7 +239,7 @@ export function runnerRuntimeTokenEnv(input: {
|
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
|
|
242
|
+
function childRunnerRuntimeTokenEnv(input: {
|
|
243
243
|
parentAgentId: string;
|
|
244
244
|
orchestratorId: string;
|
|
245
245
|
cwd: string;
|
package/src/security.ts
CHANGED
|
@@ -200,6 +200,9 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
200
200
|
if (pathname.startsWith("/api/maintenance")) return "system:admin";
|
|
201
201
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
202
202
|
if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
|
|
203
|
+
// Insights config (the feature toggle) stays admin-only via the default; only the
|
|
204
|
+
// mechanical observation feed is writable by lower-privilege callers.
|
|
205
|
+
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
203
206
|
if (pathname.startsWith("/api/system/")) return "system:admin";
|
|
204
207
|
return null;
|
|
205
208
|
}
|
|
@@ -268,6 +271,11 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
268
271
|
if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
|
|
269
272
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
270
273
|
if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:write";
|
|
274
|
+
// The Runner posts the #184 context-gathering signal here (source:"server") via its
|
|
275
|
+
// provider token. Without this case the path fell through to the system:admin default
|
|
276
|
+
// below and every observation was 403-dropped — the whole Insights feed stayed empty.
|
|
277
|
+
// Config (the feature toggle) intentionally keeps falling through to system:admin.
|
|
278
|
+
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
271
279
|
if (pathname.startsWith("/api/system/")) return "system:admin";
|
|
272
280
|
return "system:admin";
|
|
273
281
|
}
|
package/src/spawn-command.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function generateSpawnRequestId(): string {
|
|
|
17
17
|
return `sp_${randomUUID()}`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
interface ResolveSpawnModelParamsOptions {
|
|
21
21
|
/**
|
|
22
22
|
* What to do when provider-catalog resolution throws (e.g. unknown model, or
|
|
23
23
|
* effort without a model):
|
|
@@ -69,7 +69,7 @@ export function resolveSpawnModelParams(
|
|
|
69
69
|
* Every field that can appear in a spawn command-bus payload.
|
|
70
70
|
* Optional fields are omitted from the result when undefined.
|
|
71
71
|
*/
|
|
72
|
-
|
|
72
|
+
interface BuildSpawnCommandOptions {
|
|
73
73
|
provider: SpawnProvider | string;
|
|
74
74
|
cwd: string;
|
|
75
75
|
/** Correlation id; omitted from the payload when absent (e.g. automation spawns use automationRunId instead). */
|
package/src/sse.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { getAgent, getOrchestrator } from "./db";
|
|
1
|
+
import { getAgent, getOrchestrator, onWorkspaceChange } from "./db";
|
|
2
|
+
import { attachBranchState } from "./agent-branch-state";
|
|
2
3
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
4
|
+
import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
|
|
3
5
|
import type { ActivityEvent, AgentCard, Message, RelayNotification, Task } from "./types";
|
|
4
6
|
import { isRecord } from "agent-relay-sdk";
|
|
5
7
|
|
|
@@ -68,18 +70,6 @@ function send(conn: Connection, event: string, data: unknown) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
function messageMatchesAgent(msg: Message, agentId: string): boolean {
|
|
72
|
-
const agent = getAgent(agentId);
|
|
73
|
-
if (!agent) return false;
|
|
74
|
-
if (msg.resolvedToAgent === agentId) return true;
|
|
75
|
-
if (msg.to === agentId || msg.from === agentId) return true;
|
|
76
|
-
if (msg.to === "broadcast") return true;
|
|
77
|
-
if (msg.to.startsWith("tag:") && agent.tags.includes(msg.to.slice(4))) return true;
|
|
78
|
-
if (msg.to.startsWith("cap:") && agent.capabilities.includes(msg.to.slice(4))) return true;
|
|
79
|
-
if (msg.to.startsWith("label:") && agent.label === msg.to.slice(6)) return true;
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
73
|
function fanout(event: RelayEvent): void {
|
|
84
74
|
if (event.type === "message.new") {
|
|
85
75
|
sendNewMessage(event.data as unknown as Message);
|
|
@@ -100,8 +90,7 @@ function sendNewMessage(msg: Message): void {
|
|
|
100
90
|
send(conn, "message.new", msg);
|
|
101
91
|
continue;
|
|
102
92
|
}
|
|
103
|
-
if (!messageMatchesAgent(msg, conn.agentId)) continue;
|
|
104
|
-
if (msg.claimable && msg.claimedBy && msg.claimedBy !== conn.agentId) continue;
|
|
93
|
+
if (!messageMatchesAgent(msg, conn.agentId, getAgent(conn.agentId))) continue;
|
|
105
94
|
send(conn, "message.new", msg);
|
|
106
95
|
}
|
|
107
96
|
}
|
|
@@ -114,12 +103,21 @@ export function emitNewMessage(msg: Message) {
|
|
|
114
103
|
|
|
115
104
|
export function emitAgentStatus(agentId: string) {
|
|
116
105
|
const agent = getAgent(agentId);
|
|
117
|
-
|
|
106
|
+
// Enrich with the branch-state badge fields (#236) so the dashboard's live agent
|
|
107
|
+
// updates carry the same projection as GET /api/agents.
|
|
108
|
+
const data = agent ? attachBranchState(agent) : { id: agentId, status: "offline" };
|
|
118
109
|
emitRelayEvent({ type: "agent.status", source: "server", subject: agentId, data: data as unknown as Record<string, unknown> });
|
|
119
110
|
const notification = agent ? notificationForAgentStatus(agent) : undefined;
|
|
120
111
|
if (notification) emitNotificationCreated(notification);
|
|
121
112
|
}
|
|
122
113
|
|
|
114
|
+
// A workspace row changed (status transition or an idle↔changes git snapshot) →
|
|
115
|
+
// re-emit its owner's agent.status so the branch-state badge updates live (#236).
|
|
116
|
+
// Registered against the db hook to avoid a db→sse import cycle.
|
|
117
|
+
onWorkspaceChange((workspace) => {
|
|
118
|
+
if (workspace.ownerAgentId) emitAgentStatus(workspace.ownerAgentId);
|
|
119
|
+
});
|
|
120
|
+
|
|
123
121
|
export function emitAgentRemoved(agentId: string) {
|
|
124
122
|
emitRelayEvent({ type: "agent.removed", source: "server", subject: agentId, data: { id: agentId } });
|
|
125
123
|
}
|
|
@@ -165,7 +163,7 @@ export function emitMessageReactionUpdated(msg: Message) {
|
|
|
165
163
|
|
|
166
164
|
function sendTaskChanged(task: Task, eventType = "task.updated"): void {
|
|
167
165
|
for (const conn of connections.values()) {
|
|
168
|
-
if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId)) continue;
|
|
166
|
+
if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId, getAgent(conn.agentId))) continue;
|
|
169
167
|
send(conn, eventType, task);
|
|
170
168
|
}
|
|
171
169
|
}
|
|
@@ -194,16 +192,6 @@ export function getConnectionCount(): number {
|
|
|
194
192
|
return connections.size;
|
|
195
193
|
}
|
|
196
194
|
|
|
197
|
-
function targetMatchesAgent(target: string, agentId: string): boolean {
|
|
198
|
-
const agent = getAgent(agentId);
|
|
199
|
-
if (!agent) return false;
|
|
200
|
-
if (target === agentId || target === "broadcast") return true;
|
|
201
|
-
if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
|
|
202
|
-
if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
|
|
203
|
-
if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
195
|
export function emitOrchestratorStatus(orchestratorId: string) {
|
|
208
196
|
const orch = getOrchestrator(orchestratorId);
|
|
209
197
|
const data = orch ?? { id: orchestratorId, status: "offline" };
|
package/src/token-db.ts
CHANGED
|
@@ -57,7 +57,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
57
57
|
name: "Provider Agent",
|
|
58
58
|
description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
|
|
59
59
|
role: "provider",
|
|
60
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use"],
|
|
60
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
|
|
61
61
|
ttlSeconds: 24 * 60 * 60,
|
|
62
62
|
builtIn: true,
|
|
63
63
|
createdBy: "system",
|
|
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
67
67
|
name: "Provider Child Agent",
|
|
68
68
|
description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
|
|
69
69
|
role: "provider",
|
|
70
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use"],
|
|
70
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
|
|
71
71
|
constraints: { canDelegate: false },
|
|
72
72
|
ttlSeconds: 2 * 60 * 60,
|
|
73
73
|
builtIn: true,
|
|
@@ -78,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
78
78
|
name: "Provider Interactive Agent",
|
|
79
79
|
description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
|
|
80
80
|
role: "provider",
|
|
81
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use"],
|
|
81
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
|
|
82
82
|
constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
|
|
83
83
|
ttlSeconds: 30 * 24 * 60 * 60,
|
|
84
84
|
builtIn: true,
|
package/src/upgrade.ts
CHANGED
|
@@ -249,7 +249,7 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
|
|
|
249
249
|
};
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
type ExecuteUpgradeOptions = {
|
|
253
253
|
dryRun?: boolean;
|
|
254
254
|
runner?: Runner;
|
|
255
255
|
/** Re-register grace window for post-restart version checks (default 30s). */
|
package/src/workspace-actions.ts
CHANGED
|
@@ -41,7 +41,7 @@ export const WORKSPACE_ACTIONS = [
|
|
|
41
41
|
] as const;
|
|
42
42
|
export type WorkspaceAction = (typeof WORKSPACE_ACTIONS)[number];
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
interface ApplyWorkspaceActionInput {
|
|
45
45
|
action: WorkspaceAction;
|
|
46
46
|
agentId?: string;
|
|
47
47
|
detail?: string;
|
|
@@ -62,7 +62,7 @@ export interface ApplyWorkspaceActionInput {
|
|
|
62
62
|
auditMetadata?: Record<string, unknown>;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
type WorkspaceActionResult =
|
|
66
66
|
| {
|
|
67
67
|
ok: true;
|
|
68
68
|
httpStatus: number;
|
|
@@ -320,10 +320,10 @@ export function buildWorkspaceDepsRefreshCommand(
|
|
|
320
320
|
return { ok: true, command };
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
const DEFAULT_WORKSPACE_WAIT_MS = 300_000;
|
|
324
|
+
const MAX_WORKSPACE_WAIT_MS = 600_000;
|
|
325
325
|
|
|
326
|
-
|
|
326
|
+
interface WaitForWorkspaceResult {
|
|
327
327
|
workspace: WorkspaceRecord | null;
|
|
328
328
|
/** The status when the wait began. */
|
|
329
329
|
fromStatus?: WorkspaceStatus;
|
package/src/workspace-claim.ts
CHANGED
|
@@ -4,9 +4,9 @@ import type { WorkspaceRecord } from "./types";
|
|
|
4
4
|
// auto-merge (Layer 0) doesn't race it (#208 / steward report §1). The claim is a
|
|
5
5
|
// TTL'd lease stored in row metadata, so a dead steward can't block the workspace
|
|
6
6
|
// forever — it expires and auto-merge resumes. Renew by re-claiming.
|
|
7
|
-
|
|
7
|
+
const STEWARD_CLAIM_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_CLAIM_TTL_MS) || 15 * 60_000;
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
interface WorkspaceClaim {
|
|
10
10
|
by?: string;
|
|
11
11
|
purpose?: string;
|
|
12
12
|
claimedAt?: number;
|
package/src/workspace-merge.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import type { Command, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
|
|
11
11
|
import { isPathWithinBase } from "./utils";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
interface RequestWorkspaceMergeOptions {
|
|
14
14
|
/** Who asked for the merge (lease holder + audit). e.g. an agent id, "dashboard", "auto-merge". */
|
|
15
15
|
requestedBy: string;
|
|
16
16
|
/** Merge strategy; "auto" lets the host pick pr-vs-rebase-ff. Defaults to "auto". */
|
|
@@ -26,7 +26,7 @@ export interface RequestWorkspaceMergeOptions {
|
|
|
26
26
|
metadata?: Record<string, unknown>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
type RequestWorkspaceMergeResult =
|
|
30
30
|
| { ok: true; command: Command; workspace: WorkspaceRecord }
|
|
31
31
|
| { ok: false; status: number; error: string };
|
|
32
32
|
|
package/src/workspace-orphans.ts
CHANGED
|
@@ -141,7 +141,7 @@ function knownRepoRoots(workspaces: WorkspaceRecord[]): string[] {
|
|
|
141
141
|
return [...new Set(workspaces.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
interface CollectOrphansResult {
|
|
145
145
|
orphans: WorkspaceOrphan[];
|
|
146
146
|
/** Live isolated rows whose worktree is missing on disk (DB→disk drift). */
|
|
147
147
|
missingWorktrees: Array<{
|
package/src/workspace-phase.ts
CHANGED
|
@@ -15,13 +15,23 @@
|
|
|
15
15
|
// anti-panic signal.
|
|
16
16
|
|
|
17
17
|
import { TERMINAL_WORKSPACE_STATUS_VALUES } from "agent-relay-sdk";
|
|
18
|
-
import type { WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
18
|
+
import type { BranchState, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
19
|
+
import { workspaceActiveClaim } from "./workspace-claim";
|
|
19
20
|
|
|
20
21
|
// Statuses where the worktree's lifecycle is over — landed or torn down. Single
|
|
21
22
|
// home; imported by maintenance (stale reap), routes (orphan scan), and the MCP
|
|
22
23
|
// initialize primer (don't brief an agent on a dead workspace). Was duplicated.
|
|
23
24
|
export const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(TERMINAL_WORKSPACE_STATUS_VALUES);
|
|
24
25
|
|
|
26
|
+
// The "this is the worktree the agent is actively branch-working in" predicate:
|
|
27
|
+
// isolated mode and not yet landed/torn down. SINGLE HOME — the MCP owner-workspace
|
|
28
|
+
// resolver, the db owner lookup, and the #236 badge enrichment all mean the same
|
|
29
|
+
// thing; they drifted into private copies before. listWorkspaces is ORDER BY
|
|
30
|
+
// updated_at DESC, so the first match per owner is the most recent live worktree.
|
|
31
|
+
export function isLiveIsolatedWorkspace(ws: Pick<WorkspaceRecord, "mode" | "status">): boolean {
|
|
32
|
+
return ws.mode === "isolated" && !TERMINAL_WORKSPACE_STATUSES.has(ws.status);
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
// The "handed off, waiting to land" statuses — an agent has finished and the
|
|
26
36
|
// auto-merge-back is responsible for getting the branch onto base. SINGLE HOME:
|
|
27
37
|
// the auto-land consumer (maintenance `autoMergeCleanFastForwards`) and the
|
|
@@ -71,7 +81,7 @@ export function worktreeReapable(state: WorktreeReapState | null | undefined): b
|
|
|
71
81
|
// instead of the old behavior where it looked healthy for 90 minutes.
|
|
72
82
|
export const LAND_PENDING_STALL_MS = 15 * 60 * 1000;
|
|
73
83
|
|
|
74
|
-
|
|
84
|
+
type WorkspacePhase =
|
|
75
85
|
| "working" // active — your turn: commit, then mark ready
|
|
76
86
|
| "land-pending" // ready | review_requested — handed off; auto-merge will land it
|
|
77
87
|
| "landing" // merge_planned — merge dispatched, in progress
|
|
@@ -79,7 +89,7 @@ export type WorkspacePhase =
|
|
|
79
89
|
| "landed" // merged — on the base; a fresh rebased branch is coming
|
|
80
90
|
| "closed"; // abandoned | cleanup_requested | cleaned — torn down
|
|
81
91
|
|
|
82
|
-
|
|
92
|
+
interface WorkspaceNextAction {
|
|
83
93
|
/** MCP tool to call (when the agent is on the MCP surface). */
|
|
84
94
|
tool?: string;
|
|
85
95
|
/** Equivalent CLI invocation (when the agent is on the shell surface). */
|
|
@@ -209,6 +219,64 @@ export function describeWorkspacePhase(
|
|
|
209
219
|
}
|
|
210
220
|
}
|
|
211
221
|
|
|
222
|
+
function metaNumber(meta: Record<string, unknown> | undefined, key: string): number | undefined {
|
|
223
|
+
const v = meta?.[key];
|
|
224
|
+
return typeof v === "number" ? v : undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// THE branch-state projection for the human (#236) — the sibling of
|
|
228
|
+
// describeWorkspacePhase, which targets the agent. Maps a workspace to one compact
|
|
229
|
+
// state for the agent-card/chat badge, so the recurring "does this agent have
|
|
230
|
+
// unlanded work, and where is it in the merge-back" question is answered at a
|
|
231
|
+
// glance. SINGLE HOME (server-side); the dashboard never recomputes it.
|
|
232
|
+
//
|
|
233
|
+
// idle/changes need the worktree's ahead/dirty counts, which the relay isn't in the
|
|
234
|
+
// git path to know live — the ~2 min conflict scan stashes them in metadata
|
|
235
|
+
// (`gitAhead`/`gitDirtyCount`). Until the first scan they're absent, so an active
|
|
236
|
+
// worktree shows the optimistic `changes` (the high-value "mark ready" affordance);
|
|
237
|
+
// the scan then settles it to `idle` when genuinely empty (#236 v1 option a).
|
|
238
|
+
//
|
|
239
|
+
// Returns undefined for non-branch / torn-down workspaces (no badge).
|
|
240
|
+
export function deriveBranchState(
|
|
241
|
+
workspace: Pick<WorkspaceRecord, "status" | "metadata" | "readyAt"> | null | undefined,
|
|
242
|
+
opts: { now?: number; stallMs?: number } = {},
|
|
243
|
+
): BranchState | undefined {
|
|
244
|
+
if (!workspace) return undefined;
|
|
245
|
+
const now = opts.now ?? Date.now();
|
|
246
|
+
switch (workspace.status) {
|
|
247
|
+
case "active": {
|
|
248
|
+
const meta = workspace.metadata as Record<string, unknown> | undefined;
|
|
249
|
+
const ahead = metaNumber(meta, "gitAhead");
|
|
250
|
+
const dirty = metaNumber(meta, "gitDirtyCount");
|
|
251
|
+
if (ahead === undefined && dirty === undefined) return "changes";
|
|
252
|
+
return (ahead ?? 0) > 0 || (dirty ?? 0) > 0 ? "changes" : "idle";
|
|
253
|
+
}
|
|
254
|
+
case "ready":
|
|
255
|
+
case "review_requested":
|
|
256
|
+
// Handed off, waiting for the auto-merge. A steward holding the claim means a
|
|
257
|
+
// human-out-of-loop reconciliation is underway → 🟠, otherwise the robot has it → 🔵.
|
|
258
|
+
return workspaceActiveClaim(workspace, now)?.purpose === "steward" ? "steward" : "ready";
|
|
259
|
+
case "merge_planned":
|
|
260
|
+
// Merge dispatched / under reconciliation — robot-or-steward, either way not the human's move.
|
|
261
|
+
return "steward";
|
|
262
|
+
case "conflict": {
|
|
263
|
+
// Held by a steward → reconciling (🟠). Past the stall window with nobody
|
|
264
|
+
// holding it → the steward path isn't progressing → escalate to the human (🔴).
|
|
265
|
+
if (workspaceActiveClaim(workspace, now)?.purpose === "steward") return "steward";
|
|
266
|
+
const stallMs = opts.stallMs ?? LAND_PENDING_STALL_MS;
|
|
267
|
+
const since = typeof workspace.readyAt === "number" ? now - workspace.readyAt : undefined;
|
|
268
|
+
if (since !== undefined && since > stallMs) return "blocked";
|
|
269
|
+
return "steward";
|
|
270
|
+
}
|
|
271
|
+
case "merged":
|
|
272
|
+
case "abandoned":
|
|
273
|
+
case "cleanup_requested":
|
|
274
|
+
case "cleaned":
|
|
275
|
+
// Terminal/torn down: the owner lookup filters these out, so the badge clears.
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
212
280
|
// Plain-language contract printed/returned right when an agent marks a workspace
|
|
213
281
|
// ready, so the whole "what happens next" is stated up front instead of being
|
|
214
282
|
// decoded from status enums over the following minutes (#235).
|