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.
@@ -118,7 +118,7 @@ export function issueRunnerRuntimeToken(input: {
118
118
  });
119
119
  }
120
120
 
121
- export function issueChildRunnerRuntimeToken(input: {
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
- export function childRunnerRuntimeTokenEnv(input: {
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
  }
@@ -17,7 +17,7 @@ export function generateSpawnRequestId(): string {
17
17
  return `sp_${randomUUID()}`;
18
18
  }
19
19
 
20
- export interface ResolveSpawnModelParamsOptions {
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
- export interface BuildSpawnCommandOptions {
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
- const data = agent ?? { id: agentId, status: "offline" };
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
- export type ExecuteUpgradeOptions = {
252
+ type ExecuteUpgradeOptions = {
253
253
  dryRun?: boolean;
254
254
  runner?: Runner;
255
255
  /** Re-register grace window for post-restart version checks (default 30s). */
@@ -41,7 +41,7 @@ export const WORKSPACE_ACTIONS = [
41
41
  ] as const;
42
42
  export type WorkspaceAction = (typeof WORKSPACE_ACTIONS)[number];
43
43
 
44
- export interface ApplyWorkspaceActionInput {
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
- export type WorkspaceActionResult =
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
- export const DEFAULT_WORKSPACE_WAIT_MS = 300_000;
324
- export const MAX_WORKSPACE_WAIT_MS = 600_000;
323
+ const DEFAULT_WORKSPACE_WAIT_MS = 300_000;
324
+ const MAX_WORKSPACE_WAIT_MS = 600_000;
325
325
 
326
- export interface WaitForWorkspaceResult {
326
+ interface WaitForWorkspaceResult {
327
327
  workspace: WorkspaceRecord | null;
328
328
  /** The status when the wait began. */
329
329
  fromStatus?: WorkspaceStatus;
@@ -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
- export const STEWARD_CLAIM_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_CLAIM_TTL_MS) || 15 * 60_000;
7
+ const STEWARD_CLAIM_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_CLAIM_TTL_MS) || 15 * 60_000;
8
8
 
9
- export interface WorkspaceClaim {
9
+ interface WorkspaceClaim {
10
10
  by?: string;
11
11
  purpose?: string;
12
12
  claimedAt?: number;
@@ -10,7 +10,7 @@ import {
10
10
  import type { Command, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
11
11
  import { isPathWithinBase } from "./utils";
12
12
 
13
- export interface RequestWorkspaceMergeOptions {
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
- export type RequestWorkspaceMergeResult =
29
+ type RequestWorkspaceMergeResult =
30
30
  | { ok: true; command: Command; workspace: WorkspaceRecord }
31
31
  | { ok: false; status: number; error: string };
32
32
 
@@ -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
- export interface CollectOrphansResult {
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<{
@@ -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
- export type WorkspacePhase =
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
- export interface WorkspaceNextAction {
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).