agent-relay-server 0.17.0 → 0.19.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/docs/openapi.json +101 -1
- package/package.json +2 -2
- package/public/index.html +39 -32
- package/public/sw.js +51 -16
- package/runner/src/adapter.ts +1 -4
- package/runner/src/config.ts +3 -5
- package/scripts/orchestrator-spawn-smoke.ts +2 -1
- package/src/automations.ts +20 -59
- package/src/bus.ts +3 -18
- package/src/cli.ts +244 -7
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +12 -47
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/daemon.ts +1 -4
- package/src/db.ts +23 -17
- package/src/dev.ts +1 -4
- package/src/http-body.ts +49 -0
- package/src/index.ts +101 -5
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +28 -22
- package/src/managed-policy.ts +9 -28
- package/src/mcp.ts +35 -110
- package/src/memory-broker-smoke.ts +4 -2
- package/src/memory-command-broker.ts +2 -5
- package/src/memory-http-broker.ts +2 -5
- package/src/memory-service.ts +1 -4
- package/src/memory-sqlite-broker.ts +1 -8
- package/src/orchestrator-lookup.ts +29 -0
- package/src/provider-catalog-store.ts +3 -11
- package/src/recipe-loader.ts +1 -4
- package/src/recipe-validator.ts +2 -5
- package/src/routes.ts +417 -309
- package/src/security.ts +3 -7
- package/src/setup.ts +1 -4
- package/src/spawn-command.ts +151 -0
- package/src/sse.ts +1 -4
- package/src/steward.ts +17 -21
- package/src/upgrade.ts +40 -13
- package/src/utils.ts +38 -0
- package/src/validation.ts +80 -0
- package/src/workspace-claim.ts +29 -0
- package/src/workspace-merge.ts +21 -9
package/src/automations.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { parseJson } from "./utils";
|
|
2
3
|
import {
|
|
3
4
|
getAgent,
|
|
4
5
|
getDb,
|
|
@@ -12,8 +13,11 @@ import {
|
|
|
12
13
|
ValidationError,
|
|
13
14
|
} from "./db";
|
|
14
15
|
import { createCommand } from "./commands-db";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { cleanEnum, cleanString, cleanStringArray, optionalEnum } from "./validation";
|
|
17
|
+
import { getAgentProfile, getSpawnPolicy } from "./config-store";
|
|
18
|
+
import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
|
|
19
|
+
import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
|
|
20
|
+
import { errMessage, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
17
21
|
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
18
22
|
import type {
|
|
19
23
|
AgentCard,
|
|
@@ -43,8 +47,6 @@ const BLOCKING_RUN_STATUSES = new Set<AutomationRunStatus>(["dispatching", "wait
|
|
|
43
47
|
const OPEN_RUN_STATUSES = new Set<AutomationRunStatus>(["scheduled", "dispatching", "waiting_agent", "running"]);
|
|
44
48
|
const CLOSED_TASK_STATUS = new Set(["done", "failed", "canceled"]);
|
|
45
49
|
const MAX_CRON_SCAN_MINUTES = 366 * 24 * 60;
|
|
46
|
-
const VALID_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
47
|
-
const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
|
|
48
50
|
const MIN_RUNTIME_BUDGET_MS = 60_000;
|
|
49
51
|
const MAX_RUNTIME_BUDGET_MS = 24 * 60 * 60 * 1000;
|
|
50
52
|
|
|
@@ -75,15 +77,6 @@ function ensureAutomationTables(): void {
|
|
|
75
77
|
initializedDb = current;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
function parseJson<T>(value: unknown, fallback: T): T {
|
|
79
|
-
if (typeof value !== "string" || !value) return fallback;
|
|
80
|
-
try {
|
|
81
|
-
return JSON.parse(value) as T;
|
|
82
|
-
} catch {
|
|
83
|
-
return fallback;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
80
|
function rowToAutomation(row: any): Automation {
|
|
88
81
|
return {
|
|
89
82
|
id: row.id,
|
|
@@ -126,39 +119,12 @@ function rowToAutomationRun(row: any): AutomationRun {
|
|
|
126
119
|
};
|
|
127
120
|
}
|
|
128
121
|
|
|
129
|
-
function cleanString(value: unknown, field: string, opts: { required?: boolean; max?: number } = {}): string | undefined {
|
|
130
|
-
if (value === undefined || value === null) {
|
|
131
|
-
if (opts.required) throw new ValidationError(`${field} required`);
|
|
132
|
-
return undefined;
|
|
133
|
-
}
|
|
134
|
-
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
135
|
-
const trimmed = value.trim();
|
|
136
|
-
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
137
|
-
if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
138
|
-
return trimmed || undefined;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function cleanStringArray(value: unknown, field: string): string[] | undefined {
|
|
142
|
-
if (value === undefined || value === null) return undefined;
|
|
143
|
-
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
|
|
144
|
-
return [...new Set(value.map((item) => cleanString(item, `${field} item`, { max: 100 })).filter(Boolean) as string[])];
|
|
145
|
-
}
|
|
146
|
-
|
|
147
122
|
function cleanBool(value: unknown, field: string): boolean | undefined {
|
|
148
123
|
if (value === undefined || value === null) return undefined;
|
|
149
124
|
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
150
125
|
return value;
|
|
151
126
|
}
|
|
152
127
|
|
|
153
|
-
function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback?: T[number]): T[number] {
|
|
154
|
-
if (value === undefined || value === null) {
|
|
155
|
-
if (fallback !== undefined) return fallback;
|
|
156
|
-
throw new ValidationError(`${field} required`);
|
|
157
|
-
}
|
|
158
|
-
if (typeof value !== "string" || !valid.includes(value)) throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
|
|
159
|
-
return value as T[number];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
128
|
function cleanMeta(value: unknown, field = "metadata"): Record<string, unknown> | undefined {
|
|
163
129
|
if (value === undefined || value === null) return undefined;
|
|
164
130
|
if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${field} must be an object`);
|
|
@@ -172,7 +138,7 @@ function normalizeTaskTemplate(value: unknown): AutomationTaskTemplate {
|
|
|
172
138
|
return {
|
|
173
139
|
title: cleanString(input.title, "taskTemplate.title", { required: true, max: 240 })!,
|
|
174
140
|
body: cleanString(input.body, "taskTemplate.body", { required: true, max: 200_000 })!,
|
|
175
|
-
severity:
|
|
141
|
+
severity: optionalEnum(input.severity, "taskTemplate.severity", ["info", "warning", "critical"] as const, "info") as TaskSeverity,
|
|
176
142
|
dedupeKey: cleanString(input.dedupeKey, "taskTemplate.dedupeKey", { max: 240 }),
|
|
177
143
|
externalUrl: cleanString(input.externalUrl, "taskTemplate.externalUrl", { max: 1000 }),
|
|
178
144
|
metadata: cleanMeta(input.metadata, "taskTemplate.metadata"),
|
|
@@ -226,13 +192,13 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
|
|
|
226
192
|
selector: {
|
|
227
193
|
provider: cleanString(selectorInput.provider, "targetPolicy.selector.provider", { max: 40 }) as "claude" | "codex" | undefined,
|
|
228
194
|
label: cleanString(selectorInput.label, "targetPolicy.selector.label", { max: 120 }),
|
|
229
|
-
tags: cleanStringArray(selectorInput.tags, "targetPolicy.selector.tags"),
|
|
230
|
-
capabilities: cleanStringArray(selectorInput.capabilities, "targetPolicy.selector.capabilities"),
|
|
195
|
+
tags: cleanStringArray(selectorInput.tags, "targetPolicy.selector.tags", { itemMax: 100 }),
|
|
196
|
+
capabilities: cleanStringArray(selectorInput.capabilities, "targetPolicy.selector.capabilities", { itemMax: 100 }),
|
|
231
197
|
},
|
|
232
|
-
ifNoMatch:
|
|
198
|
+
ifNoMatch: optionalEnum(input.ifNoMatch, "targetPolicy.ifNoMatch", ["fail", "spawn"] as const, "fail"),
|
|
233
199
|
};
|
|
234
200
|
}
|
|
235
|
-
const provider =
|
|
201
|
+
const provider = optionalEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex");
|
|
236
202
|
const model = cleanString(input.model, "targetPolicy.model", { max: 120 });
|
|
237
203
|
const effort = input.effort === undefined || input.effort === null ? undefined : cleanEnum(input.effort, "targetPolicy.effort", VALID_EFFORTS) as ProviderEffort;
|
|
238
204
|
const profile = cleanString(input.profile, "targetPolicy.profile", { max: 120 });
|
|
@@ -240,7 +206,7 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
|
|
|
240
206
|
try {
|
|
241
207
|
resolveProviderSelection({ provider, model, effort });
|
|
242
208
|
} catch (error) {
|
|
243
|
-
throw new ValidationError(
|
|
209
|
+
throw new ValidationError(errMessage(error));
|
|
244
210
|
}
|
|
245
211
|
return {
|
|
246
212
|
mode,
|
|
@@ -248,9 +214,9 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
|
|
|
248
214
|
model,
|
|
249
215
|
effort,
|
|
250
216
|
cwd: cleanString(input.cwd, "targetPolicy.cwd", { max: 500 }),
|
|
251
|
-
workspaceMode:
|
|
217
|
+
workspaceMode: optionalEnum(input.workspaceMode, "targetPolicy.workspaceMode", VALID_WORKSPACE_MODES, "inherit"),
|
|
252
218
|
profile,
|
|
253
|
-
approvalMode:
|
|
219
|
+
approvalMode: optionalEnum(input.approvalMode, "targetPolicy.approvalMode", ["open", "guarded", "read-only"] as const, "guarded"),
|
|
254
220
|
keepAlive: cleanBool(input.keepAlive, "targetPolicy.keepAlive") ?? false,
|
|
255
221
|
runtimeBudget: normalizeRuntimeBudget(input),
|
|
256
222
|
shutdownAfterMs: typeof input.shutdownAfterMs === "number" && Number.isSafeInteger(input.shutdownAfterMs) && input.shutdownAfterMs >= 0
|
|
@@ -270,8 +236,8 @@ function normalizeCreateInput(input: CreateAutomationInput): Required<Omit<Creat
|
|
|
270
236
|
enabled: cleanBool(input.enabled, "enabled") ?? true,
|
|
271
237
|
schedule,
|
|
272
238
|
timezone,
|
|
273
|
-
catchUpPolicy:
|
|
274
|
-
concurrencyPolicy:
|
|
239
|
+
catchUpPolicy: optionalEnum(input.catchUpPolicy, "catchUpPolicy", ["skip", "run_once", "run_all"] as const, DEFAULT_CATCH_UP),
|
|
240
|
+
concurrencyPolicy: optionalEnum(input.concurrencyPolicy, "concurrencyPolicy", ["skip", "queue", "replace"] as const, DEFAULT_CONCURRENCY),
|
|
275
241
|
orchestratorId: cleanString(input.orchestratorId, "orchestratorId", { required: true, max: 160 })!,
|
|
276
242
|
targetPolicy: normalizeTargetPolicy(input.targetPolicy),
|
|
277
243
|
taskTemplate: normalizeTaskTemplate(input.taskTemplate),
|
|
@@ -530,7 +496,7 @@ function dispatchAutomationRun(automation: Automation, run: AutomationRun, now:
|
|
|
530
496
|
updateRun(run.id, {
|
|
531
497
|
status: "failed",
|
|
532
498
|
finishedAt: now,
|
|
533
|
-
error:
|
|
499
|
+
error: errMessage(e),
|
|
534
500
|
}, now);
|
|
535
501
|
return { automation, run: getAutomationRun(run.id)! };
|
|
536
502
|
}
|
|
@@ -544,22 +510,17 @@ function dispatchOnDemandAutomation(
|
|
|
544
510
|
now: number,
|
|
545
511
|
): AutomationDispatchResult {
|
|
546
512
|
if (!orchestrator.providers.includes(policy.provider)) throw new ValidationError(`orchestrator ${orchestrator.id} does not have provider available: ${policy.provider}`);
|
|
547
|
-
const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
|
|
548
513
|
const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
|
|
549
514
|
const label = automationRunLabel(automation.id, run.id);
|
|
550
515
|
const command = createCommand({
|
|
551
516
|
type: "agent.spawn",
|
|
552
517
|
source: "automation",
|
|
553
518
|
target: orchestrator.agentId,
|
|
554
|
-
params: {
|
|
555
|
-
action: "spawn",
|
|
519
|
+
params: buildSpawnCommand({
|
|
556
520
|
provider: policy.provider,
|
|
557
|
-
|
|
558
|
-
providerModel: selection.providerModel,
|
|
559
|
-
effort: selection.effort,
|
|
521
|
+
modelParams: resolveSpawnModelParams(policy.provider, policy.model, policy.effort),
|
|
560
522
|
profile: policy.profile,
|
|
561
523
|
agentProfile,
|
|
562
|
-
...workspaceSpawnParams(),
|
|
563
524
|
cwd: policy.cwd || orchestrator.baseDir,
|
|
564
525
|
workspaceMode: policy.workspaceMode ?? "inherit",
|
|
565
526
|
label,
|
|
@@ -575,7 +536,7 @@ function dispatchOnDemandAutomation(
|
|
|
575
536
|
label,
|
|
576
537
|
createdBy: "automation",
|
|
577
538
|
}),
|
|
578
|
-
},
|
|
539
|
+
}),
|
|
579
540
|
});
|
|
580
541
|
const result = createRunTask(automation, run, `label:${label}`, now, {
|
|
581
542
|
targetMode: "on_demand_agent",
|
package/src/bus.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createActivityEvent, getAgent, getDb, heartbeat, markReady, mergeAgentM
|
|
|
3
3
|
import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
|
|
4
4
|
import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
|
|
5
5
|
import { createCommand, getCommand, updateCommand } from "./commands-db";
|
|
6
|
+
import { emitCommandEvent } from "./command-events";
|
|
6
7
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
7
8
|
import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
|
|
8
9
|
import { applyCommandToRecipe } from "./recipe-runner";
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
type BusFrame,
|
|
14
15
|
type RegisterFrame,
|
|
15
16
|
} from "agent-relay-sdk/protocol";
|
|
17
|
+
import { errMessage, isRecord, stringValue } from "agent-relay-sdk";
|
|
16
18
|
import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
|
|
17
19
|
import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, Task } from "./types";
|
|
18
20
|
|
|
@@ -77,7 +79,7 @@ export function busHandleMessage(ws: BusWebSocket, data: string | Buffer): void
|
|
|
77
79
|
try {
|
|
78
80
|
handleFrame(ws, frame);
|
|
79
81
|
} catch (error) {
|
|
80
|
-
sendError(ws, frame.id, "FRAME_FAILED",
|
|
82
|
+
sendError(ws, frame.id, "FRAME_FAILED", errMessage(error));
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -611,15 +613,6 @@ function providerStateKey(state: Record<string, unknown> | null): string | null
|
|
|
611
613
|
return [state.state, typeof state.reason === "string" ? state.reason : ""].join(":");
|
|
612
614
|
}
|
|
613
615
|
|
|
614
|
-
function emitCommandEvent(command: Command, type: string): void {
|
|
615
|
-
emitRelayEvent({
|
|
616
|
-
type,
|
|
617
|
-
source: command.source,
|
|
618
|
-
subject: command.id,
|
|
619
|
-
data: { command },
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
|
|
623
616
|
function sendCommandResult(
|
|
624
617
|
ws: BusWebSocket,
|
|
625
618
|
commandId: string,
|
|
@@ -638,14 +631,6 @@ function sendCommandResult(
|
|
|
638
631
|
});
|
|
639
632
|
}
|
|
640
633
|
|
|
641
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
642
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
function stringValue(value: unknown): string | undefined {
|
|
646
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
634
|
function send(ws: BusWebSocket, frame: Record<string, unknown>): void {
|
|
650
635
|
ws.send(JSON.stringify(frame));
|
|
651
636
|
}
|
package/src/cli.ts
CHANGED
|
@@ -44,6 +44,9 @@ import {
|
|
|
44
44
|
import { formatMemoryBrokerSmokeResult, runMemoryBrokerSmoke } from "./memory-broker-smoke";
|
|
45
45
|
import { MAX_BODY_BYTES, VERSION } from "./config";
|
|
46
46
|
import { DEFAULT_CONTEXT_PROBE_STATE_DIR, runContextProbe } from "agent-relay-sdk/context-probe";
|
|
47
|
+
import { shellQuote } from "agent-relay-sdk/shell-utils";
|
|
48
|
+
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
49
|
+
import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
|
|
47
50
|
|
|
48
51
|
const HELP = `
|
|
49
52
|
agent-relay ${VERSION}
|
|
@@ -62,6 +65,8 @@ Usage:
|
|
|
62
65
|
agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
|
|
63
66
|
agent-relay token <create|list|revoke|verify> [options]
|
|
64
67
|
agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
|
|
68
|
+
agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]
|
|
69
|
+
agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
|
|
65
70
|
agent-relay message <target> <body> [options]
|
|
66
71
|
agent-relay get-message <messageId> [--json|--body]
|
|
67
72
|
agent-relay /pair <target|accept|reject|send|status> [...]
|
|
@@ -219,6 +224,21 @@ Labels and tags
|
|
|
219
224
|
agent-relay /label [LABEL]
|
|
220
225
|
agent-relay /tags [TAG ...]
|
|
221
226
|
|
|
227
|
+
Isolated workspaces
|
|
228
|
+
If you are working in an isolated workspace (a git worktree on an agent
|
|
229
|
+
branch, not the main checkout), you do NOT rebase, merge, or push yourself —
|
|
230
|
+
Relay does. Just commit your work in the worktree, then:
|
|
231
|
+
agent-relay workspace ready Hand off: Relay rebases onto the latest base,
|
|
232
|
+
lands your work, and pushes.
|
|
233
|
+
agent-relay workspace status Show your workspace's branch, base, status.
|
|
234
|
+
The base branch will move as other agents land in parallel — that is normal,
|
|
235
|
+
let the merge handle it. Never push your branch yourself; it is local-only.
|
|
236
|
+
If typecheck/build fails on a missing module (a dep added to the base after
|
|
237
|
+
your worktree was created), do NOT run a clean install — it mutates the shared
|
|
238
|
+
node_modules. Instead refresh your worktree's deps in isolation:
|
|
239
|
+
agent-relay workspace deps Re-provision deps that have gone stale.
|
|
240
|
+
agent-relay workspace deps --check Report staleness without installing.
|
|
241
|
+
|
|
222
242
|
Rules of thumb
|
|
223
243
|
If you are handling relay message #123, reply with:
|
|
224
244
|
agent-relay /reply 123 "<response>"
|
|
@@ -347,6 +367,14 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
|
|
|
347
367
|
await handleTagsCommand(args.slice(1));
|
|
348
368
|
return "handled";
|
|
349
369
|
}
|
|
370
|
+
if (command === "workspace" || command === "workspaces") {
|
|
371
|
+
await handleWorkspaceCommand(args.slice(1));
|
|
372
|
+
return "handled";
|
|
373
|
+
}
|
|
374
|
+
if (command === "steward" || command === "stewards") {
|
|
375
|
+
await handleStewardCommand(args.slice(1));
|
|
376
|
+
return "handled";
|
|
377
|
+
}
|
|
350
378
|
if (command === "/reconnect") {
|
|
351
379
|
console.log("Reconnect is handled automatically by provider runners; use `agent-relay pair status` to inspect current pair state.");
|
|
352
380
|
return "handled";
|
|
@@ -605,10 +633,6 @@ async function readStdin(): Promise<string> {
|
|
|
605
633
|
return value;
|
|
606
634
|
}
|
|
607
635
|
|
|
608
|
-
function shellQuote(value: string): string {
|
|
609
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
636
|
function currentClaudeStatusLineCommand(): string | undefined {
|
|
613
637
|
const settingsPath = join(process.env.HOME || homedir(), ".claude", "settings.json");
|
|
614
638
|
try {
|
|
@@ -994,7 +1018,7 @@ async function exchangeOrchestratorBootstrapToken(relayUrl: string, bootstrapTok
|
|
|
994
1018
|
method: "POST",
|
|
995
1019
|
headers: {
|
|
996
1020
|
"Content-Type": "application/json",
|
|
997
|
-
|
|
1021
|
+
[RELAY_TOKEN_HEADER]: bootstrapToken,
|
|
998
1022
|
},
|
|
999
1023
|
body: JSON.stringify({ id, baseDir }),
|
|
1000
1024
|
});
|
|
@@ -1376,6 +1400,219 @@ async function handlePairCommand(args: string[]): Promise<void> {
|
|
|
1376
1400
|
}
|
|
1377
1401
|
}
|
|
1378
1402
|
|
|
1403
|
+
// The agent's own isolated-workspace id, published in AGENT_RELAY_WORKSPACE_JSON
|
|
1404
|
+
// by the orchestrator at spawn. Undefined for shared-workspace / non-managed agents.
|
|
1405
|
+
function currentWorkspaceId(): string | undefined {
|
|
1406
|
+
const json = process.env.AGENT_RELAY_WORKSPACE_JSON;
|
|
1407
|
+
if (!json) return undefined;
|
|
1408
|
+
try {
|
|
1409
|
+
const parsed = JSON.parse(json) as { id?: string };
|
|
1410
|
+
return typeof parsed.id === "string" && parsed.id ? parsed.id : undefined;
|
|
1411
|
+
} catch {
|
|
1412
|
+
return undefined;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function formatWorkspaceStatus(ws: any): string {
|
|
1417
|
+
const lines = [
|
|
1418
|
+
`Workspace ${ws.id}`,
|
|
1419
|
+
` status: ${ws.status}`,
|
|
1420
|
+
` branch: ${ws.branch ?? "(none)"}`,
|
|
1421
|
+
` base: ${ws.baseRef ?? "(none)"}`,
|
|
1422
|
+
` worktree: ${ws.worktreePath ?? "(none)"}`,
|
|
1423
|
+
];
|
|
1424
|
+
return lines.join("\n");
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Poll a command to a terminal state (succeeded/failed). Returns undefined on
|
|
1428
|
+
// timeout so the caller can degrade to "dispatched, check later".
|
|
1429
|
+
async function pollCommand(id: string, timeoutMs: number): Promise<{ status?: string; result?: unknown; error?: string } | undefined> {
|
|
1430
|
+
const deadline = Date.now() + timeoutMs;
|
|
1431
|
+
while (Date.now() < deadline) {
|
|
1432
|
+
const cmd = await apiRequest("GET", `/api/commands/${encodeURIComponent(id)}`) as { status?: string; result?: unknown; error?: string };
|
|
1433
|
+
if (cmd.status === "succeeded" || cmd.status === "failed") return cmd;
|
|
1434
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1435
|
+
}
|
|
1436
|
+
return undefined;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function formatDepsRefresh(result: WorkspaceDepsRefreshResult, checkOnly: boolean): string {
|
|
1440
|
+
if (result.error && (!result.dirs || result.dirs.length === 0)) return `Deps ${checkOnly ? "check" : "refresh"}: ${result.error}`;
|
|
1441
|
+
const lines: string[] = [];
|
|
1442
|
+
for (const d of result.dirs) {
|
|
1443
|
+
const icon = d.status === "installed" ? "↻" : d.status === "stale" ? "✗" : d.status === "failed" ? "!" : "✓";
|
|
1444
|
+
const detail = d.status === "ok" ? "up to date"
|
|
1445
|
+
: d.status === "installed" ? `reinstalled${d.wasSymlink ? " (was symlinked)" : ""}`
|
|
1446
|
+
: d.status === "stale" ? `stale — missing ${d.missing?.join(", ") ?? "?"}`
|
|
1447
|
+
: `failed — ${d.error ?? "unknown"}`;
|
|
1448
|
+
lines.push(` ${icon} ${d.dir}: ${detail}`);
|
|
1449
|
+
}
|
|
1450
|
+
const header = checkOnly
|
|
1451
|
+
? (result.stale ? "Deps check: stale dirs found — run `agent-relay workspace deps` to refresh" : "Deps check: all dirs up to date")
|
|
1452
|
+
: (result.refreshed ? "Deps refreshed" : result.error ? "Deps refresh hit errors" : "Deps already up to date");
|
|
1453
|
+
return [header, ...lines].join("\n");
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Self-service workspace lifecycle for agents in isolated worktrees (#205) plus
|
|
1457
|
+
// steward coordination (#208).
|
|
1458
|
+
// status — read your workspace row ready — hand off for review/landing
|
|
1459
|
+
// land — request a base merge (operator) list — all workspaces
|
|
1460
|
+
// diagnostics — joined briefing + recommended action
|
|
1461
|
+
// claim/release — TTL'd steward lease auto-merge yields to
|
|
1462
|
+
// cleanup-stale — guarded batch cleanup of stale worktrees (dry-run by default)
|
|
1463
|
+
async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
1464
|
+
const action = args[0];
|
|
1465
|
+
const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale", "deps"]);
|
|
1466
|
+
if (!action || !valid.has(action)) {
|
|
1467
|
+
throw new Error("Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]");
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
let id = currentWorkspaceId();
|
|
1471
|
+
let strategy: string | undefined;
|
|
1472
|
+
let purpose: string | undefined;
|
|
1473
|
+
let repo: string | undefined;
|
|
1474
|
+
let execute = false;
|
|
1475
|
+
let check = false;
|
|
1476
|
+
let json = false;
|
|
1477
|
+
for (let i = 1; i < args.length; i++) {
|
|
1478
|
+
const arg = args[i];
|
|
1479
|
+
if (arg === "--id" && i + 1 < args.length) id = args[++i];
|
|
1480
|
+
else if (arg === "--strategy" && i + 1 < args.length) strategy = args[++i];
|
|
1481
|
+
else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
|
|
1482
|
+
else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
|
|
1483
|
+
else if (arg === "--execute") execute = true;
|
|
1484
|
+
else if (arg === "--check") check = true;
|
|
1485
|
+
else if (arg === "--refresh") check = false; // explicit no-op default for clarity
|
|
1486
|
+
else if (arg === "--json") json = true;
|
|
1487
|
+
else throw new Error(`Unknown workspace option "${arg}".`);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
if (action === "list") {
|
|
1491
|
+
console.log(JSON.stringify(await apiRequest("GET", "/api/workspaces"), null, 2));
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
if (action === "cleanup-stale") {
|
|
1496
|
+
const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute });
|
|
1497
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if (!id) throw new Error("No current workspace detected (AGENT_RELAY_WORKSPACE_JSON unset). Pass --id WORKSPACE_ID — only isolated-workspace agents have one.");
|
|
1502
|
+
|
|
1503
|
+
if (action === "status") {
|
|
1504
|
+
const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`);
|
|
1505
|
+
if (json) console.log(JSON.stringify(ws, null, 2));
|
|
1506
|
+
else console.log(formatWorkspaceStatus(ws));
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (action === "diagnostics" || action === "diag") {
|
|
1511
|
+
console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// Refresh (or --check) deps the shared symlinked node_modules has gone stale on
|
|
1516
|
+
// (#51). Emits a host command; poll it to a terminal state so the agent gets a
|
|
1517
|
+
// synchronous result and knows when to re-run typecheck.
|
|
1518
|
+
if (action === "deps") {
|
|
1519
|
+
const from = await detectAgentId();
|
|
1520
|
+
const res = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, { action: "deps-refresh", agentId: from, checkOnly: check }) as { command?: { id?: string } };
|
|
1521
|
+
const commandId = res.command?.id;
|
|
1522
|
+
const settled = commandId ? await pollCommand(commandId, 180_000) : undefined;
|
|
1523
|
+
const result = (settled?.result ?? null) as WorkspaceDepsRefreshResult | null;
|
|
1524
|
+
if (json) {
|
|
1525
|
+
console.log(JSON.stringify(settled ?? res, null, 2));
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (settled?.status === "failed") {
|
|
1529
|
+
console.error(`Deps ${check ? "check" : "refresh"} failed: ${settled.error ?? "unknown error"}`);
|
|
1530
|
+
process.exitCode = 1;
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (!result) {
|
|
1534
|
+
console.log(`Deps ${check ? "check" : "refresh"} dispatched (command ${commandId ?? "?"}) — host did not report back in time. Check \`agent-relay workspace deps --json\`.`);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
console.log(formatDepsRefresh(result, check));
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const from = await detectAgentId();
|
|
1542
|
+
const actionBody: Record<string, unknown> =
|
|
1543
|
+
action === "ready" ? { action: "request-review", agentId: from }
|
|
1544
|
+
: action === "claim" ? { action: "claim", agentId: from, purpose }
|
|
1545
|
+
: action === "release" ? { action: "release-claim", agentId: from }
|
|
1546
|
+
: { action: "merge", agentId: from, ...(strategy ? { strategy } : {}) };
|
|
1547
|
+
const result = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, actionBody);
|
|
1548
|
+
if (json) {
|
|
1549
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
console.log(
|
|
1553
|
+
action === "ready" ? `Workspace ${id} marked ready — Relay will rebase onto the latest base, land, and push.`
|
|
1554
|
+
: action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
|
|
1555
|
+
: action === "release" ? `Workspace ${id} claim released.`
|
|
1556
|
+
: `Workspace ${id} merge requested (${strategy ?? "auto"}).`,
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Steward briefing commands (#208): queue of workspaces needing attention, a
|
|
1561
|
+
// per-workspace diagnostics inspection, and a check-command suggestion.
|
|
1562
|
+
async function handleStewardCommand(args: string[]): Promise<void> {
|
|
1563
|
+
const action = args[0];
|
|
1564
|
+
if (!action || !["queue", "inspect", "checks"].includes(action)) {
|
|
1565
|
+
throw new Error("Usage: agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]");
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
let repo: string | undefined;
|
|
1569
|
+
let json = false;
|
|
1570
|
+
const positional: string[] = [];
|
|
1571
|
+
for (let i = 1; i < args.length; i++) {
|
|
1572
|
+
const arg = args[i];
|
|
1573
|
+
if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
|
|
1574
|
+
else if (arg === "--json") json = true;
|
|
1575
|
+
else if (!arg!.startsWith("--")) positional.push(arg!);
|
|
1576
|
+
else throw new Error(`Unknown steward option "${arg}".`);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
if (action === "queue") {
|
|
1580
|
+
const all = await apiRequest("GET", "/api/workspaces") as any[];
|
|
1581
|
+
const attention = new Set(["conflict", "review_requested", "merge_planned"]);
|
|
1582
|
+
const queue = all.filter((ws) => attention.has(ws.status) && (!repo || ws.repoRoot === repo));
|
|
1583
|
+
if (json) { console.log(JSON.stringify(queue, null, 2)); return; }
|
|
1584
|
+
if (!queue.length) { console.log("Steward queue empty — no workspaces awaiting review, merge, or conflict resolution."); return; }
|
|
1585
|
+
for (const ws of queue) console.log(`${ws.status.padEnd(16)} ${ws.branch ?? ws.id} (${ws.repoRoot})`);
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
const id = positional[0];
|
|
1590
|
+
if (!id) throw new Error(`Usage: agent-relay steward ${action} WORKSPACE_ID [--json]`);
|
|
1591
|
+
|
|
1592
|
+
if (action === "inspect") {
|
|
1593
|
+
console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// checks: suggest validation commands from the workspace's changed files.
|
|
1598
|
+
const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
|
|
1599
|
+
const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
|
|
1600
|
+
const checks = suggestStewardChecks(files);
|
|
1601
|
+
console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
|
|
1605
|
+
// steward can refine; cheaper than re-deriving from project docs every run.
|
|
1606
|
+
function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
|
|
1607
|
+
const checks: Array<{ command: string; reason: string }> = [];
|
|
1608
|
+
const has = (re: RegExp) => files.some((f) => re.test(f));
|
|
1609
|
+
if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
|
|
1610
|
+
if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
|
|
1611
|
+
else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
|
|
1612
|
+
if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
|
|
1613
|
+
return checks;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1379
1616
|
async function handleMessageCommand(args: string[], defaults: { claimable?: boolean } = {}): Promise<void> {
|
|
1380
1617
|
const target = args[0];
|
|
1381
1618
|
if (!target || target.startsWith("--")) {
|
|
@@ -1989,7 +2226,7 @@ async function apiRequest(method: string, path: string, body?: unknown): Promise
|
|
|
1989
2226
|
const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
1990
2227
|
const headers: Record<string, string> = {};
|
|
1991
2228
|
const token = process.env.AGENT_RELAY_TOKEN;
|
|
1992
|
-
if (token) headers[
|
|
2229
|
+
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
1993
2230
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
1994
2231
|
const response = await fetch(new URL(path, baseUrl), {
|
|
1995
2232
|
method,
|
|
@@ -2009,7 +2246,7 @@ async function apiRawRequest(method: string, path: string, body: BodyInit, extra
|
|
|
2009
2246
|
const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
2010
2247
|
const headers: Record<string, string> = { ...extraHeaders };
|
|
2011
2248
|
const token = process.env.AGENT_RELAY_TOKEN;
|
|
2012
|
-
if (token) headers[
|
|
2249
|
+
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
2013
2250
|
const response = await fetch(new URL(path, baseUrl), { method, headers, body });
|
|
2014
2251
|
const text = await response.text();
|
|
2015
2252
|
const payload = text ? JSON.parse(text) : null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { emitRelayEvent } from "./events";
|
|
2
|
+
import type { Command } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Emit the relay event for a command. Single home — was duplicated as bus.ts's
|
|
6
|
+
* `emitCommandEvent(command, type)` and lifecycle-manager's private
|
|
7
|
+
* `emitCommand(command)` (which hardcoded "command.requested").
|
|
8
|
+
*
|
|
9
|
+
* The caller passes the exact event type: "command.requested" for a freshly
|
|
10
|
+
* created command (the relay auto-emits the follow-up "command.dispatched" — see
|
|
11
|
+
* `events.ts`), or `command.<status>` after an update.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NO `commandEventType(command)` derivation helper: a
|
|
14
|
+
* pending→"command.requested" mapping would diverge from the literal
|
|
15
|
+
* `command.<status>` used on the update path and spuriously trigger the
|
|
16
|
+
* requested→dispatched fan-out for a (valid, externally reachable) update to
|
|
17
|
+
* status "pending". Pass the type explicitly.
|
|
18
|
+
*/
|
|
19
|
+
export function emitCommandEvent(command: Command, type: string): void {
|
|
20
|
+
emitRelayEvent({
|
|
21
|
+
type,
|
|
22
|
+
source: command.source,
|
|
23
|
+
subject: command.id,
|
|
24
|
+
data: { command },
|
|
25
|
+
});
|
|
26
|
+
}
|