agent-relay-server 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +101 -1
- package/package.json +2 -2
- package/public/index.html +39 -32
- package/public/sw.js +51 -16
- package/runner/src/adapter.ts +1 -4
- package/runner/src/config.ts +3 -5
- package/scripts/orchestrator-spawn-smoke.ts +2 -1
- package/src/automations.ts +20 -59
- package/src/bus.ts +3 -18
- package/src/cli.ts +244 -7
- package/src/command-events.ts +26 -0
- package/src/config-store.ts +12 -47
- package/src/connectors.ts +1 -4
- package/src/contracts.ts +2 -8
- package/src/daemon.ts +1 -4
- package/src/db.ts +23 -17
- package/src/dev.ts +1 -4
- package/src/http-body.ts +49 -0
- package/src/index.ts +101 -5
- package/src/lifecycle-manager.ts +11 -24
- package/src/maintenance.ts +28 -22
- package/src/managed-policy.ts +9 -28
- package/src/mcp.ts +35 -110
- package/src/memory-broker-smoke.ts +4 -2
- package/src/memory-command-broker.ts +2 -5
- package/src/memory-http-broker.ts +2 -5
- package/src/memory-service.ts +1 -4
- package/src/memory-sqlite-broker.ts +1 -8
- package/src/orchestrator-lookup.ts +29 -0
- package/src/provider-catalog-store.ts +3 -11
- package/src/recipe-loader.ts +1 -4
- package/src/recipe-validator.ts +2 -5
- package/src/routes.ts +417 -309
- package/src/security.ts +3 -7
- package/src/setup.ts +1 -4
- package/src/spawn-command.ts +151 -0
- package/src/sse.ts +1 -4
- package/src/steward.ts +17 -21
- package/src/upgrade.ts +40 -13
- package/src/utils.ts +38 -0
- package/src/validation.ts +80 -0
- package/src/workspace-claim.ts +29 -0
- package/src/workspace-merge.ts +21 -9
package/src/config-store.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getDb, ValidationError } from "./db";
|
|
2
|
+
import { cleanEnum, cleanString, cleanStringArray } from "./validation";
|
|
2
3
|
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
4
|
+
import { errMessage, 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,33 +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
|
-
function cleanStringArray(value: unknown, field: string, opts: { required?: boolean } = {}): string[] {
|
|
176
|
-
if (value === undefined || value === null) {
|
|
177
|
-
if (opts.required) throw new ValidationError(`${field} required`);
|
|
178
|
-
return [];
|
|
179
|
-
}
|
|
180
|
-
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
|
|
181
|
-
const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: 120 })).filter(Boolean) as string[];
|
|
182
|
-
if (cleaned.length > 100) throw new ValidationError(`${field} can contain at most 100 values`);
|
|
183
|
-
return [...new Set(cleaned)];
|
|
184
|
-
}
|
|
185
|
-
|
|
186
158
|
function cleanStringRecord(value: unknown, field: string): Record<string, string> {
|
|
187
159
|
if (value === undefined || value === null) return {};
|
|
188
160
|
if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
|
|
@@ -213,13 +185,6 @@ function cleanNumber(value: unknown, field: string, opts: { min: number; max: nu
|
|
|
213
185
|
return value;
|
|
214
186
|
}
|
|
215
187
|
|
|
216
|
-
function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
|
|
217
|
-
if (typeof value !== "string" || !valid.includes(value)) {
|
|
218
|
-
throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
|
|
219
|
-
}
|
|
220
|
-
return value as T[number];
|
|
221
|
-
}
|
|
222
|
-
|
|
223
188
|
function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Partial<AgentProfile>): AgentProfile {
|
|
224
189
|
const isolated = input.base !== "host";
|
|
225
190
|
return {
|
|
@@ -296,7 +261,7 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
|
|
|
296
261
|
: cleanEnum(value.provider, "provider", VALID_PROFILE_PROVIDERS),
|
|
297
262
|
instructions: {
|
|
298
263
|
system: cleanString(instructions.system, "instructions.system", { max: 16_000 }),
|
|
299
|
-
append: cleanStringArray(instructions.append, "instructions.append"),
|
|
264
|
+
append: cleanStringArray(instructions.append, "instructions.append", { itemMax: 120, maxItems: 100 }) ?? [],
|
|
300
265
|
repoInstructions: instructions.repoInstructions === undefined
|
|
301
266
|
? defaults.instructions.repoInstructions
|
|
302
267
|
: cleanEnum(instructions.repoInstructions, "instructions.repoInstructions", VALID_PROFILE_INSTRUCTION_POLICIES),
|
|
@@ -343,16 +308,16 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
|
|
|
343
308
|
enabled: value.enabled === undefined ? true : cleanBoolean(value.enabled, "enabled"),
|
|
344
309
|
orchestratorId: cleanString(value.orchestratorId, "orchestratorId", { required: true, max: 200 })!,
|
|
345
310
|
cwd: cleanString(value.cwd, "cwd", { required: true, max: 1000 })!,
|
|
346
|
-
provider: cleanEnum(value.provider, "provider",
|
|
311
|
+
provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
|
|
347
312
|
workspaceMode: value.workspaceMode === undefined || value.workspaceMode === null ? "inherit" : cleanEnum(value.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
|
|
348
313
|
rig: cleanString(value.rig, "rig", { max: 120 }),
|
|
349
314
|
model: cleanString(value.model, "model", { max: 120 }),
|
|
350
315
|
effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
|
|
351
316
|
profile: cleanString(value.profile, "profile", { max: 120 }),
|
|
352
|
-
providerArgs: cleanStringArray(value.providerArgs, "providerArgs"),
|
|
317
|
+
providerArgs: cleanStringArray(value.providerArgs, "providerArgs", { itemMax: 120, maxItems: 100 }) ?? [],
|
|
353
318
|
prompt: cleanString(value.prompt, "prompt", { max: 16_000 }),
|
|
354
|
-
tags: cleanStringArray(value.tags, "tags"),
|
|
355
|
-
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 }) ?? [],
|
|
356
321
|
label: cleanString(value.label, "label", { max: 120 }),
|
|
357
322
|
mode,
|
|
358
323
|
permissionMode: cleanEnum(value.permissionMode, "permissionMode", VALID_PERMISSION_MODES),
|
|
@@ -363,7 +328,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
|
|
|
363
328
|
try {
|
|
364
329
|
resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
|
|
365
330
|
} catch (error) {
|
|
366
|
-
throw new ValidationError(
|
|
331
|
+
throw new ValidationError(errMessage(error));
|
|
367
332
|
}
|
|
368
333
|
if (policy.profile && !getAgentProfile(policy.profile)) throw new ValidationError("agent profile not found");
|
|
369
334
|
if (mode === "on-demand") policy.onDemand = cleanOnDemand(value.onDemand);
|
|
@@ -411,7 +376,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
|
|
|
411
376
|
if (!isRecord(value)) throw new ValidationError("steward config value must be an object");
|
|
412
377
|
const config: StewardConfig = {
|
|
413
378
|
enabled: value.enabled === undefined ? false : cleanBoolean(value.enabled, "enabled"),
|
|
414
|
-
provider: cleanEnum(value.provider, "provider",
|
|
379
|
+
provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
|
|
415
380
|
model: cleanString(value.model, "model", { max: 120 }),
|
|
416
381
|
effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
|
|
417
382
|
permissionMode: (value.permissionMode === undefined || value.permissionMode === null
|
|
@@ -426,7 +391,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
|
|
|
426
391
|
try {
|
|
427
392
|
resolveProviderSelection({ provider: config.provider, model: config.model, effort: config.effort });
|
|
428
393
|
} catch (error) {
|
|
429
|
-
throw new ValidationError(
|
|
394
|
+
throw new ValidationError(errMessage(error));
|
|
430
395
|
}
|
|
431
396
|
return config;
|
|
432
397
|
}
|
|
@@ -484,7 +449,7 @@ const WORKSPACE_CONFIG_DEFAULTS: WorkspaceConfig = {
|
|
|
484
449
|
|
|
485
450
|
function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
|
|
486
451
|
if (!isRecord(value)) throw new ValidationError("workspace config value must be an object");
|
|
487
|
-
const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths");
|
|
452
|
+
const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths", { itemMax: 120, maxItems: 100 }) ?? [];
|
|
488
453
|
// Reject absolute paths and parent-traversal up front: symlink sources must stay
|
|
489
454
|
// inside the main checkout. The orchestrator re-checks containment at link time,
|
|
490
455
|
// but failing here gives the operator immediate feedback in the dashboard.
|
|
@@ -721,7 +686,7 @@ function listManagedAgentStates(): ManagedAgentState[] {
|
|
|
721
686
|
|
|
722
687
|
export function upsertManagedAgentState(input: ManagedAgentStateInput): ManagedAgentState {
|
|
723
688
|
if (!VALID_MANAGED_STATUSES.includes(input.status)) throw new ValidationError("status must be a managed-agent status");
|
|
724
|
-
if (!
|
|
689
|
+
if (!SPAWN_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
|
|
725
690
|
const now = input.updatedAt ?? Date.now();
|
|
726
691
|
getDb().query(`
|
|
727
692
|
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/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
|
@@ -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";
|
|
@@ -5092,14 +5079,21 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
|
|
|
5092
5079
|
// preserved and metadata is merged, not replaced.
|
|
5093
5080
|
const existing = getWorkspace(workspace.id);
|
|
5094
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.
|
|
5095
5089
|
return upsertWorkspace({
|
|
5096
5090
|
id: workspace.id,
|
|
5097
5091
|
repoRoot: workspace.repoRoot,
|
|
5098
5092
|
sourceCwd: workspace.sourceCwd ?? agent.cwd,
|
|
5099
5093
|
worktreePath: workspace.worktreePath,
|
|
5100
|
-
branch: workspace.branch,
|
|
5094
|
+
branch: existing?.branch ?? workspace.branch,
|
|
5101
5095
|
baseRef: workspace.baseRef,
|
|
5102
|
-
baseSha: workspace.baseSha,
|
|
5096
|
+
baseSha: existing?.baseSha ?? workspace.baseSha,
|
|
5103
5097
|
mode: workspace.mode,
|
|
5104
5098
|
requestedMode: workspace.requestedMode,
|
|
5105
5099
|
status: preserveStatus ? existing!.status : (workspace.status ?? "active"),
|
|
@@ -5241,6 +5235,18 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
|
|
|
5241
5235
|
return getWorkspace(id);
|
|
5242
5236
|
}
|
|
5243
5237
|
|
|
5238
|
+
// Repoint a workspace row at a recycled branch after a land-and-continue merge
|
|
5239
|
+
// (#206): the worktree switched to a fresh branch cut from the advanced base, so
|
|
5240
|
+
// the row must track the new branch (else the next merge command targets a branch
|
|
5241
|
+
// that no longer exists) and the new base sha. No-op if the row is gone.
|
|
5242
|
+
export function setWorkspaceBranch(id: string, branch: string, baseSha?: string): WorkspaceRecord | null {
|
|
5243
|
+
const existing = getWorkspace(id);
|
|
5244
|
+
if (!existing) return null;
|
|
5245
|
+
db.query(`UPDATE workspaces SET branch = ?, base_sha = coalesce(?, base_sha), updated_at = ? WHERE id = ?`)
|
|
5246
|
+
.run(branch, baseSha ?? null, Date.now(), id);
|
|
5247
|
+
return getWorkspace(id);
|
|
5248
|
+
}
|
|
5249
|
+
|
|
5244
5250
|
// Workspace statuses that count as "live" for stewardship — an agent owning one
|
|
5245
5251
|
// of these is a candidate steward; the repo is worth coordinating.
|
|
5246
5252
|
const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
|
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
|
@@ -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";
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
} from "./security";
|
|
30
31
|
import { handleCli } from "./cli";
|
|
31
32
|
import { startMaintenanceScheduler } from "./maintenance";
|
|
33
|
+
import { errMessage } from "agent-relay-sdk";
|
|
32
34
|
|
|
33
35
|
async function main(): Promise<void> {
|
|
34
36
|
const result = await handleCli(process.argv.slice(2));
|
|
@@ -264,8 +266,7 @@ export function createFetchHandler(
|
|
|
264
266
|
}
|
|
265
267
|
const file = Bun.file(resolved);
|
|
266
268
|
if (await file.exists()) {
|
|
267
|
-
|
|
268
|
-
return new Response(file, { headers });
|
|
269
|
+
return await serveStaticFile(req, resolved, requested, file);
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
return Response.json({ error: "not found" }, { status: 404 });
|
|
@@ -275,13 +276,108 @@ export function createFetchHandler(
|
|
|
275
276
|
};
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
// In-memory compressed-variant cache, keyed by absolute path. Invalidated when
|
|
280
|
+
// the file's mtime/size changes, so a rebuilt bundle is recompressed on next
|
|
281
|
+
// request. The single-file dashboard bundle (~10 MB unminified) is served
|
|
282
|
+
// uncompressed by Bun.serve otherwise; brotli/gzip cuts it ~6-7x on the wire,
|
|
283
|
+
// which is the dominant cost over high-latency links. Further wins (minify,
|
|
284
|
+
// code-split) tracked in #200 / #201.
|
|
285
|
+
type CompressedVariant = { body: Uint8Array; encoding: "br" | "gzip" };
|
|
286
|
+
const compressedCache = new Map<
|
|
287
|
+
string,
|
|
288
|
+
{ mtimeMs: number; size: number; br?: Uint8Array; gzip?: Uint8Array }
|
|
289
|
+
>();
|
|
290
|
+
|
|
291
|
+
const COMPRESSIBLE = /\.(html|js|css|svg|json|webmanifest|map)$/;
|
|
292
|
+
|
|
293
|
+
function isCompressible(pathname: string): boolean {
|
|
294
|
+
return pathname === "/" || pathname.endsWith("/") || COMPRESSIBLE.test(pathname);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function negotiateEncoding(req: Request): "br" | "gzip" | null {
|
|
298
|
+
const accept = req.headers.get("accept-encoding") ?? "";
|
|
299
|
+
if (/\bbr\b/.test(accept)) return "br";
|
|
300
|
+
if (/\bgzip\b/.test(accept)) return "gzip";
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function getCompressedVariant(
|
|
305
|
+
resolved: string,
|
|
306
|
+
mtimeMs: number,
|
|
307
|
+
size: number,
|
|
308
|
+
encoding: "br" | "gzip",
|
|
309
|
+
raw: () => Promise<Uint8Array>,
|
|
310
|
+
): Promise<CompressedVariant> {
|
|
311
|
+
let entry = compressedCache.get(resolved);
|
|
312
|
+
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) {
|
|
313
|
+
entry = { mtimeMs, size };
|
|
314
|
+
compressedCache.set(resolved, entry);
|
|
315
|
+
}
|
|
316
|
+
const cached = entry[encoding];
|
|
317
|
+
if (cached) return { body: cached, encoding };
|
|
318
|
+
const data = await raw();
|
|
319
|
+
const body =
|
|
320
|
+
encoding === "br"
|
|
321
|
+
? brotliCompressSync(data, {
|
|
322
|
+
params: {
|
|
323
|
+
// Quality 5 ~ near-gzip CPU, much better ratio; this runs once per
|
|
324
|
+
// file version then serves from cache, so cost is amortized away.
|
|
325
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: 5,
|
|
326
|
+
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.byteLength,
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
: gzipSync(data, { level: 6 });
|
|
330
|
+
entry[encoding] = body;
|
|
331
|
+
return { body, encoding };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function serveStaticFile(
|
|
335
|
+
req: Request,
|
|
336
|
+
resolved: string,
|
|
337
|
+
requested: string,
|
|
338
|
+
file: ReturnType<typeof Bun.file>,
|
|
339
|
+
): Promise<Response> {
|
|
340
|
+
const stat = await file.stat();
|
|
341
|
+
const headers = staticHeaders(requested);
|
|
342
|
+
// Strong-ish validator from mtime + size; encoding-suffixed so a client never
|
|
343
|
+
// gets a 304 for a variant it didn't store.
|
|
344
|
+
const baseTag = `${stat.mtime.getTime().toString(36)}-${stat.size.toString(36)}`;
|
|
345
|
+
|
|
346
|
+
const encoding = isCompressible(requested) ? negotiateEncoding(req) : null;
|
|
347
|
+
const etag = `"${baseTag}${encoding ? `-${encoding}` : ""}"`;
|
|
348
|
+
headers.set("ETag", etag);
|
|
349
|
+
if (req.headers.get("if-none-match") === etag) {
|
|
350
|
+
return new Response(null, { status: 304, headers });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (encoding) {
|
|
354
|
+
const { body } = await getCompressedVariant(
|
|
355
|
+
resolved,
|
|
356
|
+
stat.mtime.getTime(),
|
|
357
|
+
stat.size,
|
|
358
|
+
encoding,
|
|
359
|
+
() => file.bytes(),
|
|
360
|
+
);
|
|
361
|
+
headers.set("Content-Encoding", encoding);
|
|
362
|
+
headers.set("Vary", "Accept-Encoding");
|
|
363
|
+
headers.set("Content-Length", String(body.byteLength));
|
|
364
|
+
// Uint8Array is a valid BodyInit at runtime in Bun; the DOM lib types omit it.
|
|
365
|
+
return new Response(body as unknown as BodyInit, { headers });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return new Response(file, { headers });
|
|
369
|
+
}
|
|
370
|
+
|
|
278
371
|
function staticHeaders(pathname: string): Headers {
|
|
279
372
|
const headers = new Headers();
|
|
280
373
|
const contentType = staticContentType(pathname);
|
|
281
374
|
if (contentType) headers.set("Content-Type", contentType);
|
|
282
|
-
|
|
375
|
+
// The shell + PWA control files must always revalidate (ETag makes this a
|
|
376
|
+
// cheap 304 when unchanged). Hashing isn't available — viteSingleFile inlines
|
|
377
|
+
// everything into index.html — so no immutable long-cache assets exist.
|
|
378
|
+
if (pathname === "/sw.js" || pathname === "/manifest.webmanifest") {
|
|
283
379
|
headers.set("Cache-Control", "no-cache");
|
|
284
|
-
} else if (pathname === "/
|
|
380
|
+
} else if (pathname === "/" || pathname.endsWith("/") || pathname.endsWith(".html")) {
|
|
285
381
|
headers.set("Cache-Control", "no-cache");
|
|
286
382
|
}
|
|
287
383
|
return headers;
|
|
@@ -298,7 +394,7 @@ function staticContentType(pathname: string): string | undefined {
|
|
|
298
394
|
|
|
299
395
|
if (import.meta.main) {
|
|
300
396
|
main().catch((error) => {
|
|
301
|
-
console.error(
|
|
397
|
+
console.error(errMessage(error));
|
|
302
398
|
process.exit(1);
|
|
303
399
|
});
|
|
304
400
|
}
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
1
|
import { createCommand } from "./commands-db";
|
|
2
|
+
import { isPathWithinBase } from "./utils";
|
|
3
3
|
import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces, resolveQueuedPolicyMessages } from "./db";
|
|
4
4
|
import {
|
|
5
5
|
getManagedAgentState,
|
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
upsertManagedAgentState,
|
|
9
9
|
} from "./config-store";
|
|
10
10
|
import { emitRelayEvent } from "./events";
|
|
11
|
+
import { emitCommandEvent } from "./command-events";
|
|
11
12
|
import { buildManagedSpawnParams } from "./managed-policy";
|
|
13
|
+
import { generateSpawnRequestId } from "./spawn-command";
|
|
12
14
|
import type { Command, ManagedAgent, ManagedAgentState, ManagedSessionExitDiagnostics, SpawnPolicy } from "./types";
|
|
13
15
|
|
|
14
16
|
const DEFAULT_TICK_MS = 10_000;
|
|
@@ -208,7 +210,7 @@ export class LifecycleManager {
|
|
|
208
210
|
this.emitState(state);
|
|
209
211
|
return null;
|
|
210
212
|
}
|
|
211
|
-
const spawnRequestId =
|
|
213
|
+
const spawnRequestId = generateSpawnRequestId();
|
|
212
214
|
const state = upsertManagedAgentState({
|
|
213
215
|
policyName: policy.name,
|
|
214
216
|
status: "starting",
|
|
@@ -230,7 +232,7 @@ export class LifecycleManager {
|
|
|
230
232
|
reason,
|
|
231
233
|
},
|
|
232
234
|
});
|
|
233
|
-
|
|
235
|
+
emitCommandEvent(command, "command.requested");
|
|
234
236
|
return command;
|
|
235
237
|
}
|
|
236
238
|
|
|
@@ -270,7 +272,7 @@ export class LifecycleManager {
|
|
|
270
272
|
requestedAt: this.now(),
|
|
271
273
|
},
|
|
272
274
|
});
|
|
273
|
-
|
|
275
|
+
emitCommandEvent(command, "command.requested");
|
|
274
276
|
return command;
|
|
275
277
|
}
|
|
276
278
|
|
|
@@ -278,7 +280,7 @@ export class LifecycleManager {
|
|
|
278
280
|
const orch = getOrchestrator(policy.orchestratorId);
|
|
279
281
|
if (!orch) return null;
|
|
280
282
|
const state = getManagedAgentState(policy.name);
|
|
281
|
-
const restartRequestId =
|
|
283
|
+
const restartRequestId = generateSpawnRequestId();
|
|
282
284
|
const restartSpawn = buildManagedSpawnParams(policy, restartRequestId, { createdBy: "lifecycle-manager", requestedAt: this.now() });
|
|
283
285
|
const restarted = upsertManagedAgentState({
|
|
284
286
|
policyName: policy.name,
|
|
@@ -314,7 +316,7 @@ export class LifecycleManager {
|
|
|
314
316
|
requestedAt: this.now(),
|
|
315
317
|
},
|
|
316
318
|
});
|
|
317
|
-
|
|
319
|
+
emitCommandEvent(command, "command.requested");
|
|
318
320
|
return command;
|
|
319
321
|
}
|
|
320
322
|
|
|
@@ -406,7 +408,7 @@ export class LifecycleManager {
|
|
|
406
408
|
requestedAt: this.now(),
|
|
407
409
|
},
|
|
408
410
|
});
|
|
409
|
-
|
|
411
|
+
emitCommandEvent(command, "command.requested");
|
|
410
412
|
return command;
|
|
411
413
|
}
|
|
412
414
|
|
|
@@ -564,15 +566,6 @@ export class LifecycleManager {
|
|
|
564
566
|
});
|
|
565
567
|
}
|
|
566
568
|
|
|
567
|
-
private emitCommand(command: Command): void {
|
|
568
|
-
emitRelayEvent({
|
|
569
|
-
type: "command.requested",
|
|
570
|
-
source: command.source,
|
|
571
|
-
subject: command.id,
|
|
572
|
-
data: { command },
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
|
|
576
569
|
// When an agent disappears, its isolated worktrees would otherwise sit
|
|
577
570
|
// `active` on disk forever. Dispatch a workspace.reconcile command to the
|
|
578
571
|
// owning orchestrator, which probes the worktree and either removes it (no
|
|
@@ -593,7 +586,7 @@ export class LifecycleManager {
|
|
|
593
586
|
const orchestrators = listOrchestrators();
|
|
594
587
|
for (const ws of candidates) {
|
|
595
588
|
const orch = orchestrators.find(
|
|
596
|
-
(candidate) => candidate.status === "online" &&
|
|
589
|
+
(candidate) => candidate.status === "online" && isPathWithinBase(ws.sourceCwd, candidate.baseDir),
|
|
597
590
|
);
|
|
598
591
|
if (!orch) continue;
|
|
599
592
|
const command = createCommand({
|
|
@@ -614,17 +607,11 @@ export class LifecycleManager {
|
|
|
614
607
|
requestedAt: this.now(),
|
|
615
608
|
},
|
|
616
609
|
});
|
|
617
|
-
|
|
610
|
+
emitCommandEvent(command, "command.requested");
|
|
618
611
|
}
|
|
619
612
|
}
|
|
620
613
|
}
|
|
621
614
|
|
|
622
|
-
function pathWithinBaseDir(path: string | undefined, baseDir: string | undefined): boolean {
|
|
623
|
-
if (!path || !baseDir) return false;
|
|
624
|
-
const rel = relative(resolve(baseDir), resolve(path));
|
|
625
|
-
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
626
|
-
}
|
|
627
|
-
|
|
628
615
|
let singleton: LifecycleManager | null = null;
|
|
629
616
|
|
|
630
617
|
export function getLifecycleManager(): LifecycleManager {
|