agent-relay-server 0.16.0 → 0.18.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 +201 -1
- package/package.json +2 -2
- package/public/index.html +100 -25
- package/public/sw.js +51 -16
- package/runner/src/adapter.ts +1 -4
- package/runner/src/config.ts +1 -4
- package/scripts/orchestrator-spawn-smoke.ts +2 -1
- package/src/automations.ts +8 -31
- package/src/bus.ts +2 -17
- package/src/cli.ts +179 -3
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +64 -22
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/db.ts +36 -18
- package/src/index.ts +99 -4
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +26 -20
- package/src/managed-policy.ts +8 -26
- package/src/mcp.ts +19 -43
- package/src/memory-broker-smoke.ts +3 -1
- package/src/memory-command-broker.ts +1 -4
- package/src/memory-http-broker.ts +1 -4
- package/src/memory-service.ts +1 -4
- package/src/memory-sqlite-broker.ts +1 -8
- package/src/provider-catalog-store.ts +3 -11
- package/src/recipe-loader.ts +1 -4
- package/src/recipe-validator.ts +1 -4
- package/src/routes.ts +290 -139
- package/src/security.ts +3 -7
- package/src/spawn-command.ts +150 -0
- package/src/sse.ts +1 -4
- package/src/steward.ts +16 -21
- package/src/upgrade.ts +3 -2
- package/src/utils.ts +38 -0
- package/src/validation.ts +28 -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";
|
|
16
|
+
import { cleanString } from "./validation";
|
|
15
17
|
import { getAgentProfile, getSpawnPolicy } from "./config-store";
|
|
16
|
-
import {
|
|
18
|
+
import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
|
|
19
|
+
import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
|
|
20
|
+
import { 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,18 +119,6 @@ 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
122
|
function cleanStringArray(value: unknown, field: string): string[] | undefined {
|
|
142
123
|
if (value === undefined || value === null) return undefined;
|
|
143
124
|
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
|
|
@@ -544,19 +525,15 @@ function dispatchOnDemandAutomation(
|
|
|
544
525
|
now: number,
|
|
545
526
|
): AutomationDispatchResult {
|
|
546
527
|
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
528
|
const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
|
|
549
529
|
const label = automationRunLabel(automation.id, run.id);
|
|
550
530
|
const command = createCommand({
|
|
551
531
|
type: "agent.spawn",
|
|
552
532
|
source: "automation",
|
|
553
533
|
target: orchestrator.agentId,
|
|
554
|
-
params: {
|
|
555
|
-
action: "spawn",
|
|
534
|
+
params: buildSpawnCommand({
|
|
556
535
|
provider: policy.provider,
|
|
557
|
-
|
|
558
|
-
providerModel: selection.providerModel,
|
|
559
|
-
effort: selection.effort,
|
|
536
|
+
modelParams: resolveSpawnModelParams(policy.provider, policy.model, policy.effort),
|
|
560
537
|
profile: policy.profile,
|
|
561
538
|
agentProfile,
|
|
562
539
|
cwd: policy.cwd || orchestrator.baseDir,
|
|
@@ -574,7 +551,7 @@ function dispatchOnDemandAutomation(
|
|
|
574
551
|
label,
|
|
575
552
|
createdBy: "automation",
|
|
576
553
|
}),
|
|
577
|
-
},
|
|
554
|
+
}),
|
|
578
555
|
});
|
|
579
556
|
const result = createRunTask(automation, run, `label:${label}`, now, {
|
|
580
557
|
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 { 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
|
|
|
@@ -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,7 @@ 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 { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
47
48
|
|
|
48
49
|
const HELP = `
|
|
49
50
|
agent-relay ${VERSION}
|
|
@@ -62,6 +63,8 @@ Usage:
|
|
|
62
63
|
agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
|
|
63
64
|
agent-relay token <create|list|revoke|verify> [options]
|
|
64
65
|
agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
|
|
66
|
+
agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--execute] [--json]
|
|
67
|
+
agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
|
|
65
68
|
agent-relay message <target> <body> [options]
|
|
66
69
|
agent-relay get-message <messageId> [--json|--body]
|
|
67
70
|
agent-relay /pair <target|accept|reject|send|status> [...]
|
|
@@ -219,6 +222,16 @@ Labels and tags
|
|
|
219
222
|
agent-relay /label [LABEL]
|
|
220
223
|
agent-relay /tags [TAG ...]
|
|
221
224
|
|
|
225
|
+
Isolated workspaces
|
|
226
|
+
If you are working in an isolated workspace (a git worktree on an agent
|
|
227
|
+
branch, not the main checkout), you do NOT rebase, merge, or push yourself —
|
|
228
|
+
Relay does. Just commit your work in the worktree, then:
|
|
229
|
+
agent-relay workspace ready Hand off: Relay rebases onto the latest base,
|
|
230
|
+
lands your work, and pushes.
|
|
231
|
+
agent-relay workspace status Show your workspace's branch, base, status.
|
|
232
|
+
The base branch will move as other agents land in parallel — that is normal,
|
|
233
|
+
let the merge handle it. Never push your branch yourself; it is local-only.
|
|
234
|
+
|
|
222
235
|
Rules of thumb
|
|
223
236
|
If you are handling relay message #123, reply with:
|
|
224
237
|
agent-relay /reply 123 "<response>"
|
|
@@ -347,6 +360,14 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
|
|
|
347
360
|
await handleTagsCommand(args.slice(1));
|
|
348
361
|
return "handled";
|
|
349
362
|
}
|
|
363
|
+
if (command === "workspace" || command === "workspaces") {
|
|
364
|
+
await handleWorkspaceCommand(args.slice(1));
|
|
365
|
+
return "handled";
|
|
366
|
+
}
|
|
367
|
+
if (command === "steward" || command === "stewards") {
|
|
368
|
+
await handleStewardCommand(args.slice(1));
|
|
369
|
+
return "handled";
|
|
370
|
+
}
|
|
350
371
|
if (command === "/reconnect") {
|
|
351
372
|
console.log("Reconnect is handled automatically by provider runners; use `agent-relay pair status` to inspect current pair state.");
|
|
352
373
|
return "handled";
|
|
@@ -994,7 +1015,7 @@ async function exchangeOrchestratorBootstrapToken(relayUrl: string, bootstrapTok
|
|
|
994
1015
|
method: "POST",
|
|
995
1016
|
headers: {
|
|
996
1017
|
"Content-Type": "application/json",
|
|
997
|
-
|
|
1018
|
+
[RELAY_TOKEN_HEADER]: bootstrapToken,
|
|
998
1019
|
},
|
|
999
1020
|
body: JSON.stringify({ id, baseDir }),
|
|
1000
1021
|
});
|
|
@@ -1376,6 +1397,161 @@ async function handlePairCommand(args: string[]): Promise<void> {
|
|
|
1376
1397
|
}
|
|
1377
1398
|
}
|
|
1378
1399
|
|
|
1400
|
+
// The agent's own isolated-workspace id, published in AGENT_RELAY_WORKSPACE_JSON
|
|
1401
|
+
// by the orchestrator at spawn. Undefined for shared-workspace / non-managed agents.
|
|
1402
|
+
function currentWorkspaceId(): string | undefined {
|
|
1403
|
+
const json = process.env.AGENT_RELAY_WORKSPACE_JSON;
|
|
1404
|
+
if (!json) return undefined;
|
|
1405
|
+
try {
|
|
1406
|
+
const parsed = JSON.parse(json) as { id?: string };
|
|
1407
|
+
return typeof parsed.id === "string" && parsed.id ? parsed.id : undefined;
|
|
1408
|
+
} catch {
|
|
1409
|
+
return undefined;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function formatWorkspaceStatus(ws: any): string {
|
|
1414
|
+
const lines = [
|
|
1415
|
+
`Workspace ${ws.id}`,
|
|
1416
|
+
` status: ${ws.status}`,
|
|
1417
|
+
` branch: ${ws.branch ?? "(none)"}`,
|
|
1418
|
+
` base: ${ws.baseRef ?? "(none)"}`,
|
|
1419
|
+
` worktree: ${ws.worktreePath ?? "(none)"}`,
|
|
1420
|
+
];
|
|
1421
|
+
return lines.join("\n");
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Self-service workspace lifecycle for agents in isolated worktrees (#205) plus
|
|
1425
|
+
// steward coordination (#208).
|
|
1426
|
+
// status — read your workspace row ready — hand off for review/landing
|
|
1427
|
+
// land — request a base merge (operator) list — all workspaces
|
|
1428
|
+
// diagnostics — joined briefing + recommended action
|
|
1429
|
+
// claim/release — TTL'd steward lease auto-merge yields to
|
|
1430
|
+
// cleanup-stale — guarded batch cleanup of stale worktrees (dry-run by default)
|
|
1431
|
+
async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
1432
|
+
const action = args[0];
|
|
1433
|
+
const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale"]);
|
|
1434
|
+
if (!action || !valid.has(action)) {
|
|
1435
|
+
throw new Error("Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--execute] [--json]");
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
let id = currentWorkspaceId();
|
|
1439
|
+
let strategy: string | undefined;
|
|
1440
|
+
let purpose: string | undefined;
|
|
1441
|
+
let repo: string | undefined;
|
|
1442
|
+
let execute = false;
|
|
1443
|
+
let json = false;
|
|
1444
|
+
for (let i = 1; i < args.length; i++) {
|
|
1445
|
+
const arg = args[i];
|
|
1446
|
+
if (arg === "--id" && i + 1 < args.length) id = args[++i];
|
|
1447
|
+
else if (arg === "--strategy" && i + 1 < args.length) strategy = args[++i];
|
|
1448
|
+
else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
|
|
1449
|
+
else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
|
|
1450
|
+
else if (arg === "--execute") execute = true;
|
|
1451
|
+
else if (arg === "--json") json = true;
|
|
1452
|
+
else throw new Error(`Unknown workspace option "${arg}".`);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
if (action === "list") {
|
|
1456
|
+
console.log(JSON.stringify(await apiRequest("GET", "/api/workspaces"), null, 2));
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (action === "cleanup-stale") {
|
|
1461
|
+
const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute });
|
|
1462
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
if (!id) throw new Error("No current workspace detected (AGENT_RELAY_WORKSPACE_JSON unset). Pass --id WORKSPACE_ID — only isolated-workspace agents have one.");
|
|
1467
|
+
|
|
1468
|
+
if (action === "status") {
|
|
1469
|
+
const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`);
|
|
1470
|
+
if (json) console.log(JSON.stringify(ws, null, 2));
|
|
1471
|
+
else console.log(formatWorkspaceStatus(ws));
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (action === "diagnostics" || action === "diag") {
|
|
1476
|
+
console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const from = await detectAgentId();
|
|
1481
|
+
const actionBody: Record<string, unknown> =
|
|
1482
|
+
action === "ready" ? { action: "request-review", agentId: from }
|
|
1483
|
+
: action === "claim" ? { action: "claim", agentId: from, purpose }
|
|
1484
|
+
: action === "release" ? { action: "release-claim", agentId: from }
|
|
1485
|
+
: { action: "merge", agentId: from, ...(strategy ? { strategy } : {}) };
|
|
1486
|
+
const result = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, actionBody);
|
|
1487
|
+
if (json) {
|
|
1488
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
console.log(
|
|
1492
|
+
action === "ready" ? `Workspace ${id} marked ready — Relay will rebase onto the latest base, land, and push.`
|
|
1493
|
+
: action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
|
|
1494
|
+
: action === "release" ? `Workspace ${id} claim released.`
|
|
1495
|
+
: `Workspace ${id} merge requested (${strategy ?? "auto"}).`,
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Steward briefing commands (#208): queue of workspaces needing attention, a
|
|
1500
|
+
// per-workspace diagnostics inspection, and a check-command suggestion.
|
|
1501
|
+
async function handleStewardCommand(args: string[]): Promise<void> {
|
|
1502
|
+
const action = args[0];
|
|
1503
|
+
if (!action || !["queue", "inspect", "checks"].includes(action)) {
|
|
1504
|
+
throw new Error("Usage: agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]");
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
let repo: string | undefined;
|
|
1508
|
+
let json = false;
|
|
1509
|
+
const positional: string[] = [];
|
|
1510
|
+
for (let i = 1; i < args.length; i++) {
|
|
1511
|
+
const arg = args[i];
|
|
1512
|
+
if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
|
|
1513
|
+
else if (arg === "--json") json = true;
|
|
1514
|
+
else if (!arg!.startsWith("--")) positional.push(arg!);
|
|
1515
|
+
else throw new Error(`Unknown steward option "${arg}".`);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
if (action === "queue") {
|
|
1519
|
+
const all = await apiRequest("GET", "/api/workspaces") as any[];
|
|
1520
|
+
const attention = new Set(["conflict", "review_requested", "merge_planned"]);
|
|
1521
|
+
const queue = all.filter((ws) => attention.has(ws.status) && (!repo || ws.repoRoot === repo));
|
|
1522
|
+
if (json) { console.log(JSON.stringify(queue, null, 2)); return; }
|
|
1523
|
+
if (!queue.length) { console.log("Steward queue empty — no workspaces awaiting review, merge, or conflict resolution."); return; }
|
|
1524
|
+
for (const ws of queue) console.log(`${ws.status.padEnd(16)} ${ws.branch ?? ws.id} (${ws.repoRoot})`);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const id = positional[0];
|
|
1529
|
+
if (!id) throw new Error(`Usage: agent-relay steward ${action} WORKSPACE_ID [--json]`);
|
|
1530
|
+
|
|
1531
|
+
if (action === "inspect") {
|
|
1532
|
+
console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// checks: suggest validation commands from the workspace's changed files.
|
|
1537
|
+
const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
|
|
1538
|
+
const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
|
|
1539
|
+
const checks = suggestStewardChecks(files);
|
|
1540
|
+
console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
|
|
1544
|
+
// steward can refine; cheaper than re-deriving from project docs every run.
|
|
1545
|
+
function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
|
|
1546
|
+
const checks: Array<{ command: string; reason: string }> = [];
|
|
1547
|
+
const has = (re: RegExp) => files.some((f) => re.test(f));
|
|
1548
|
+
if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
|
|
1549
|
+
if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
|
|
1550
|
+
else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
|
|
1551
|
+
if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
|
|
1552
|
+
return checks;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1379
1555
|
async function handleMessageCommand(args: string[], defaults: { claimable?: boolean } = {}): Promise<void> {
|
|
1380
1556
|
const target = args[0];
|
|
1381
1557
|
if (!target || target.startsWith("--")) {
|
|
@@ -1989,7 +2165,7 @@ async function apiRequest(method: string, path: string, body?: unknown): Promise
|
|
|
1989
2165
|
const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
1990
2166
|
const headers: Record<string, string> = {};
|
|
1991
2167
|
const token = process.env.AGENT_RELAY_TOKEN;
|
|
1992
|
-
if (token) headers[
|
|
2168
|
+
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
1993
2169
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
1994
2170
|
const response = await fetch(new URL(path, baseUrl), {
|
|
1995
2171
|
method,
|
|
@@ -2009,7 +2185,7 @@ async function apiRawRequest(method: string, path: string, body: BodyInit, extra
|
|
|
2009
2185
|
const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
2010
2186
|
const headers: Record<string, string> = { ...extraHeaders };
|
|
2011
2187
|
const token = process.env.AGENT_RELAY_TOKEN;
|
|
2012
|
-
if (token) headers[
|
|
2188
|
+
if (token) headers[RELAY_TOKEN_HEADER] = token;
|
|
2013
2189
|
const response = await fetch(new URL(path, baseUrl), { method, headers, body });
|
|
2014
2190
|
const text = await response.text();
|
|
2015
2191
|
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
|
+
}
|
package/src/config-store.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getDb, ValidationError } from "./db";
|
|
2
|
+
import { cleanString } from "./validation";
|
|
2
3
|
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
4
|
+
import { isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
3
5
|
import type {
|
|
4
6
|
AgentProfile,
|
|
5
7
|
AgentProfileBase,
|
|
@@ -12,6 +14,7 @@ import type {
|
|
|
12
14
|
SpawnPolicy,
|
|
13
15
|
SpawnProvider,
|
|
14
16
|
StewardConfig,
|
|
17
|
+
WorkspaceConfig,
|
|
15
18
|
} from "./types";
|
|
16
19
|
|
|
17
20
|
const CONFIG_HISTORY_LIMIT = 50;
|
|
@@ -21,18 +24,17 @@ const STEWARD_NAMESPACE = "steward";
|
|
|
21
24
|
const STEWARD_KEY = "default";
|
|
22
25
|
const INSIGHTS_NAMESPACE = "insights";
|
|
23
26
|
const INSIGHTS_KEY = "default";
|
|
24
|
-
const
|
|
27
|
+
const WORKSPACE_NAMESPACE = "workspace";
|
|
28
|
+
const WORKSPACE_KEY = "default";
|
|
25
29
|
const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
|
|
26
30
|
const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
|
|
27
31
|
const VALID_PROFILE_INSTRUCTION_POLICIES = ["allow", "ignore"] as const;
|
|
28
32
|
const VALID_PROFILE_CATEGORY_MODES = ["host", "profile", "repo", "none"] as const;
|
|
29
33
|
const VALID_PROFILE_ASSET_SOURCES = ["relay", "repo", "inline", "provider"] as const;
|
|
30
34
|
const VALID_PROFILE_FILESYSTEM_SCOPES = ["repo", "workspace", "host"] as const;
|
|
31
|
-
const VALID_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
32
35
|
const VALID_PERMISSION_MODES = ["open", "guarded", "read-only"] as const;
|
|
33
36
|
const VALID_POLICY_MODES = ["always-on", "on-demand"] as const;
|
|
34
37
|
const VALID_MANAGED_STATUSES = ["stopped", "starting", "running", "stopping", "backoff"] as const;
|
|
35
|
-
const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
|
|
36
38
|
const BUILT_IN_AGENT_PROFILE_NAMES = new Set(["default-relay", "minimal", "isolated-research"]);
|
|
37
39
|
|
|
38
40
|
const BUILT_IN_AGENT_PROFILES: AgentProfile[] = [
|
|
@@ -153,22 +155,6 @@ function rowToManagedAgentState(row: ManagedAgentStateRow): ManagedAgentState {
|
|
|
153
155
|
};
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
157
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function cleanString(value: unknown, field: string, opts: { required?: boolean; max?: number } = {}): string | undefined {
|
|
161
|
-
if (value === undefined || value === null) {
|
|
162
|
-
if (opts.required) throw new ValidationError(`${field} required`);
|
|
163
|
-
return undefined;
|
|
164
|
-
}
|
|
165
|
-
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
166
|
-
const trimmed = value.trim();
|
|
167
|
-
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
168
|
-
if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
169
|
-
return trimmed || undefined;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
158
|
function cleanStringArray(value: unknown, field: string, opts: { required?: boolean } = {}): string[] {
|
|
173
159
|
if (value === undefined || value === null) {
|
|
174
160
|
if (opts.required) throw new ValidationError(`${field} required`);
|
|
@@ -340,7 +326,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
|
|
|
340
326
|
enabled: value.enabled === undefined ? true : cleanBoolean(value.enabled, "enabled"),
|
|
341
327
|
orchestratorId: cleanString(value.orchestratorId, "orchestratorId", { required: true, max: 200 })!,
|
|
342
328
|
cwd: cleanString(value.cwd, "cwd", { required: true, max: 1000 })!,
|
|
343
|
-
provider: cleanEnum(value.provider, "provider",
|
|
329
|
+
provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
|
|
344
330
|
workspaceMode: value.workspaceMode === undefined || value.workspaceMode === null ? "inherit" : cleanEnum(value.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
|
|
345
331
|
rig: cleanString(value.rig, "rig", { max: 120 }),
|
|
346
332
|
model: cleanString(value.model, "model", { max: 120 }),
|
|
@@ -408,7 +394,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
|
|
|
408
394
|
if (!isRecord(value)) throw new ValidationError("steward config value must be an object");
|
|
409
395
|
const config: StewardConfig = {
|
|
410
396
|
enabled: value.enabled === undefined ? false : cleanBoolean(value.enabled, "enabled"),
|
|
411
|
-
provider: cleanEnum(value.provider, "provider",
|
|
397
|
+
provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
|
|
412
398
|
model: cleanString(value.model, "model", { max: 120 }),
|
|
413
399
|
effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
|
|
414
400
|
permissionMode: (value.permissionMode === undefined || value.permissionMode === null
|
|
@@ -472,12 +458,34 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
|
|
|
472
458
|
};
|
|
473
459
|
}
|
|
474
460
|
|
|
461
|
+
// Global workspace provisioning config for isolated worktrees (#159 follow-up).
|
|
462
|
+
// Defaults seed the two untracked paths an isolated agent almost always needs:
|
|
463
|
+
// the agent guide and the rig config, both gitignored so a fresh worktree lacks them.
|
|
464
|
+
const WORKSPACE_CONFIG_DEFAULTS: WorkspaceConfig = {
|
|
465
|
+
symlinkPaths: ["AGENTS.md", ".claude-rig"],
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
|
|
469
|
+
if (!isRecord(value)) throw new ValidationError("workspace config value must be an object");
|
|
470
|
+
const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths");
|
|
471
|
+
// Reject absolute paths and parent-traversal up front: symlink sources must stay
|
|
472
|
+
// inside the main checkout. The orchestrator re-checks containment at link time,
|
|
473
|
+
// but failing here gives the operator immediate feedback in the dashboard.
|
|
474
|
+
for (const entry of symlinkPaths) {
|
|
475
|
+
if (entry.startsWith("/") || entry.split(/[\\/]/).includes("..")) {
|
|
476
|
+
throw new ValidationError(`symlinkPaths entry must be a relative path within the repo: ${entry}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return { symlinkPaths };
|
|
480
|
+
}
|
|
481
|
+
|
|
475
482
|
function normalizeValue(namespace: string, key: string, value: unknown): unknown {
|
|
476
483
|
if (value === undefined) throw new ValidationError("value required");
|
|
477
484
|
if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
|
|
478
485
|
if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
|
|
479
486
|
if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
|
|
480
487
|
if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
|
|
488
|
+
if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
|
|
481
489
|
if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
|
|
482
490
|
return value;
|
|
483
491
|
}
|
|
@@ -614,6 +622,40 @@ export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEnt
|
|
|
614
622
|
return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
|
|
615
623
|
}
|
|
616
624
|
|
|
625
|
+
/** Global workspace config, merged over defaults (always returns a usable value). */
|
|
626
|
+
export function getWorkspaceConfig(): WorkspaceConfig {
|
|
627
|
+
const entry = getConfig<Partial<WorkspaceConfig>>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
|
|
628
|
+
if (!entry) return { ...WORKSPACE_CONFIG_DEFAULTS };
|
|
629
|
+
return validateWorkspaceConfig({ ...WORKSPACE_CONFIG_DEFAULTS, ...entry.value });
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function getWorkspaceConfigEntry(): ConfigEntry<WorkspaceConfig> {
|
|
633
|
+
const entry = getConfig<WorkspaceConfig>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
|
|
634
|
+
return entry ?? {
|
|
635
|
+
namespace: WORKSPACE_NAMESPACE,
|
|
636
|
+
key: WORKSPACE_KEY,
|
|
637
|
+
value: { ...WORKSPACE_CONFIG_DEFAULTS },
|
|
638
|
+
version: 0,
|
|
639
|
+
updatedAt: "default",
|
|
640
|
+
updatedBy: "system",
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function setWorkspaceConfig(value: unknown, updatedBy?: string): ConfigEntry<WorkspaceConfig> {
|
|
645
|
+
return setConfig(WORKSPACE_NAMESPACE, WORKSPACE_KEY, value as WorkspaceConfig, updatedBy);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Spawn-param fragment carrying the global workspace symlink list to the orchestrator.
|
|
650
|
+
* Spread into every spawn command's params next to `agentProfile` so any isolated
|
|
651
|
+
* worktree — direct spawn, managed policy, restart, automation — provisions the same
|
|
652
|
+
* untracked paths. Returns `{}` when nothing is configured (keeps params lean).
|
|
653
|
+
*/
|
|
654
|
+
export function workspaceSpawnParams(): { workspaceSymlinks?: string[] } {
|
|
655
|
+
const { symlinkPaths } = getWorkspaceConfig();
|
|
656
|
+
return symlinkPaths.length ? { workspaceSymlinks: symlinkPaths } : {};
|
|
657
|
+
}
|
|
658
|
+
|
|
617
659
|
function builtInProfileEntry(profile: AgentProfile): ConfigEntry<AgentProfile> {
|
|
618
660
|
return {
|
|
619
661
|
namespace: AGENT_PROFILE_NAMESPACE,
|
|
@@ -662,7 +704,7 @@ function listManagedAgentStates(): ManagedAgentState[] {
|
|
|
662
704
|
|
|
663
705
|
export function upsertManagedAgentState(input: ManagedAgentStateInput): ManagedAgentState {
|
|
664
706
|
if (!VALID_MANAGED_STATUSES.includes(input.status)) throw new ValidationError("status must be a managed-agent status");
|
|
665
|
-
if (!
|
|
707
|
+
if (!SPAWN_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
|
|
666
708
|
const now = input.updatedAt ?? Date.now();
|
|
667
709
|
getDb().query(`
|
|
668
710
|
INSERT INTO managed_agent_state (
|
package/src/connectors.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { ConnectorAction, ConnectorActionResult, ConnectorManifest, ConnectorSummary } from "./types";
|
|
5
5
|
import { ValidationError } from "./db";
|
|
6
|
+
import { isRecord } from "agent-relay-sdk";
|
|
6
7
|
|
|
7
8
|
const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
|
|
8
9
|
const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
|
|
@@ -34,10 +35,6 @@ function readRecordFile(path: string): Record<string, unknown> | undefined {
|
|
|
34
35
|
return isRecord(parsed) ? parsed : undefined;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
38
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
38
|
function validateConnectorId(id: string): void {
|
|
42
39
|
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(id)) {
|
|
43
40
|
throw new ValidationError("connector id must be lowercase alphanumeric plus dot, underscore, or dash");
|
package/src/contracts.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isRecord, stringValue } from "agent-relay-sdk";
|
|
2
|
+
|
|
1
3
|
export const CONTRACT_VERSIONS = {
|
|
2
4
|
relayApi: 1,
|
|
3
5
|
orchestratorProtocol: 3,
|
|
@@ -95,10 +97,6 @@ function rangeLabel(requirement: ContractRequirement): string {
|
|
|
95
97
|
return `>=${requirement.min} <${requirement.maxExclusive}`;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
function stringValue(value: unknown): string | undefined {
|
|
99
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
100
|
function positiveInteger(value: unknown): number | undefined {
|
|
103
101
|
const parsed = typeof value === "number"
|
|
104
102
|
? value
|
|
@@ -106,7 +104,3 @@ function positiveInteger(value: unknown): number | undefined {
|
|
|
106
104
|
if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) return undefined;
|
|
107
105
|
return parsed;
|
|
108
106
|
}
|
|
109
|
-
|
|
110
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
111
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
-
}
|