agent-relay-server 0.17.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 +101 -1
- package/package.json +2 -2
- package/public/index.html +25 -22
- 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 +9 -33
- package/src/bus.ts +2 -17
- package/src/cli.ts +179 -3
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +5 -22
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/db.ts +14 -15
- package/src/index.ts +99 -4
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +26 -20
- package/src/managed-policy.ts +9 -28
- 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 +249 -142
- 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/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,
|
|
@@ -24,18 +26,15 @@ const INSIGHTS_NAMESPACE = "insights";
|
|
|
24
26
|
const INSIGHTS_KEY = "default";
|
|
25
27
|
const WORKSPACE_NAMESPACE = "workspace";
|
|
26
28
|
const WORKSPACE_KEY = "default";
|
|
27
|
-
const VALID_PROVIDERS = ["claude", "codex"] as const;
|
|
28
29
|
const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
|
|
29
30
|
const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
|
|
30
31
|
const VALID_PROFILE_INSTRUCTION_POLICIES = ["allow", "ignore"] as const;
|
|
31
32
|
const VALID_PROFILE_CATEGORY_MODES = ["host", "profile", "repo", "none"] as const;
|
|
32
33
|
const VALID_PROFILE_ASSET_SOURCES = ["relay", "repo", "inline", "provider"] as const;
|
|
33
34
|
const VALID_PROFILE_FILESYSTEM_SCOPES = ["repo", "workspace", "host"] as const;
|
|
34
|
-
const VALID_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
35
35
|
const VALID_PERMISSION_MODES = ["open", "guarded", "read-only"] as const;
|
|
36
36
|
const VALID_POLICY_MODES = ["always-on", "on-demand"] as const;
|
|
37
37
|
const VALID_MANAGED_STATUSES = ["stopped", "starting", "running", "stopping", "backoff"] as const;
|
|
38
|
-
const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
|
|
39
38
|
const BUILT_IN_AGENT_PROFILE_NAMES = new Set(["default-relay", "minimal", "isolated-research"]);
|
|
40
39
|
|
|
41
40
|
const BUILT_IN_AGENT_PROFILES: AgentProfile[] = [
|
|
@@ -156,22 +155,6 @@ function rowToManagedAgentState(row: ManagedAgentStateRow): ManagedAgentState {
|
|
|
156
155
|
};
|
|
157
156
|
}
|
|
158
157
|
|
|
159
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
160
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function cleanString(value: unknown, field: string, opts: { required?: boolean; max?: number } = {}): string | undefined {
|
|
164
|
-
if (value === undefined || value === null) {
|
|
165
|
-
if (opts.required) throw new ValidationError(`${field} required`);
|
|
166
|
-
return undefined;
|
|
167
|
-
}
|
|
168
|
-
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
169
|
-
const trimmed = value.trim();
|
|
170
|
-
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
171
|
-
if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
172
|
-
return trimmed || undefined;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
158
|
function cleanStringArray(value: unknown, field: string, opts: { required?: boolean } = {}): string[] {
|
|
176
159
|
if (value === undefined || value === null) {
|
|
177
160
|
if (opts.required) throw new ValidationError(`${field} required`);
|
|
@@ -343,7 +326,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
|
|
|
343
326
|
enabled: value.enabled === undefined ? true : cleanBoolean(value.enabled, "enabled"),
|
|
344
327
|
orchestratorId: cleanString(value.orchestratorId, "orchestratorId", { required: true, max: 200 })!,
|
|
345
328
|
cwd: cleanString(value.cwd, "cwd", { required: true, max: 1000 })!,
|
|
346
|
-
provider: cleanEnum(value.provider, "provider",
|
|
329
|
+
provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
|
|
347
330
|
workspaceMode: value.workspaceMode === undefined || value.workspaceMode === null ? "inherit" : cleanEnum(value.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
|
|
348
331
|
rig: cleanString(value.rig, "rig", { max: 120 }),
|
|
349
332
|
model: cleanString(value.model, "model", { max: 120 }),
|
|
@@ -411,7 +394,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
|
|
|
411
394
|
if (!isRecord(value)) throw new ValidationError("steward config value must be an object");
|
|
412
395
|
const config: StewardConfig = {
|
|
413
396
|
enabled: value.enabled === undefined ? false : cleanBoolean(value.enabled, "enabled"),
|
|
414
|
-
provider: cleanEnum(value.provider, "provider",
|
|
397
|
+
provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
|
|
415
398
|
model: cleanString(value.model, "model", { max: 120 }),
|
|
416
399
|
effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
|
|
417
400
|
permissionMode: (value.permissionMode === undefined || value.permissionMode === null
|
|
@@ -721,7 +704,7 @@ function listManagedAgentStates(): ManagedAgentState[] {
|
|
|
721
704
|
|
|
722
705
|
export function upsertManagedAgentState(input: ManagedAgentStateInput): ManagedAgentState {
|
|
723
706
|
if (!VALID_MANAGED_STATUSES.includes(input.status)) throw new ValidationError("status must be a managed-agent status");
|
|
724
|
-
if (!
|
|
707
|
+
if (!SPAWN_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
|
|
725
708
|
const now = input.updatedAt ?? Date.now();
|
|
726
709
|
getDb().query(`
|
|
727
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
|
-
}
|
package/src/db.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { isRecord, stringValue } from "agent-relay-sdk";
|
|
3
4
|
import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "./config.ts";
|
|
5
|
+
import { parseJson } from "./utils";
|
|
4
6
|
import {
|
|
5
7
|
CONTRACT_REQUIREMENTS,
|
|
6
8
|
contractCompatibility,
|
|
@@ -1173,13 +1175,6 @@ export function getDb(): Database {
|
|
|
1173
1175
|
export class ValidationError extends Error {}
|
|
1174
1176
|
class ClaimError extends Error {}
|
|
1175
1177
|
|
|
1176
|
-
function parseJson<T>(raw: string, fallback: T): T {
|
|
1177
|
-
try {
|
|
1178
|
-
return JSON.parse(raw);
|
|
1179
|
-
} catch {
|
|
1180
|
-
return fallback;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
1178
|
|
|
1184
1179
|
function parseStringArray(raw: string): string[] {
|
|
1185
1180
|
const parsed = parseJson<unknown>(raw, []);
|
|
@@ -1191,10 +1186,6 @@ function normalizeTags(tags: string[] | undefined): string[] {
|
|
|
1191
1186
|
return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
|
|
1192
1187
|
}
|
|
1193
1188
|
|
|
1194
|
-
function stringValue(value: unknown): string | undefined {
|
|
1195
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
1189
|
function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
|
|
1199
1190
|
if (input.kind) return input.kind;
|
|
1200
1191
|
if (input.id === "user") return "user";
|
|
@@ -2418,10 +2409,6 @@ function runtimeTokenJtisFromMeta(meta: Record<string, unknown>): string[] {
|
|
|
2418
2409
|
return [...jtis];
|
|
2419
2410
|
}
|
|
2420
2411
|
|
|
2421
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2422
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
2412
|
// --- Tasks ---
|
|
2426
2413
|
|
|
2427
2414
|
const TASK_SELECT = "SELECT * FROM tasks";
|
|
@@ -5241,6 +5228,18 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
|
|
|
5241
5228
|
return getWorkspace(id);
|
|
5242
5229
|
}
|
|
5243
5230
|
|
|
5231
|
+
// Repoint a workspace row at a recycled branch after a land-and-continue merge
|
|
5232
|
+
// (#206): the worktree switched to a fresh branch cut from the advanced base, so
|
|
5233
|
+
// the row must track the new branch (else the next merge command targets a branch
|
|
5234
|
+
// that no longer exists) and the new base sha. No-op if the row is gone.
|
|
5235
|
+
export function setWorkspaceBranch(id: string, branch: string, baseSha?: string): WorkspaceRecord | null {
|
|
5236
|
+
const existing = getWorkspace(id);
|
|
5237
|
+
if (!existing) return null;
|
|
5238
|
+
db.query(`UPDATE workspaces SET branch = ?, base_sha = coalesce(?, base_sha), updated_at = ? WHERE id = ?`)
|
|
5239
|
+
.run(branch, baseSha ?? null, Date.now(), id);
|
|
5240
|
+
return getWorkspace(id);
|
|
5241
|
+
}
|
|
5242
|
+
|
|
5244
5243
|
// Workspace statuses that count as "live" for stewardship — an agent owning one
|
|
5245
5244
|
// of these is a candidate steward; the repo is worth coordinating.
|
|
5246
5245
|
const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "./compaction-watch";
|
|
|
11
11
|
import { getConfig, setConfig } from "./config-store";
|
|
12
12
|
import { startConnectorStatusPoller } from "./connectors";
|
|
13
13
|
import { resolve, sep } from "path";
|
|
14
|
+
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
14
15
|
import {
|
|
15
16
|
VERSION,
|
|
16
17
|
} from "./config";
|
|
@@ -264,8 +265,7 @@ export function createFetchHandler(
|
|
|
264
265
|
}
|
|
265
266
|
const file = Bun.file(resolved);
|
|
266
267
|
if (await file.exists()) {
|
|
267
|
-
|
|
268
|
-
return new Response(file, { headers });
|
|
268
|
+
return await serveStaticFile(req, resolved, requested, file);
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
return Response.json({ error: "not found" }, { status: 404 });
|
|
@@ -275,13 +275,108 @@ export function createFetchHandler(
|
|
|
275
275
|
};
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
// In-memory compressed-variant cache, keyed by absolute path. Invalidated when
|
|
279
|
+
// the file's mtime/size changes, so a rebuilt bundle is recompressed on next
|
|
280
|
+
// request. The single-file dashboard bundle (~10 MB unminified) is served
|
|
281
|
+
// uncompressed by Bun.serve otherwise; brotli/gzip cuts it ~6-7x on the wire,
|
|
282
|
+
// which is the dominant cost over high-latency links. Further wins (minify,
|
|
283
|
+
// code-split) tracked in #200 / #201.
|
|
284
|
+
type CompressedVariant = { body: Uint8Array; encoding: "br" | "gzip" };
|
|
285
|
+
const compressedCache = new Map<
|
|
286
|
+
string,
|
|
287
|
+
{ mtimeMs: number; size: number; br?: Uint8Array; gzip?: Uint8Array }
|
|
288
|
+
>();
|
|
289
|
+
|
|
290
|
+
const COMPRESSIBLE = /\.(html|js|css|svg|json|webmanifest|map)$/;
|
|
291
|
+
|
|
292
|
+
function isCompressible(pathname: string): boolean {
|
|
293
|
+
return pathname === "/" || pathname.endsWith("/") || COMPRESSIBLE.test(pathname);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function negotiateEncoding(req: Request): "br" | "gzip" | null {
|
|
297
|
+
const accept = req.headers.get("accept-encoding") ?? "";
|
|
298
|
+
if (/\bbr\b/.test(accept)) return "br";
|
|
299
|
+
if (/\bgzip\b/.test(accept)) return "gzip";
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function getCompressedVariant(
|
|
304
|
+
resolved: string,
|
|
305
|
+
mtimeMs: number,
|
|
306
|
+
size: number,
|
|
307
|
+
encoding: "br" | "gzip",
|
|
308
|
+
raw: () => Promise<Uint8Array>,
|
|
309
|
+
): Promise<CompressedVariant> {
|
|
310
|
+
let entry = compressedCache.get(resolved);
|
|
311
|
+
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) {
|
|
312
|
+
entry = { mtimeMs, size };
|
|
313
|
+
compressedCache.set(resolved, entry);
|
|
314
|
+
}
|
|
315
|
+
const cached = entry[encoding];
|
|
316
|
+
if (cached) return { body: cached, encoding };
|
|
317
|
+
const data = await raw();
|
|
318
|
+
const body =
|
|
319
|
+
encoding === "br"
|
|
320
|
+
? brotliCompressSync(data, {
|
|
321
|
+
params: {
|
|
322
|
+
// Quality 5 ~ near-gzip CPU, much better ratio; this runs once per
|
|
323
|
+
// file version then serves from cache, so cost is amortized away.
|
|
324
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: 5,
|
|
325
|
+
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.byteLength,
|
|
326
|
+
},
|
|
327
|
+
})
|
|
328
|
+
: gzipSync(data, { level: 6 });
|
|
329
|
+
entry[encoding] = body;
|
|
330
|
+
return { body, encoding };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function serveStaticFile(
|
|
334
|
+
req: Request,
|
|
335
|
+
resolved: string,
|
|
336
|
+
requested: string,
|
|
337
|
+
file: ReturnType<typeof Bun.file>,
|
|
338
|
+
): Promise<Response> {
|
|
339
|
+
const stat = await file.stat();
|
|
340
|
+
const headers = staticHeaders(requested);
|
|
341
|
+
// Strong-ish validator from mtime + size; encoding-suffixed so a client never
|
|
342
|
+
// gets a 304 for a variant it didn't store.
|
|
343
|
+
const baseTag = `${stat.mtime.getTime().toString(36)}-${stat.size.toString(36)}`;
|
|
344
|
+
|
|
345
|
+
const encoding = isCompressible(requested) ? negotiateEncoding(req) : null;
|
|
346
|
+
const etag = `"${baseTag}${encoding ? `-${encoding}` : ""}"`;
|
|
347
|
+
headers.set("ETag", etag);
|
|
348
|
+
if (req.headers.get("if-none-match") === etag) {
|
|
349
|
+
return new Response(null, { status: 304, headers });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (encoding) {
|
|
353
|
+
const { body } = await getCompressedVariant(
|
|
354
|
+
resolved,
|
|
355
|
+
stat.mtime.getTime(),
|
|
356
|
+
stat.size,
|
|
357
|
+
encoding,
|
|
358
|
+
() => file.bytes(),
|
|
359
|
+
);
|
|
360
|
+
headers.set("Content-Encoding", encoding);
|
|
361
|
+
headers.set("Vary", "Accept-Encoding");
|
|
362
|
+
headers.set("Content-Length", String(body.byteLength));
|
|
363
|
+
// Uint8Array is a valid BodyInit at runtime in Bun; the DOM lib types omit it.
|
|
364
|
+
return new Response(body as unknown as BodyInit, { headers });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return new Response(file, { headers });
|
|
368
|
+
}
|
|
369
|
+
|
|
278
370
|
function staticHeaders(pathname: string): Headers {
|
|
279
371
|
const headers = new Headers();
|
|
280
372
|
const contentType = staticContentType(pathname);
|
|
281
373
|
if (contentType) headers.set("Content-Type", contentType);
|
|
282
|
-
|
|
374
|
+
// The shell + PWA control files must always revalidate (ETag makes this a
|
|
375
|
+
// cheap 304 when unchanged). Hashing isn't available — viteSingleFile inlines
|
|
376
|
+
// everything into index.html — so no immutable long-cache assets exist.
|
|
377
|
+
if (pathname === "/sw.js" || pathname === "/manifest.webmanifest") {
|
|
283
378
|
headers.set("Cache-Control", "no-cache");
|
|
284
|
-
} else if (pathname === "/
|
|
379
|
+
} else if (pathname === "/" || pathname.endsWith("/") || pathname.endsWith(".html")) {
|
|
285
380
|
headers.set("Cache-Control", "no-cache");
|
|
286
381
|
}
|
|
287
382
|
return headers;
|