agent-relay-server 0.18.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/package.json +2 -2
- package/public/index.html +14 -10
- package/runner/src/config.ts +2 -1
- package/src/automations.ts +13 -28
- package/src/bus.ts +2 -2
- package/src/cli.ts +68 -7
- package/src/config-store.ts +9 -27
- package/src/daemon.ts +1 -4
- package/src/db.ts +9 -2
- package/src/dev.ts +1 -4
- package/src/http-body.ts +49 -0
- package/src/index.ts +2 -1
- package/src/maintenance.ts +3 -3
- package/src/mcp.ts +17 -68
- package/src/memory-broker-smoke.ts +2 -2
- package/src/memory-command-broker.ts +2 -2
- package/src/memory-http-broker.ts +2 -2
- package/src/orchestrator-lookup.ts +29 -0
- package/src/recipe-validator.ts +2 -2
- package/src/routes.ts +180 -179
- package/src/setup.ts +1 -4
- package/src/spawn-command.ts +2 -1
- package/src/steward.ts +2 -1
- package/src/upgrade.ts +38 -12
- package/src/validation.ts +54 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"CONTRIBUTING.md"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"agent-relay-sdk": "0.2.
|
|
36
|
+
"agent-relay-sdk": "0.2.10"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"prepack": "bun run build:dashboard:bundle >&2",
|
package/public/index.html
CHANGED
|
@@ -10830,6 +10830,10 @@ function stringValue(value) {
|
|
|
10830
10830
|
const trimmed = value.trim();
|
|
10831
10831
|
return trimmed.length > 0 ? trimmed : void 0;
|
|
10832
10832
|
}
|
|
10833
|
+
/** Extract a human-readable message from any thrown value. */
|
|
10834
|
+
function errMessage(error) {
|
|
10835
|
+
return error instanceof Error ? error.message : String(error);
|
|
10836
|
+
}
|
|
10833
10837
|
//#endregion
|
|
10834
10838
|
//#region src/lib/display.ts
|
|
10835
10839
|
function toTimestamp(value) {
|
|
@@ -123777,7 +123781,7 @@ function TerminalViewer({ orchestratorId, session, interactive: initialInteracti
|
|
|
123777
123781
|
try {
|
|
123778
123782
|
writeSnapshot(await apiCall("GET", `/orchestrators/${encodeURIComponent(orchestratorId)}/terminal/${encodeURIComponent(session)}`));
|
|
123779
123783
|
} catch (e) {
|
|
123780
|
-
setError(
|
|
123784
|
+
setError(errMessage(e));
|
|
123781
123785
|
} finally {
|
|
123782
123786
|
inFlightRef.current = false;
|
|
123783
123787
|
}
|
|
@@ -123834,7 +123838,7 @@ function TerminalViewer({ orchestratorId, session, interactive: initialInteracti
|
|
|
123834
123838
|
for (const chunk of chunks) inputQueueRef.current = inputQueueRef.current.then(() => apiCall("POST", `/orchestrators/${encodeURIComponent(orchestratorId)}/terminal/${encodeURIComponent(session)}/input`, { data: chunk })).then(() => {
|
|
123835
123839
|
setError(null);
|
|
123836
123840
|
}).catch((e) => {
|
|
123837
|
-
setError(
|
|
123841
|
+
setError(errMessage(e));
|
|
123838
123842
|
});
|
|
123839
123843
|
}
|
|
123840
123844
|
function handleTerminalData(data) {
|
|
@@ -124429,7 +124433,7 @@ function useFileRead(orchestratorId, selectedPath) {
|
|
|
124429
124433
|
}).catch((e) => {
|
|
124430
124434
|
if (!cancelled) {
|
|
124431
124435
|
setFile(null);
|
|
124432
|
-
setError(
|
|
124436
|
+
setError(errMessage(e));
|
|
124433
124437
|
}
|
|
124434
124438
|
}).finally(() => {
|
|
124435
124439
|
if (!cancelled) setLoading(false);
|
|
@@ -124467,7 +124471,7 @@ function useFileListing(orchestratorId, selectedPath, git = false) {
|
|
|
124467
124471
|
}).catch((e) => {
|
|
124468
124472
|
if (!cancelled) {
|
|
124469
124473
|
setListing(null);
|
|
124470
|
-
setError(
|
|
124474
|
+
setError(errMessage(e));
|
|
124471
124475
|
}
|
|
124472
124476
|
}).finally(() => {
|
|
124473
124477
|
if (!cancelled) setLoading(false);
|
|
@@ -124783,7 +124787,7 @@ function FileBrowser({ overlay = false }) {
|
|
|
124783
124787
|
setPathDraft(result.path);
|
|
124784
124788
|
setReadError("");
|
|
124785
124789
|
} catch (e) {
|
|
124786
|
-
setError(
|
|
124790
|
+
setError(errMessage(e));
|
|
124787
124791
|
} finally {
|
|
124788
124792
|
setLoading(false);
|
|
124789
124793
|
}
|
|
@@ -128124,7 +128128,7 @@ function LogViewer({ orchestratorId, session, lines = 200 }) {
|
|
|
128124
128128
|
setLogLines((await apiCall("GET", `/orchestrators/${encodeURIComponent(orchestratorId)}/logs/${encodeURIComponent(session)}?lines=${lines}${stream === "mirror" ? "&stream=mirror" : ""}`)).lines || []);
|
|
128125
128129
|
setError(null);
|
|
128126
128130
|
} catch (e) {
|
|
128127
|
-
setError(
|
|
128131
|
+
setError(errMessage(e));
|
|
128128
128132
|
}
|
|
128129
128133
|
}
|
|
128130
128134
|
(0, import_react.useEffect)(() => {
|
|
@@ -152926,7 +152930,7 @@ function InsightsView() {
|
|
|
152926
152930
|
setProjects(obs.projects);
|
|
152927
152931
|
setNow(Date.now());
|
|
152928
152932
|
} catch (e) {
|
|
152929
|
-
setError(
|
|
152933
|
+
setError(errMessage(e));
|
|
152930
152934
|
}
|
|
152931
152935
|
}
|
|
152932
152936
|
(0, import_react.useEffect)(() => {
|
|
@@ -152946,7 +152950,7 @@ function InsightsView() {
|
|
|
152946
152950
|
setVersion(entry.version);
|
|
152947
152951
|
} catch (e) {
|
|
152948
152952
|
setConfig(previous);
|
|
152949
|
-
setError(
|
|
152953
|
+
setError(errMessage(e));
|
|
152950
152954
|
} finally {
|
|
152951
152955
|
setSaving(false);
|
|
152952
152956
|
}
|
|
@@ -156470,7 +156474,7 @@ function DirectoryBrowserDialog({ open, onOpenChange, onSelect, orchestratorId,
|
|
|
156470
156474
|
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
|
156471
156475
|
setListing(await apiCall("GET", `/orchestrators/${encodeURIComponent(orchestratorId)}/directories${query}`));
|
|
156472
156476
|
} catch (e) {
|
|
156473
|
-
setError(
|
|
156477
|
+
setError(errMessage(e));
|
|
156474
156478
|
} finally {
|
|
156475
156479
|
setLoading(false);
|
|
156476
156480
|
}
|
|
@@ -156494,7 +156498,7 @@ function DirectoryBrowserDialog({ open, onOpenChange, onSelect, orchestratorId,
|
|
|
156494
156498
|
setCreating(false);
|
|
156495
156499
|
setNewDirName("");
|
|
156496
156500
|
} catch (e) {
|
|
156497
|
-
setCreateError(
|
|
156501
|
+
setCreateError(errMessage(e));
|
|
156498
156502
|
}
|
|
156499
156503
|
}
|
|
156500
156504
|
function handleSelect() {
|
package/runner/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir, hostname } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { stringValue } from "agent-relay-sdk";
|
|
5
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
5
6
|
import type { ProviderConfig } from "./adapter";
|
|
6
7
|
|
|
7
8
|
interface GlobalRunnerConfig {
|
|
@@ -98,7 +99,7 @@ export function providerConfigPublic(config: LoadedProviderConfig): Record<strin
|
|
|
98
99
|
|
|
99
100
|
export function runnerId(provider: string, cwd: string, label?: string): string {
|
|
100
101
|
const project = cwd.split("/").filter(Boolean).at(-1) || "workspace";
|
|
101
|
-
const cleanLabel = (label || project
|
|
102
|
+
const cleanLabel = sanitizeFsName(label || project, { replacement: "-", lowercase: true });
|
|
102
103
|
return `${hostname()}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
|
|
103
104
|
}
|
|
104
105
|
|
package/src/automations.ts
CHANGED
|
@@ -13,11 +13,11 @@ import {
|
|
|
13
13
|
ValidationError,
|
|
14
14
|
} from "./db";
|
|
15
15
|
import { createCommand } from "./commands-db";
|
|
16
|
-
import { cleanString } from "./validation";
|
|
16
|
+
import { cleanEnum, cleanString, cleanStringArray, optionalEnum } from "./validation";
|
|
17
17
|
import { getAgentProfile, getSpawnPolicy } from "./config-store";
|
|
18
18
|
import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
|
|
19
19
|
import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
|
|
20
|
-
import { VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
20
|
+
import { errMessage, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
21
21
|
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
22
22
|
import type {
|
|
23
23
|
AgentCard,
|
|
@@ -119,27 +119,12 @@ function rowToAutomationRun(row: any): AutomationRun {
|
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
function cleanStringArray(value: unknown, field: string): string[] | undefined {
|
|
123
|
-
if (value === undefined || value === null) return undefined;
|
|
124
|
-
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
|
|
125
|
-
return [...new Set(value.map((item) => cleanString(item, `${field} item`, { max: 100 })).filter(Boolean) as string[])];
|
|
126
|
-
}
|
|
127
|
-
|
|
128
122
|
function cleanBool(value: unknown, field: string): boolean | undefined {
|
|
129
123
|
if (value === undefined || value === null) return undefined;
|
|
130
124
|
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
131
125
|
return value;
|
|
132
126
|
}
|
|
133
127
|
|
|
134
|
-
function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback?: T[number]): T[number] {
|
|
135
|
-
if (value === undefined || value === null) {
|
|
136
|
-
if (fallback !== undefined) return fallback;
|
|
137
|
-
throw new ValidationError(`${field} required`);
|
|
138
|
-
}
|
|
139
|
-
if (typeof value !== "string" || !valid.includes(value)) throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
|
|
140
|
-
return value as T[number];
|
|
141
|
-
}
|
|
142
|
-
|
|
143
128
|
function cleanMeta(value: unknown, field = "metadata"): Record<string, unknown> | undefined {
|
|
144
129
|
if (value === undefined || value === null) return undefined;
|
|
145
130
|
if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${field} must be an object`);
|
|
@@ -153,7 +138,7 @@ function normalizeTaskTemplate(value: unknown): AutomationTaskTemplate {
|
|
|
153
138
|
return {
|
|
154
139
|
title: cleanString(input.title, "taskTemplate.title", { required: true, max: 240 })!,
|
|
155
140
|
body: cleanString(input.body, "taskTemplate.body", { required: true, max: 200_000 })!,
|
|
156
|
-
severity:
|
|
141
|
+
severity: optionalEnum(input.severity, "taskTemplate.severity", ["info", "warning", "critical"] as const, "info") as TaskSeverity,
|
|
157
142
|
dedupeKey: cleanString(input.dedupeKey, "taskTemplate.dedupeKey", { max: 240 }),
|
|
158
143
|
externalUrl: cleanString(input.externalUrl, "taskTemplate.externalUrl", { max: 1000 }),
|
|
159
144
|
metadata: cleanMeta(input.metadata, "taskTemplate.metadata"),
|
|
@@ -207,13 +192,13 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
|
|
|
207
192
|
selector: {
|
|
208
193
|
provider: cleanString(selectorInput.provider, "targetPolicy.selector.provider", { max: 40 }) as "claude" | "codex" | undefined,
|
|
209
194
|
label: cleanString(selectorInput.label, "targetPolicy.selector.label", { max: 120 }),
|
|
210
|
-
tags: cleanStringArray(selectorInput.tags, "targetPolicy.selector.tags"),
|
|
211
|
-
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 }),
|
|
212
197
|
},
|
|
213
|
-
ifNoMatch:
|
|
198
|
+
ifNoMatch: optionalEnum(input.ifNoMatch, "targetPolicy.ifNoMatch", ["fail", "spawn"] as const, "fail"),
|
|
214
199
|
};
|
|
215
200
|
}
|
|
216
|
-
const provider =
|
|
201
|
+
const provider = optionalEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex");
|
|
217
202
|
const model = cleanString(input.model, "targetPolicy.model", { max: 120 });
|
|
218
203
|
const effort = input.effort === undefined || input.effort === null ? undefined : cleanEnum(input.effort, "targetPolicy.effort", VALID_EFFORTS) as ProviderEffort;
|
|
219
204
|
const profile = cleanString(input.profile, "targetPolicy.profile", { max: 120 });
|
|
@@ -221,7 +206,7 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
|
|
|
221
206
|
try {
|
|
222
207
|
resolveProviderSelection({ provider, model, effort });
|
|
223
208
|
} catch (error) {
|
|
224
|
-
throw new ValidationError(
|
|
209
|
+
throw new ValidationError(errMessage(error));
|
|
225
210
|
}
|
|
226
211
|
return {
|
|
227
212
|
mode,
|
|
@@ -229,9 +214,9 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
|
|
|
229
214
|
model,
|
|
230
215
|
effort,
|
|
231
216
|
cwd: cleanString(input.cwd, "targetPolicy.cwd", { max: 500 }),
|
|
232
|
-
workspaceMode:
|
|
217
|
+
workspaceMode: optionalEnum(input.workspaceMode, "targetPolicy.workspaceMode", VALID_WORKSPACE_MODES, "inherit"),
|
|
233
218
|
profile,
|
|
234
|
-
approvalMode:
|
|
219
|
+
approvalMode: optionalEnum(input.approvalMode, "targetPolicy.approvalMode", ["open", "guarded", "read-only"] as const, "guarded"),
|
|
235
220
|
keepAlive: cleanBool(input.keepAlive, "targetPolicy.keepAlive") ?? false,
|
|
236
221
|
runtimeBudget: normalizeRuntimeBudget(input),
|
|
237
222
|
shutdownAfterMs: typeof input.shutdownAfterMs === "number" && Number.isSafeInteger(input.shutdownAfterMs) && input.shutdownAfterMs >= 0
|
|
@@ -251,8 +236,8 @@ function normalizeCreateInput(input: CreateAutomationInput): Required<Omit<Creat
|
|
|
251
236
|
enabled: cleanBool(input.enabled, "enabled") ?? true,
|
|
252
237
|
schedule,
|
|
253
238
|
timezone,
|
|
254
|
-
catchUpPolicy:
|
|
255
|
-
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),
|
|
256
241
|
orchestratorId: cleanString(input.orchestratorId, "orchestratorId", { required: true, max: 160 })!,
|
|
257
242
|
targetPolicy: normalizeTargetPolicy(input.targetPolicy),
|
|
258
243
|
taskTemplate: normalizeTaskTemplate(input.taskTemplate),
|
|
@@ -511,7 +496,7 @@ function dispatchAutomationRun(automation: Automation, run: AutomationRun, now:
|
|
|
511
496
|
updateRun(run.id, {
|
|
512
497
|
status: "failed",
|
|
513
498
|
finishedAt: now,
|
|
514
|
-
error:
|
|
499
|
+
error: errMessage(e),
|
|
515
500
|
}, now);
|
|
516
501
|
return { automation, run: getAutomationRun(run.id)! };
|
|
517
502
|
}
|
package/src/bus.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type BusFrame,
|
|
15
15
|
type RegisterFrame,
|
|
16
16
|
} from "agent-relay-sdk/protocol";
|
|
17
|
-
import { isRecord, stringValue } from "agent-relay-sdk";
|
|
17
|
+
import { errMessage, isRecord, stringValue } from "agent-relay-sdk";
|
|
18
18
|
import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
|
|
19
19
|
import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, Task } from "./types";
|
|
20
20
|
|
|
@@ -79,7 +79,7 @@ export function busHandleMessage(ws: BusWebSocket, data: string | Buffer): void
|
|
|
79
79
|
try {
|
|
80
80
|
handleFrame(ws, frame);
|
|
81
81
|
} catch (error) {
|
|
82
|
-
sendError(ws, frame.id, "FRAME_FAILED",
|
|
82
|
+
sendError(ws, frame.id, "FRAME_FAILED", errMessage(error));
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
package/src/cli.ts
CHANGED
|
@@ -44,7 +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";
|
|
47
48
|
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
49
|
+
import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
|
|
48
50
|
|
|
49
51
|
const HELP = `
|
|
50
52
|
agent-relay ${VERSION}
|
|
@@ -63,7 +65,7 @@ Usage:
|
|
|
63
65
|
agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
|
|
64
66
|
agent-relay token <create|list|revoke|verify> [options]
|
|
65
67
|
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]
|
|
68
|
+
agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]
|
|
67
69
|
agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
|
|
68
70
|
agent-relay message <target> <body> [options]
|
|
69
71
|
agent-relay get-message <messageId> [--json|--body]
|
|
@@ -231,6 +233,11 @@ Isolated workspaces
|
|
|
231
233
|
agent-relay workspace status Show your workspace's branch, base, status.
|
|
232
234
|
The base branch will move as other agents land in parallel — that is normal,
|
|
233
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.
|
|
234
241
|
|
|
235
242
|
Rules of thumb
|
|
236
243
|
If you are handling relay message #123, reply with:
|
|
@@ -626,10 +633,6 @@ async function readStdin(): Promise<string> {
|
|
|
626
633
|
return value;
|
|
627
634
|
}
|
|
628
635
|
|
|
629
|
-
function shellQuote(value: string): string {
|
|
630
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
636
|
function currentClaudeStatusLineCommand(): string | undefined {
|
|
634
637
|
const settingsPath = join(process.env.HOME || homedir(), ".claude", "settings.json");
|
|
635
638
|
try {
|
|
@@ -1421,6 +1424,35 @@ function formatWorkspaceStatus(ws: any): string {
|
|
|
1421
1424
|
return lines.join("\n");
|
|
1422
1425
|
}
|
|
1423
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
|
+
|
|
1424
1456
|
// Self-service workspace lifecycle for agents in isolated worktrees (#205) plus
|
|
1425
1457
|
// steward coordination (#208).
|
|
1426
1458
|
// status — read your workspace row ready — hand off for review/landing
|
|
@@ -1430,9 +1462,9 @@ function formatWorkspaceStatus(ws: any): string {
|
|
|
1430
1462
|
// cleanup-stale — guarded batch cleanup of stale worktrees (dry-run by default)
|
|
1431
1463
|
async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
1432
1464
|
const action = args[0];
|
|
1433
|
-
const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale"]);
|
|
1465
|
+
const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale", "deps"]);
|
|
1434
1466
|
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]");
|
|
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]");
|
|
1436
1468
|
}
|
|
1437
1469
|
|
|
1438
1470
|
let id = currentWorkspaceId();
|
|
@@ -1440,6 +1472,7 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
1440
1472
|
let purpose: string | undefined;
|
|
1441
1473
|
let repo: string | undefined;
|
|
1442
1474
|
let execute = false;
|
|
1475
|
+
let check = false;
|
|
1443
1476
|
let json = false;
|
|
1444
1477
|
for (let i = 1; i < args.length; i++) {
|
|
1445
1478
|
const arg = args[i];
|
|
@@ -1448,6 +1481,8 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
1448
1481
|
else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
|
|
1449
1482
|
else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
|
|
1450
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
|
|
1451
1486
|
else if (arg === "--json") json = true;
|
|
1452
1487
|
else throw new Error(`Unknown workspace option "${arg}".`);
|
|
1453
1488
|
}
|
|
@@ -1477,6 +1512,32 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
|
|
|
1477
1512
|
return;
|
|
1478
1513
|
}
|
|
1479
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
|
+
|
|
1480
1541
|
const from = await detectAgentId();
|
|
1481
1542
|
const actionBody: Record<string, unknown> =
|
|
1482
1543
|
action === "ready" ? { action: "request-review", agentId: from }
|
package/src/config-store.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getDb, ValidationError } from "./db";
|
|
2
|
-
import { cleanString } from "./validation";
|
|
2
|
+
import { cleanEnum, cleanString, cleanStringArray } from "./validation";
|
|
3
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";
|
|
4
|
+
import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
5
5
|
import type {
|
|
6
6
|
AgentProfile,
|
|
7
7
|
AgentProfileBase,
|
|
@@ -155,17 +155,6 @@ function rowToManagedAgentState(row: ManagedAgentStateRow): ManagedAgentState {
|
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
function cleanStringArray(value: unknown, field: string, opts: { required?: boolean } = {}): string[] {
|
|
159
|
-
if (value === undefined || value === null) {
|
|
160
|
-
if (opts.required) throw new ValidationError(`${field} required`);
|
|
161
|
-
return [];
|
|
162
|
-
}
|
|
163
|
-
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
|
|
164
|
-
const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: 120 })).filter(Boolean) as string[];
|
|
165
|
-
if (cleaned.length > 100) throw new ValidationError(`${field} can contain at most 100 values`);
|
|
166
|
-
return [...new Set(cleaned)];
|
|
167
|
-
}
|
|
168
|
-
|
|
169
158
|
function cleanStringRecord(value: unknown, field: string): Record<string, string> {
|
|
170
159
|
if (value === undefined || value === null) return {};
|
|
171
160
|
if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
|
|
@@ -196,13 +185,6 @@ function cleanNumber(value: unknown, field: string, opts: { min: number; max: nu
|
|
|
196
185
|
return value;
|
|
197
186
|
}
|
|
198
187
|
|
|
199
|
-
function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
|
|
200
|
-
if (typeof value !== "string" || !valid.includes(value)) {
|
|
201
|
-
throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
|
|
202
|
-
}
|
|
203
|
-
return value as T[number];
|
|
204
|
-
}
|
|
205
|
-
|
|
206
188
|
function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Partial<AgentProfile>): AgentProfile {
|
|
207
189
|
const isolated = input.base !== "host";
|
|
208
190
|
return {
|
|
@@ -279,7 +261,7 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
|
|
|
279
261
|
: cleanEnum(value.provider, "provider", VALID_PROFILE_PROVIDERS),
|
|
280
262
|
instructions: {
|
|
281
263
|
system: cleanString(instructions.system, "instructions.system", { max: 16_000 }),
|
|
282
|
-
append: cleanStringArray(instructions.append, "instructions.append"),
|
|
264
|
+
append: cleanStringArray(instructions.append, "instructions.append", { itemMax: 120, maxItems: 100 }) ?? [],
|
|
283
265
|
repoInstructions: instructions.repoInstructions === undefined
|
|
284
266
|
? defaults.instructions.repoInstructions
|
|
285
267
|
: cleanEnum(instructions.repoInstructions, "instructions.repoInstructions", VALID_PROFILE_INSTRUCTION_POLICIES),
|
|
@@ -332,10 +314,10 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
|
|
|
332
314
|
model: cleanString(value.model, "model", { max: 120 }),
|
|
333
315
|
effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
|
|
334
316
|
profile: cleanString(value.profile, "profile", { max: 120 }),
|
|
335
|
-
providerArgs: cleanStringArray(value.providerArgs, "providerArgs"),
|
|
317
|
+
providerArgs: cleanStringArray(value.providerArgs, "providerArgs", { itemMax: 120, maxItems: 100 }) ?? [],
|
|
336
318
|
prompt: cleanString(value.prompt, "prompt", { max: 16_000 }),
|
|
337
|
-
tags: cleanStringArray(value.tags, "tags"),
|
|
338
|
-
capabilities: cleanStringArray(value.capabilities, "capabilities"),
|
|
319
|
+
tags: cleanStringArray(value.tags, "tags", { itemMax: 120, maxItems: 100 }) ?? [],
|
|
320
|
+
capabilities: cleanStringArray(value.capabilities, "capabilities", { itemMax: 120, maxItems: 100 }) ?? [],
|
|
339
321
|
label: cleanString(value.label, "label", { max: 120 }),
|
|
340
322
|
mode,
|
|
341
323
|
permissionMode: cleanEnum(value.permissionMode, "permissionMode", VALID_PERMISSION_MODES),
|
|
@@ -346,7 +328,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
|
|
|
346
328
|
try {
|
|
347
329
|
resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
|
|
348
330
|
} catch (error) {
|
|
349
|
-
throw new ValidationError(
|
|
331
|
+
throw new ValidationError(errMessage(error));
|
|
350
332
|
}
|
|
351
333
|
if (policy.profile && !getAgentProfile(policy.profile)) throw new ValidationError("agent profile not found");
|
|
352
334
|
if (mode === "on-demand") policy.onDemand = cleanOnDemand(value.onDemand);
|
|
@@ -409,7 +391,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
|
|
|
409
391
|
try {
|
|
410
392
|
resolveProviderSelection({ provider: config.provider, model: config.model, effort: config.effort });
|
|
411
393
|
} catch (error) {
|
|
412
|
-
throw new ValidationError(
|
|
394
|
+
throw new ValidationError(errMessage(error));
|
|
413
395
|
}
|
|
414
396
|
return config;
|
|
415
397
|
}
|
|
@@ -467,7 +449,7 @@ const WORKSPACE_CONFIG_DEFAULTS: WorkspaceConfig = {
|
|
|
467
449
|
|
|
468
450
|
function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
|
|
469
451
|
if (!isRecord(value)) throw new ValidationError("workspace config value must be an object");
|
|
470
|
-
const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths");
|
|
452
|
+
const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths", { itemMax: 120, maxItems: 100 }) ?? [];
|
|
471
453
|
// Reject absolute paths and parent-traversal up front: symlink sources must stay
|
|
472
454
|
// inside the main checkout. The orchestrator re-checks containment at link time,
|
|
473
455
|
// but failing here gives the operator immediate feedback in the dashboard.
|
package/src/daemon.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { shellQuote } from "agent-relay-sdk/shell-utils";
|
|
2
3
|
import { constants, existsSync } from "node:fs";
|
|
3
4
|
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import { homedir } from "node:os";
|
|
@@ -460,10 +461,6 @@ function quoteSystemdArg(value: string): string {
|
|
|
460
461
|
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
461
462
|
}
|
|
462
463
|
|
|
463
|
-
function shellQuote(value: string): string {
|
|
464
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
464
|
function xmlEscape(value: string): string {
|
|
468
465
|
return value
|
|
469
466
|
.replace(/&/g, "&")
|
package/src/db.ts
CHANGED
|
@@ -5079,14 +5079,21 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
|
|
|
5079
5079
|
// preserved and metadata is merged, not replaced.
|
|
5080
5080
|
const existing = getWorkspace(workspace.id);
|
|
5081
5081
|
const preserveStatus = existing != null && existing.status !== "active";
|
|
5082
|
+
// The branch (and advanced base) change ONLY via the relay's own land-and-continue
|
|
5083
|
+
// recycle (setWorkspaceBranch repoints to `<branch>-N` and bumps base_sha). The
|
|
5084
|
+
// runner keeps re-reporting its spawn-time branch on every heartbeat, and the
|
|
5085
|
+
// recycle returns status to "active" — so without this the next heartbeat clobbers
|
|
5086
|
+
// the repoint back to the original branch, and the next land targets a deleted
|
|
5087
|
+
// branch and strands the work (vent #62 follow-up). Trust the existing row's
|
|
5088
|
+
// branch/base over registration; only a brand-new row takes the runner's values.
|
|
5082
5089
|
return upsertWorkspace({
|
|
5083
5090
|
id: workspace.id,
|
|
5084
5091
|
repoRoot: workspace.repoRoot,
|
|
5085
5092
|
sourceCwd: workspace.sourceCwd ?? agent.cwd,
|
|
5086
5093
|
worktreePath: workspace.worktreePath,
|
|
5087
|
-
branch: workspace.branch,
|
|
5094
|
+
branch: existing?.branch ?? workspace.branch,
|
|
5088
5095
|
baseRef: workspace.baseRef,
|
|
5089
|
-
baseSha: workspace.baseSha,
|
|
5096
|
+
baseSha: existing?.baseSha ?? workspace.baseSha,
|
|
5090
5097
|
mode: workspace.mode,
|
|
5091
5098
|
requestedMode: workspace.requestedMode,
|
|
5092
5099
|
status: preserveStatus ? existing!.status : (workspace.status ?? "active"),
|
package/src/dev.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { shellQuote } from "agent-relay-sdk/shell-utils";
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import { homedir, hostname as osHostname } from "node:os";
|
|
@@ -504,7 +505,3 @@ function quote(value: string): string {
|
|
|
504
505
|
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
|
|
505
506
|
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
506
507
|
}
|
|
507
|
-
|
|
508
|
-
function shellQuote(value: string): string {
|
|
509
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
510
|
-
}
|
package/src/http-body.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Concatenate body chunks into a single contiguous Uint8Array. */
|
|
2
|
+
export function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
|
3
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
4
|
+
const output = new Uint8Array(total);
|
|
5
|
+
let offset = 0;
|
|
6
|
+
for (const chunk of chunks) {
|
|
7
|
+
output.set(chunk, offset);
|
|
8
|
+
offset += chunk.byteLength;
|
|
9
|
+
}
|
|
10
|
+
return output;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Wrap bytes in a single-chunk ReadableStream (e.g. for artifact `storage.store`). */
|
|
14
|
+
export function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
15
|
+
return new ReadableStream<Uint8Array>({
|
|
16
|
+
start(controller) {
|
|
17
|
+
controller.enqueue(bytes);
|
|
18
|
+
controller.close();
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Drain a request body stream into a single Uint8Array, enforcing a byte cap.
|
|
25
|
+
* Returns a 413 result the moment the running total exceeds `maxBytes` (cancels
|
|
26
|
+
* the reader). Single home for the read-loop that `parseBody` (routes) and
|
|
27
|
+
* `parseJsonRpcRequest` (mcp) each open-coded — they differed only in the cap.
|
|
28
|
+
* Callers handle body-absence and decoding.
|
|
29
|
+
*/
|
|
30
|
+
export async function readBodyBytes(
|
|
31
|
+
body: ReadableStream<Uint8Array>,
|
|
32
|
+
maxBytes: number,
|
|
33
|
+
): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 413; error: string }> {
|
|
34
|
+
const reader = body.getReader();
|
|
35
|
+
const chunks: Uint8Array[] = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
while (true) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done) break;
|
|
40
|
+
if (!value) continue;
|
|
41
|
+
total += value.byteLength;
|
|
42
|
+
if (total > maxBytes) {
|
|
43
|
+
await reader.cancel().catch(() => {});
|
|
44
|
+
return { ok: false, status: 413, error: `request body exceeds ${maxBytes} bytes` };
|
|
45
|
+
}
|
|
46
|
+
chunks.push(value);
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, bytes: concatBytes(chunks) };
|
|
49
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "./security";
|
|
31
31
|
import { handleCli } from "./cli";
|
|
32
32
|
import { startMaintenanceScheduler } from "./maintenance";
|
|
33
|
+
import { errMessage } from "agent-relay-sdk";
|
|
33
34
|
|
|
34
35
|
async function main(): Promise<void> {
|
|
35
36
|
const result = await handleCli(process.argv.slice(2));
|
|
@@ -393,7 +394,7 @@ function staticContentType(pathname: string): string | undefined {
|
|
|
393
394
|
|
|
394
395
|
if (import.meta.main) {
|
|
395
396
|
main().catch((error) => {
|
|
396
|
-
console.error(
|
|
397
|
+
console.error(errMessage(error));
|
|
397
398
|
process.exit(1);
|
|
398
399
|
});
|
|
399
400
|
}
|
package/src/maintenance.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
35
35
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
36
36
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
37
|
-
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
37
|
+
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
38
38
|
import { getStewardConfig } from "./config-store";
|
|
39
39
|
import { ensureRepoSteward } from "./steward";
|
|
40
40
|
import { emitRelayEvent } from "./events";
|
|
@@ -931,7 +931,7 @@ async function runDueMaintenanceJobs(): Promise<void> {
|
|
|
931
931
|
for (const row of rows) {
|
|
932
932
|
const definition = definitions.find((job) => job.id === row.id);
|
|
933
933
|
if (definition) void runJob(definition).catch((error) => {
|
|
934
|
-
console.warn(`maintenance job ${definition.id} failed: ${
|
|
934
|
+
console.warn(`maintenance job ${definition.id} failed: ${errMessage(error)}`);
|
|
935
935
|
});
|
|
936
936
|
}
|
|
937
937
|
}
|
|
@@ -999,7 +999,7 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
|
|
|
999
999
|
} catch (error) {
|
|
1000
1000
|
const finishedAt = Date.now();
|
|
1001
1001
|
const durationMs = finishedAt - startedAt;
|
|
1002
|
-
const message =
|
|
1002
|
+
const message = errMessage(error);
|
|
1003
1003
|
db.query(`
|
|
1004
1004
|
UPDATE maintenance_jobs
|
|
1005
1005
|
SET last_run_at = ?, next_run_at = ?, last_duration_ms = ?, last_status = 'failed',
|