agent-relay-server 0.11.8 → 0.12.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/src/routes.ts CHANGED
@@ -85,8 +85,6 @@ import {
85
85
  getWorkspace,
86
86
  listWorkspaces,
87
87
  updateWorkspaceStatus,
88
- acquireMergeLease,
89
- setMergeLeaseCommand,
90
88
  releaseMergeLease,
91
89
  listRepoStewards,
92
90
  listMergeLeases,
@@ -107,11 +105,13 @@ import {
107
105
  getAgentProfile,
108
106
  getManagedAgentState,
109
107
  getSpawnPolicy,
108
+ getStewardConfigEntry,
110
109
  listAgentProfiles,
111
110
  listSpawnPolicies,
112
111
  listConfig,
113
112
  setAgentProfile,
114
113
  setConfig,
114
+ setStewardConfig,
115
115
  upsertManagedAgentState,
116
116
  updateManagedAgentState,
117
117
  } from "./config-store";
@@ -140,7 +140,7 @@ import {
140
140
  updateAutomation,
141
141
  type AutomationDispatchResult,
142
142
  } from "./automations";
143
- import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMetadata, WorkspaceMode, WorkspaceOrphan, WorkspaceProbe, WorkspaceStatus } from "./types";
143
+ import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfile, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, ManagedSessionExitDiagnostics, Message, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput, WorkspaceMergeStrategy, WorkspaceMetadata, WorkspaceMode, WorkspaceOrphan, WorkspaceProbe, WorkspaceStatus } from "./types";
144
144
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES, VERSION, type IntegrationTokenConfig } from "./config";
145
145
  import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
146
146
  import { listHostDirectories } from "./agent-spawn";
@@ -149,6 +149,7 @@ import type { ProviderConfig } from "../runner/src/adapter";
149
149
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
150
150
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
151
151
  import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
152
+ import { requestWorkspaceMerge } from "./workspace-merge";
152
153
  import {
153
154
  getComponentAuth,
154
155
  getIntegrationAuth,
@@ -187,7 +188,7 @@ import { postMcp } from "./mcp";
187
188
  import { readFileSync } from "node:fs";
188
189
  import { isAbsolute, relative, resolve } from "node:path";
189
190
  import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, ContextBudget, CreateMemoryInput, MemoryBrokerContext, MemoryConfidence, MemoryQuery, MemoryRedactionState, MemorySensitivity, MemoryType, MemoryVisibility, TaskRoutingHints, TokenConstraints, UpdateMemoryInput } from "./types";
190
- import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
191
+ import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, reissueRunnerRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
191
192
  import { listMaintenanceJobs, runLegacyMaintenanceReaper, runMaintenanceJobNow } from "./maintenance";
192
193
 
193
194
  type Handler = (
@@ -319,7 +320,8 @@ const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability
319
320
  const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
320
321
  const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
321
322
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
322
- const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext"] as const;
323
+ const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
324
+ const CLAUDE_RESUME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
323
325
  const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
324
326
  const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
325
327
  const VALID_PROVIDER_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
@@ -1248,7 +1250,10 @@ function auditEvent(input: ActivityEventInput): void {
1248
1250
  function auditCommandOutcome(command: Command): void {
1249
1251
  if (command.status !== "succeeded" && command.status !== "failed") return;
1250
1252
  if (command.type !== "agent.restart" && command.type !== "agent.shutdown" && command.type !== "agent.reconnect") return;
1251
- const action = agentControlActionFromCommandType(command.type);
1253
+ const paramAction = typeof command.params?.action === "string" && (VALID_AGENT_ACTIONS as readonly string[]).includes(command.params.action)
1254
+ ? command.params.action as AgentControlAction
1255
+ : null;
1256
+ const action = paramAction ?? agentControlActionFromCommandType(command.type);
1252
1257
  if (!action) return;
1253
1258
  const agentId = typeof command.params?.agentId === "string" ? command.params.agentId : command.target;
1254
1259
  const succeeded = command.status === "succeeded";
@@ -2142,11 +2147,12 @@ const deleteAgentById: Handler = (_req, params) => {
2142
2147
 
2143
2148
  type AgentControlAction = (typeof VALID_AGENT_ACTIONS)[number];
2144
2149
 
2145
- function agentControlActionCommandType(action: AgentControlAction): "agent.restart" | "agent.shutdown" | "agent.reconnect" | "agent.compact" | "agent.clearContext" {
2146
- if (action === "restart") return "agent.restart";
2150
+ function agentControlActionCommandType(action: AgentControlAction): "agent.restart" | "agent.shutdown" | "agent.reconnect" | "agent.compact" | "agent.clearContext" | "agent.interrupt" {
2151
+ if (action === "restart" || action === "resume") return "agent.restart";
2147
2152
  if (action === "shutdown") return "agent.shutdown";
2148
2153
  if (action === "compact") return "agent.compact";
2149
2154
  if (action === "clearContext") return "agent.clearContext";
2155
+ if (action === "interrupt") return "agent.interrupt";
2150
2156
  return "agent.reconnect";
2151
2157
  }
2152
2158
 
@@ -2156,22 +2162,27 @@ function agentControlActionFromCommandType(type: string): AgentControlAction | n
2156
2162
  if (type === "agent.reconnect") return "reconnect";
2157
2163
  if (type === "agent.compact") return "compact";
2158
2164
  if (type === "agent.clearContext") return "clearContext";
2165
+ if (type === "agent.interrupt") return "interrupt";
2159
2166
  return null;
2160
2167
  }
2161
2168
 
2162
2169
  function agentControlActionRequestedTitle(action: AgentControlAction): string {
2163
2170
  if (action === "restart") return "Agent restart requested";
2171
+ if (action === "resume") return "Agent resume requested";
2164
2172
  if (action === "shutdown") return "Agent shutdown requested";
2165
2173
  if (action === "compact") return "Agent compaction requested";
2166
2174
  if (action === "clearContext") return "Agent context clear requested";
2175
+ if (action === "interrupt") return "Agent interrupt requested";
2167
2176
  return "Agent reconnect requested";
2168
2177
  }
2169
2178
 
2170
2179
  function agentControlActionCompletedTitle(action: AgentControlAction): string {
2171
2180
  if (action === "restart") return "Agent restarted";
2181
+ if (action === "resume") return "Agent resumed";
2172
2182
  if (action === "shutdown") return "Agent shut down";
2173
2183
  if (action === "compact") return "Agent compacted";
2174
2184
  if (action === "clearContext") return "Agent context cleared";
2185
+ if (action === "interrupt") return "Agent interrupted";
2175
2186
  return "Agent reconnected";
2176
2187
  }
2177
2188
 
@@ -2179,6 +2190,8 @@ function agentControlActionIcon(action: AgentControlAction): string {
2179
2190
  if (action === "shutdown") return "ti-power";
2180
2191
  if (action === "compact") return "ti-compress";
2181
2192
  if (action === "clearContext") return "ti-eraser";
2193
+ if (action === "resume") return "ti-player-play";
2194
+ if (action === "interrupt") return "ti-player-stop";
2182
2195
  return "ti-refresh";
2183
2196
  }
2184
2197
 
@@ -2191,6 +2204,10 @@ function agentIsControlEligible(agent: AgentCard): boolean {
2191
2204
 
2192
2205
  function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlAction): boolean {
2193
2206
  if (!agentIsControlEligible(agent)) return false;
2207
+ if (action === "resume") return agentRuntimeProvider(agent) === "claude" && (agent.status === "offline" || agent.status === "stale");
2208
+ // Interrupt only makes sense while the provider is mid-turn, and only if the
2209
+ // provider advertises it can be interrupted from the dashboard.
2210
+ if (action === "interrupt") return agent.providerCapabilities?.liveSession?.interrupt === true && agent.status === "busy";
2194
2211
  const lifecycle = agent.providerCapabilities?.lifecycle;
2195
2212
  if (lifecycle) {
2196
2213
  if (action === "restart") return lifecycle.restartHard === true;
@@ -2202,6 +2219,10 @@ function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlActi
2202
2219
  return agent.meta?.runnerManaged === true && (action === "restart" || action === "shutdown");
2203
2220
  }
2204
2221
 
2222
+ function agentRuntimeProvider(agent: AgentCard): string | undefined {
2223
+ return metaString(agent.meta, "provider") ?? agent.providerCapabilities?.model?.provider;
2224
+ }
2225
+
2205
2226
  function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<typeof getOrchestrator>> | null {
2206
2227
  if (agent.meta?.runnerManaged !== true) return null;
2207
2228
  const metaSessionName = typeof agent.meta.sessionName === "string" ? agent.meta.sessionName : "";
@@ -2226,11 +2247,16 @@ function restartSpawnParamsForAgent(
2226
2247
  orchestrator: NonNullable<ReturnType<typeof getOrchestrator>> | null,
2227
2248
  policyName?: string,
2228
2249
  spawnRequestId?: string,
2250
+ opts: { resumeId?: string } = {},
2229
2251
  ): Record<string, unknown> | undefined {
2230
2252
  if (!orchestrator) return undefined;
2231
2253
  const requestId = spawnRequestId ?? spawnRequestIdForRestart();
2232
2254
  const policy = policyName ? getSpawnPolicy(policyName) : null;
2233
- if (policy) return { ...managedSpawnParams(policy.value, requestId), agentId: agent.id, requestedBy: "dashboard-restart" };
2255
+ const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
2256
+ if (policy) {
2257
+ const params = { ...managedSpawnParams(policy.value, requestId), agentId: agent.id, requestedBy };
2258
+ return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
2259
+ }
2234
2260
 
2235
2261
  const provider = metaString(agent.meta, "provider");
2236
2262
  if (provider !== "claude" && provider !== "codex") return undefined;
@@ -2259,7 +2285,7 @@ function restartSpawnParamsForAgent(
2259
2285
  };
2260
2286
  }
2261
2287
  }
2262
- return {
2288
+ const params = {
2263
2289
  action: "spawn",
2264
2290
  provider,
2265
2291
  ...resolvedModel,
@@ -2284,17 +2310,57 @@ function restartSpawnParamsForAgent(
2284
2310
  label,
2285
2311
  policyName,
2286
2312
  spawnRequestId: requestId,
2287
- createdBy: "dashboard-restart",
2313
+ createdBy: requestedBy,
2288
2314
  }),
2289
- requestedBy: "dashboard-restart",
2315
+ requestedBy,
2290
2316
  requestedAt: Date.now(),
2291
2317
  };
2318
+ return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
2292
2319
  }
2293
2320
 
2294
2321
  function spawnRequestIdForRestart(): string {
2295
2322
  return `sp_${crypto.randomUUID()}`;
2296
2323
  }
2297
2324
 
2325
+ function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
2326
+ return {
2327
+ ...params,
2328
+ providerArgs: providerArgsWithClaudeResume(recordStringArray(params.providerArgs), resumeId),
2329
+ resumeOfAgentId: agentId,
2330
+ claudeResumeId: resumeId,
2331
+ };
2332
+ }
2333
+
2334
+ function providerArgsWithClaudeResume(args: string[], resumeId: string): string[] {
2335
+ const cleaned: string[] = [];
2336
+ for (let i = 0; i < args.length; i += 1) {
2337
+ const arg = args[i];
2338
+ if (!arg) continue;
2339
+ if (arg === "--resume") {
2340
+ i += 1;
2341
+ continue;
2342
+ }
2343
+ if (arg.startsWith("--resume=")) continue;
2344
+ cleaned.push(arg);
2345
+ }
2346
+ return [...cleaned, "--resume", resumeId];
2347
+ }
2348
+
2349
+ function recordStringArray(value: unknown): string[] {
2350
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
2351
+ }
2352
+
2353
+ function latestClaudeResumeIdForAgent(agent: AgentCard): string | undefined {
2354
+ for (const entry of getAgentTimeline(agent.id, { limit: 50 })) {
2355
+ const metadata = entry.metadata;
2356
+ if (!metadata) continue;
2357
+ const provider = metaString(metadata, "provider");
2358
+ const resumeId = metaString(metadata, "claudeResumeId");
2359
+ if (provider === "claude" && resumeId && CLAUDE_RESUME_ID_RE.test(resumeId)) return resumeId;
2360
+ }
2361
+ return undefined;
2362
+ }
2363
+
2298
2364
  function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
2299
2365
  const alwaysReload = agent.tags
2300
2366
  .filter((tag) => tag.startsWith("memory-reload:"))
@@ -2315,11 +2381,14 @@ const postAgentAction: Handler = async (req, params) => {
2315
2381
  if (!agent) return error("agent not found", 404);
2316
2382
  if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
2317
2383
 
2318
- const orchestrator = (action === "restart" || action === "shutdown") ? managedControlOrchestrator(agent) : null;
2384
+ const orchestrator = (action === "restart" || action === "shutdown" || action === "resume") ? managedControlOrchestrator(agent) : null;
2319
2385
  const metaSessionName = typeof agent.meta?.sessionName === "string" ? agent.meta.sessionName : undefined;
2320
2386
  const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : undefined;
2321
2387
  const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
2322
2388
  const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
2389
+ const resumeId = action === "resume" ? latestClaudeResumeIdForAgent(agent) : undefined;
2390
+ if (action === "resume" && !orchestrator) return error("no online orchestrator available to resume agent", 409);
2391
+ if (action === "resume" && !resumeId) return error("no Claude resume id recorded for agent", 422);
2323
2392
  const denied = authorizeRoute(req, {
2324
2393
  scope: "agent:write",
2325
2394
  resource: { agentId: agent.id, orchestratorId: orchestrator?.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId },
@@ -2338,14 +2407,15 @@ const postAgentAction: Handler = async (req, params) => {
2338
2407
  ...(metaTmuxSession ? { tmuxSession: metaTmuxSession } : {}),
2339
2408
  ...(metaPolicyName ? { policyName: metaPolicyName } : {}),
2340
2409
  ...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
2341
- ...(action === "restart" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId) } : {}),
2410
+ ...(action === "restart" || action === "resume" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId, { resumeId }) } : {}),
2342
2411
  ...(action === "compact" ? compactMemoryParams(agent) : {}),
2412
+ ...(resumeId ? { claudeResumeId: resumeId } : {}),
2343
2413
  requestedBy: "dashboard",
2344
2414
  requestedAt: Date.now(),
2345
2415
  },
2346
2416
  });
2347
- if (action === "shutdown" || action === "restart") {
2348
- const lifecycleAction = action === "shutdown" ? "shutting-down" : "restarting";
2417
+ if (action === "shutdown" || action === "restart" || action === "resume") {
2418
+ const lifecycleAction = action === "shutdown" ? "shutting-down" : action === "resume" ? "resuming" : "restarting";
2349
2419
  markReady(agent.id, false);
2350
2420
  mergeAgentMeta(agent.id, { lifecycleAction, lifecycleActionAt: Date.now(), lifecycleCommandId: command.id });
2351
2421
  emitAgentStatus(agent.id);
@@ -2826,6 +2896,40 @@ const postRuntimeTokenRenew: Handler = async (req) => {
2826
2896
  }, 201);
2827
2897
  };
2828
2898
 
2899
+ // Orchestrator-mediated runner-token re-mint. A live runner whose runtime token
2900
+ // has expired (e.g. the relay was unreachable across the renewal window) cannot
2901
+ // self-renew — its dead token can't authenticate. Its orchestrator, which holds
2902
+ // a long-lived credential, proxies the runner's expired token here. We verify the
2903
+ // token is a genuine, non-revoked runner token owned by THIS orchestrator and
2904
+ // mint a fresh one cloning its scope, so the session self-heals without a restart.
2905
+ const postOrchestratorRunnerToken: Handler = async (req, params) => {
2906
+ const orch = getOrchestrator(params.id!);
2907
+ if (!orch) return error("orchestrator not found", 404);
2908
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
2909
+ if (denied) return denied;
2910
+ const parsed = await parseBody<unknown>(req);
2911
+ if (!parsed.ok) return error(parsed.error, parsed.status);
2912
+ if (!isRecord(parsed.body)) return error("body required");
2913
+ const token = cleanString(parsed.body.token, "token", { required: true, max: 8192 })!;
2914
+ const result = reissueRunnerRuntimeToken({
2915
+ expiredToken: token,
2916
+ orchestratorId: orch.id,
2917
+ createdBy: `orchestrator-remint:${orch.id}`,
2918
+ });
2919
+ if ("error" in result) return error(result.error, 403);
2920
+ auditEvent({
2921
+ clientId: "server-runner-token-remint-" + result.record.jti + "-" + Date.now(),
2922
+ kind: "state",
2923
+ title: "Runner token re-minted",
2924
+ body: result.record.sub,
2925
+ meta: result.record.jti,
2926
+ icon: "ti-key",
2927
+ view: "security",
2928
+ metadata: { orchestratorId: orch.id, jti: result.record.jti, sub: result.record.sub, ...authAuditMetadata(req) },
2929
+ });
2930
+ return json(result, 201);
2931
+ };
2932
+
2829
2933
  const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
2830
2934
  if (!isRootCredentialRequest(req)) return error("root credential required for runtime token exchange", 403);
2831
2935
  const parsed = await parseBody<unknown>(req);
@@ -3940,7 +4044,6 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
3940
4044
  const postWorkspaceAction: Handler = async (req, params) => {
3941
4045
  const parsed = await parseBody<unknown>(req);
3942
4046
  if (!parsed.ok) return error(parsed.error, parsed.status);
3943
- let mergeLeaseRepo: string | undefined;
3944
4047
  try {
3945
4048
  if (!isRecord(parsed.body)) return error("body required");
3946
4049
  const workspace = getWorkspace(params.id!);
@@ -3959,17 +4062,32 @@ const postWorkspaceAction: Handler = async (req, params) => {
3959
4062
  const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
3960
4063
  if (denied) return denied;
3961
4064
  if (action === "status") return json(workspace);
3962
- // Serialize base merges per repo: acquire the merge lease BEFORE mutating
3963
- // status so a losing request leaves the workspace untouched (issue #157).
4065
+ // Base merges go through the shared helper (lease + command + bind), the same
4066
+ // path the auto-merge job uses, so both serialize per repo (issue #157).
3964
4067
  if (action === "merge") {
3965
- const lease = acquireMergeLease(workspace.repoRoot, workspace.id, agentId ?? "dashboard");
3966
- if (!lease.ok) {
3967
- return error(
3968
- `a merge is already in progress for ${workspace.repoRoot} (workspace ${lease.lease.workspaceId}); retry after it settles`,
3969
- 409,
3970
- );
3971
- }
3972
- mergeLeaseRepo = workspace.repoRoot;
4068
+ const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
4069
+ const result = requestWorkspaceMerge(workspace, {
4070
+ requestedBy: agentId ?? "dashboard",
4071
+ strategy,
4072
+ deleteBranch: parsed.body.deleteBranch !== false,
4073
+ prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
4074
+ prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
4075
+ metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
4076
+ });
4077
+ if (!result.ok) return error(result.error, result.status);
4078
+ emitCommand(result.command);
4079
+ auditEvent({
4080
+ clientId: `workspace-merge-${workspace.id}-${Date.now()}`,
4081
+ kind: "state",
4082
+ title: "Workspace merge",
4083
+ body: detail ?? workspace.worktreePath,
4084
+ meta: workspace.branch ?? workspace.id,
4085
+ icon: "ti-git-merge",
4086
+ view: "orchestrators",
4087
+ agentId,
4088
+ metadata: { action: "merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: result.workspace.status, commandId: result.command.id, ...authAuditMetadata(req) },
4089
+ });
4090
+ return json({ workspace: result.workspace, command: result.command }, 202);
3973
4091
  }
3974
4092
  const statusByAction: Record<string, WorkspaceStatus | undefined> = {
3975
4093
  status: undefined,
@@ -3977,7 +4095,6 @@ const postWorkspaceAction: Handler = async (req, params) => {
3977
4095
  "conflict-found": "conflict",
3978
4096
  "request-review": "review_requested",
3979
4097
  "merge-plan": "merge_planned",
3980
- merge: "merge_planned",
3981
4098
  abandon: "abandoned",
3982
4099
  cleanup: "cleanup_requested",
3983
4100
  };
@@ -3991,58 +4108,31 @@ const postWorkspaceAction: Handler = async (req, params) => {
3991
4108
  if (!updated) return error("workspace not found", 404);
3992
4109
  let command: Command | undefined;
3993
4110
  if (requiresCommand) {
3994
- // All orchestrators whose baseDir contains the workspace; prefer an online one.
4111
+ // Only `cleanup` reaches here `merge` returned early via the shared helper.
4112
+ // Cleanup may queue: if the owning orchestrator is offline, the command (no
4113
+ // TTL) waits and reconciles when it reconnects. Only hard-fail when no
4114
+ // orchestrator owns the path at all — then DELETE is the escape.
3995
4115
  const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
3996
4116
  const onlineOwner = owners.find((candidate) => candidate.status === "online");
3997
- const baseParams = {
3998
- workspaceId: workspace.id,
3999
- repoRoot: workspace.repoRoot,
4000
- worktreePath: workspace.worktreePath,
4001
- branch: workspace.branch,
4002
- requestedBy: agentId ?? "dashboard",
4003
- requestedAt: Date.now(),
4004
- };
4005
- if (action === "merge") {
4006
- // Merge needs a live host: rebasing against a stale base later is unsafe.
4007
- if (!onlineOwner) {
4008
- releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
4009
- mergeLeaseRepo = undefined;
4010
- return error("no online orchestrator available for workspace merge", 409);
4011
- }
4012
- const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto");
4013
- command = createCommand({
4014
- type: "workspace.merge",
4015
- source: "system",
4016
- target: onlineOwner.agentId,
4017
- correlationId: workspace.id,
4018
- params: {
4019
- action: "merge",
4020
- ...baseParams,
4021
- baseRef: workspace.baseRef,
4022
- baseSha: workspace.baseSha,
4023
- strategy,
4024
- deleteBranch: parsed.body.deleteBranch !== false,
4025
- prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
4026
- prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
4027
- },
4028
- });
4029
- } else {
4030
- // Cleanup may queue: if the owning orchestrator is offline, the command
4031
- // (no TTL) waits and reconciles when it reconnects. Only hard-fail when
4032
- // no orchestrator owns the path at all — then DELETE is the escape.
4033
- const owner = onlineOwner ?? owners[0];
4034
- if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
4035
- command = createCommand({
4036
- type: "workspace.cleanup",
4037
- source: "system",
4038
- target: owner.agentId,
4039
- correlationId: workspace.id,
4040
- params: { action: "cleanup", ...baseParams, deleteBranch: true, queued: owner.status !== "online" },
4041
- });
4042
- }
4043
- // Bind the lease to the dispatched merge command so it's released by id
4044
- // when the command settles (postCommandResult).
4045
- if (action === "merge" && mergeLeaseRepo) setMergeLeaseCommand(mergeLeaseRepo, command.id);
4117
+ const owner = onlineOwner ?? owners[0];
4118
+ if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
4119
+ command = createCommand({
4120
+ type: "workspace.cleanup",
4121
+ source: "system",
4122
+ target: owner.agentId,
4123
+ correlationId: workspace.id,
4124
+ params: {
4125
+ action: "cleanup",
4126
+ workspaceId: workspace.id,
4127
+ repoRoot: workspace.repoRoot,
4128
+ worktreePath: workspace.worktreePath,
4129
+ branch: workspace.branch,
4130
+ requestedBy: agentId ?? "dashboard",
4131
+ requestedAt: Date.now(),
4132
+ deleteBranch: true,
4133
+ queued: owner.status !== "online",
4134
+ },
4135
+ });
4046
4136
  emitCommand(command);
4047
4137
  }
4048
4138
  auditEvent({
@@ -4051,16 +4141,13 @@ const postWorkspaceAction: Handler = async (req, params) => {
4051
4141
  title: `Workspace ${action}`,
4052
4142
  body: detail ?? workspace.worktreePath,
4053
4143
  meta: workspace.branch ?? workspace.id,
4054
- icon: action === "cleanup" ? "ti-trash" : action === "merge" ? "ti-git-merge" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
4144
+ icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
4055
4145
  view: "orchestrators",
4056
4146
  agentId,
4057
4147
  metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: updated.status, commandId: command?.id, ...authAuditMetadata(req) },
4058
4148
  });
4059
4149
  return json({ workspace: updated, command }, requiresCommand ? 202 : 200);
4060
4150
  } catch (e) {
4061
- // A merge that acquired the lease but failed before dispatch must release it,
4062
- // or the repo stays blocked until the TTL expires.
4063
- if (mergeLeaseRepo) releaseMergeLease({ repoRoot: mergeLeaseRepo });
4064
4151
  if (e instanceof ValidationError) return error(e.message, 400);
4065
4152
  throw e;
4066
4153
  }
@@ -4397,16 +4484,35 @@ const patchCommand: Handler = async (req, params) => {
4397
4484
  if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
4398
4485
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4399
4486
  const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4487
+ const removed = command.result.removed === true;
4400
4488
  if (workspaceId && resultStatus) {
4401
4489
  // Only act on workspaces the agent left in a live state; never overwrite
4402
4490
  // a status a human/agent has since moved on (merge_planned, abandoned, …).
4403
4491
  const current = getWorkspace(workspaceId);
4404
4492
  if (current && (current.status === "active" || current.status === "ready")) {
4405
- updateWorkspaceStatus(workspaceId, resultStatus, {
4406
- reconcileResult: command.result,
4407
- reconcileCommandId: command.id,
4408
- reconciledAt: Date.now(),
4409
- });
4493
+ if (removed) {
4494
+ // The owner exited and the worktree had no work — the orchestrator
4495
+ // already deleted it on disk. Drop the DB row immediately instead of
4496
+ // parking it at `cleaned` for 24h: a no-change session is pure junk
4497
+ // from the user's view, so it should leave the Workspaces panel now.
4498
+ deleteWorkspace(workspaceId);
4499
+ auditEvent({
4500
+ clientId: `workspace-reconcile-removed-${workspaceId}-${Date.now()}`,
4501
+ kind: "state",
4502
+ title: "Workspace removed (no changes)",
4503
+ body: current.worktreePath,
4504
+ meta: current.branch ?? workspaceId,
4505
+ icon: "ti-trash",
4506
+ view: "orchestrators",
4507
+ metadata: { reconcileCommandId: command.id, workspaceId, repoRoot: current.repoRoot },
4508
+ });
4509
+ } else {
4510
+ updateWorkspaceStatus(workspaceId, resultStatus, {
4511
+ reconcileResult: command.result,
4512
+ reconcileCommandId: command.id,
4513
+ reconciledAt: Date.now(),
4514
+ });
4515
+ }
4410
4516
  }
4411
4517
  }
4412
4518
  }
@@ -4469,6 +4575,27 @@ const deleteAgentProfileRoute: Handler = (req, params) => {
4469
4575
  }
4470
4576
  };
4471
4577
 
4578
+ // --- Steward config (global, provider-independent — issue #167) ---
4579
+
4580
+ const getStewardConfigRoute: Handler = () => json(getStewardConfigEntry());
4581
+
4582
+ const putStewardConfigRoute: Handler = async (req) => {
4583
+ const parsed = await parseBody<unknown>(req);
4584
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4585
+ try {
4586
+ const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
4587
+ ? parsed.body.value
4588
+ : parsed.body;
4589
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
4590
+ const entry = setStewardConfig(value, updatedBy);
4591
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
4592
+ return json(entry, entry.version === 1 ? 201 : 200);
4593
+ } catch (e) {
4594
+ if (e instanceof ValidationError) return error(e.message, 400);
4595
+ throw e;
4596
+ }
4597
+ };
4598
+
4472
4599
  // --- Config routes ---
4473
4600
 
4474
4601
  function normalizeConfigPathParam(raw: string | undefined, field: string): string {
@@ -6276,6 +6403,7 @@ const routes: Route[] = [
6276
6403
  route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
6277
6404
  route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
6278
6405
  route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
6406
+ route("POST", "/api/orchestrators/:id/runner-token", postOrchestratorRunnerToken),
6279
6407
  route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
6280
6408
  route("GET", "/api/orchestrators/:id/directories", getOrchestratorDirectories),
6281
6409
  route("POST", "/api/orchestrators/:id/directories", postOrchestratorCreateDirectory),
@@ -6334,6 +6462,8 @@ const routes: Route[] = [
6334
6462
  route("GET", "/api/agent-profiles/:name", getAgentProfileRoute),
6335
6463
  route("PUT", "/api/agent-profiles/:name", putAgentProfileRoute),
6336
6464
  route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
6465
+ route("GET", "/api/steward-config", getStewardConfigRoute),
6466
+ route("PUT", "/api/steward-config", putStewardConfigRoute),
6337
6467
  route("GET", "/api/config/:namespace", getConfigNamespace),
6338
6468
  route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
6339
6469
  route("GET", "/api/config/:namespace/:key", getConfigKey),
@@ -1,4 +1,5 @@
1
- import { createToken } from "./token-db";
1
+ import { createToken, revokeToken } from "./token-db";
2
+ import { verifyComponentTokenAllowExpired } from "./security";
2
3
  import type { TokenRecord } from "./types";
3
4
 
4
5
  interface RuntimeTokenResult {
@@ -6,6 +7,48 @@ interface RuntimeTokenResult {
6
7
  record: TokenRecord;
7
8
  }
8
9
 
10
+ // How long after expiry a runner token may still be re-minted. Bounds replay of
11
+ // a long-dead token to a sane window — a live session re-mints well within this.
12
+ const REMINT_MAX_EXPIRED_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
13
+
14
+ // Orchestrator-mediated re-mint: given a runner's current (possibly expired) token
15
+ // and the calling orchestrator's id, verify the token belongs to a runner of THIS
16
+ // orchestrator and issue a fresh provider-agent token cloning its scope. The old
17
+ // token is revoked. The caller MUST already be authenticated/authorized as the
18
+ // orchestrator — this only establishes that the presented token is a genuine,
19
+ // non-revoked runner token owned by that orchestrator. See approach #1 in the
20
+ // runner self-heal design: the relay stays strict, the orchestrator's standing
21
+ // privilege is the authorization, the signed token is the identity.
22
+ export function reissueRunnerRuntimeToken(input: {
23
+ expiredToken: string;
24
+ orchestratorId: string;
25
+ createdBy?: string;
26
+ }): RuntimeTokenResult | { error: string } {
27
+ const payload = verifyComponentTokenAllowExpired(input.expiredToken);
28
+ if (!payload) return { error: "invalid or revoked runner token" };
29
+ if (payload.role !== "provider" || !payload.sub.startsWith("runner:")) {
30
+ return { error: "not a runner token" };
31
+ }
32
+ const orchestrators = payload.constraints?.orchestrators;
33
+ if (!Array.isArray(orchestrators) || !orchestrators.includes(input.orchestratorId)) {
34
+ return { error: "runner token not owned by this orchestrator" };
35
+ }
36
+ if (payload.exp !== undefined) {
37
+ const ageSeconds = Math.floor(Date.now() / 1000) - payload.exp;
38
+ if (ageSeconds > REMINT_MAX_EXPIRED_AGE_SECONDS) return { error: "runner token expired too long ago" };
39
+ }
40
+ const reissued = createToken({
41
+ profileId: "provider-agent",
42
+ sub: payload.sub,
43
+ role: "provider",
44
+ scope: payload.scope,
45
+ constraints: payload.constraints,
46
+ createdBy: input.createdBy ?? `remint:${payload.jti ?? "unknown"}`,
47
+ });
48
+ if (payload.jti) revokeToken(payload.jti);
49
+ return reissued;
50
+ }
51
+
9
52
  export function issueOrchestratorRuntimeToken(input: {
10
53
  orchestratorId: string;
11
54
  baseDir: string;
package/src/security.ts CHANGED
@@ -294,6 +294,23 @@ export function verifyComponentToken(token: string, nowSeconds = Math.floor(Date
294
294
  return payload;
295
295
  }
296
296
 
297
+ // Verify a component token's signature and revocation WITHOUT enforcing expiry.
298
+ // Used only for orchestrator-mediated runner-token re-minting: an expired runner
299
+ // token is unforgeable proof of identity (it is HMAC-signed by this relay), even
300
+ // though it can no longer authenticate a request. The caller (an authenticated
301
+ // orchestrator) supplies the authorization; this just establishes which runner.
302
+ // Revoked tokens are still rejected — revocation is a hard kill.
303
+ export function verifyComponentTokenAllowExpired(token: string): ComponentToken | null {
304
+ const parts = token.split(".");
305
+ if (parts.length !== 3) return null;
306
+ const [headerRaw, payloadRaw, signature] = parts as [string, string, string];
307
+ if (!safeEqual(signature, hmac(`${headerRaw}.${payloadRaw}`))) return null;
308
+ const payload = parseBase64urlJson(payloadRaw);
309
+ if (!isComponentToken(payload)) return null;
310
+ if (payload.jti && isTokenRevoked(payload.jti)) return null;
311
+ return payload;
312
+ }
313
+
297
314
  export function forbidden(req: Request): Response {
298
315
  return applyCors(req, Response.json({ error: "forbidden" }, { status: 403 }));
299
316
  }