agent-relay-server 0.10.7 → 0.10.8

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.
@@ -0,0 +1,380 @@
1
+ import { getDb, ValidationError } from "./db";
2
+ import type {
3
+ ConfigEntry,
4
+ ConfigHistoryEntry,
5
+ ManagedAgentState,
6
+ ManagedAgentStatus,
7
+ SpawnPolicy,
8
+ SpawnProvider,
9
+ } from "./types";
10
+
11
+ const CONFIG_HISTORY_LIMIT = 50;
12
+ const SPAWN_POLICY_NAMESPACE = "spawn-policy";
13
+ const VALID_PROVIDERS = ["claude", "codex"] as const;
14
+ const VALID_PERMISSION_MODES = ["open", "guarded", "read-only"] as const;
15
+ const VALID_POLICY_MODES = ["always-on", "on-demand"] as const;
16
+ const VALID_MANAGED_STATUSES = ["stopped", "starting", "running", "stopping", "backoff"] as const;
17
+
18
+ interface ConfigRow {
19
+ namespace: string;
20
+ key: string;
21
+ value: string;
22
+ version: number;
23
+ updated_at: string;
24
+ updated_by: string | null;
25
+ }
26
+
27
+ interface ConfigHistoryRow {
28
+ id: number;
29
+ namespace: string;
30
+ key: string;
31
+ value: string;
32
+ version: number;
33
+ changed_at: string;
34
+ changed_by: string | null;
35
+ }
36
+
37
+ interface ManagedAgentStateRow {
38
+ policy_name: string;
39
+ status: ManagedAgentStatus;
40
+ agent_id: string | null;
41
+ orchestrator_id: string;
42
+ provider: SpawnProvider;
43
+ tmux_session: string | null;
44
+ spawn_request_id: string | null;
45
+ last_spawn_at: number | null;
46
+ last_stop_at: number | null;
47
+ healthy_since: number | null;
48
+ restart_count: number;
49
+ consecutive_failures: number;
50
+ backoff_until: number | null;
51
+ last_error: string | null;
52
+ updated_at: number;
53
+ }
54
+
55
+ export type ManagedAgentStateInput = Omit<ManagedAgentState, "updatedAt" | "restartCount" | "consecutiveFailures"> &
56
+ Partial<Pick<ManagedAgentState, "updatedAt" | "restartCount" | "consecutiveFailures">>;
57
+
58
+ export type ManagedAgentStatePatch = Partial<Omit<ManagedAgentState, "policyName" | "orchestratorId" | "provider">> &
59
+ Partial<Pick<ManagedAgentState, "orchestratorId" | "provider">>;
60
+
61
+ function parseJsonValue(raw: string): unknown {
62
+ try {
63
+ return JSON.parse(raw);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function rowToConfigEntry<T>(row: ConfigRow): ConfigEntry<T> {
70
+ return {
71
+ namespace: row.namespace,
72
+ key: row.key,
73
+ value: parseJsonValue(row.value) as T,
74
+ version: row.version,
75
+ updatedAt: row.updated_at,
76
+ updatedBy: row.updated_by ?? undefined,
77
+ };
78
+ }
79
+
80
+ function rowToConfigHistoryEntry<T>(row: ConfigHistoryRow): ConfigHistoryEntry<T> {
81
+ return {
82
+ id: row.id,
83
+ namespace: row.namespace,
84
+ key: row.key,
85
+ value: parseJsonValue(row.value) as T,
86
+ version: row.version,
87
+ changedAt: row.changed_at,
88
+ changedBy: row.changed_by ?? undefined,
89
+ };
90
+ }
91
+
92
+ function rowToManagedAgentState(row: ManagedAgentStateRow): ManagedAgentState {
93
+ return {
94
+ policyName: row.policy_name,
95
+ status: row.status,
96
+ agentId: row.agent_id ?? undefined,
97
+ orchestratorId: row.orchestrator_id,
98
+ provider: row.provider,
99
+ tmuxSession: row.tmux_session ?? undefined,
100
+ spawnRequestId: row.spawn_request_id ?? undefined,
101
+ lastSpawnAt: row.last_spawn_at ?? undefined,
102
+ lastStopAt: row.last_stop_at ?? undefined,
103
+ healthySince: row.healthy_since ?? undefined,
104
+ restartCount: row.restart_count,
105
+ consecutiveFailures: row.consecutive_failures,
106
+ backoffUntil: row.backoff_until ?? undefined,
107
+ lastError: row.last_error ?? undefined,
108
+ updatedAt: row.updated_at,
109
+ };
110
+ }
111
+
112
+ function isRecord(value: unknown): value is Record<string, unknown> {
113
+ return typeof value === "object" && value !== null && !Array.isArray(value);
114
+ }
115
+
116
+ function cleanString(value: unknown, field: string, opts: { required?: boolean; max?: number } = {}): string | undefined {
117
+ if (value === undefined || value === null) {
118
+ if (opts.required) throw new ValidationError(`${field} required`);
119
+ return undefined;
120
+ }
121
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
122
+ const trimmed = value.trim();
123
+ if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
124
+ if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
125
+ return trimmed || undefined;
126
+ }
127
+
128
+ function cleanStringArray(value: unknown, field: string, opts: { required?: boolean } = {}): string[] {
129
+ if (value === undefined || value === null) {
130
+ if (opts.required) throw new ValidationError(`${field} required`);
131
+ return [];
132
+ }
133
+ if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
134
+ const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: 120 })).filter(Boolean) as string[];
135
+ if (cleaned.length > 100) throw new ValidationError(`${field} can contain at most 100 values`);
136
+ return [...new Set(cleaned)];
137
+ }
138
+
139
+ function cleanBoolean(value: unknown, field: string): boolean {
140
+ if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
141
+ return value;
142
+ }
143
+
144
+ function cleanNumber(value: unknown, field: string, opts: { min: number; max: number }): number {
145
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < opts.min || value > opts.max) {
146
+ throw new ValidationError(`${field} must be an integer between ${opts.min} and ${opts.max}`);
147
+ }
148
+ return value;
149
+ }
150
+
151
+ function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
152
+ if (typeof value !== "string" || !valid.includes(value)) {
153
+ throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
154
+ }
155
+ return value as T[number];
156
+ }
157
+
158
+ export function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
159
+ if (!isRecord(value)) throw new ValidationError("spawn-policy value must be an object");
160
+ const name = cleanString(value.name, "name", { required: true, max: 120 })!;
161
+ if (name !== key) throw new ValidationError("spawn-policy name must match config key");
162
+ const mode = cleanEnum(value.mode, "mode", VALID_POLICY_MODES);
163
+ const policy: SpawnPolicy = {
164
+ name,
165
+ description: cleanString(value.description, "description", { max: 1000 }),
166
+ orchestratorId: cleanString(value.orchestratorId, "orchestratorId", { required: true, max: 200 })!,
167
+ cwd: cleanString(value.cwd, "cwd", { required: true, max: 1000 })!,
168
+ provider: cleanEnum(value.provider, "provider", VALID_PROVIDERS) as SpawnProvider,
169
+ providerArgs: cleanStringArray(value.providerArgs, "providerArgs"),
170
+ prompt: cleanString(value.prompt, "prompt", { max: 16_000 }),
171
+ tags: cleanStringArray(value.tags, "tags"),
172
+ capabilities: cleanStringArray(value.capabilities, "capabilities"),
173
+ label: cleanString(value.label, "label", { max: 120 }),
174
+ mode,
175
+ permissionMode: cleanEnum(value.permissionMode, "permissionMode", VALID_PERMISSION_MODES),
176
+ restartOnUpdate: cleanBoolean(value.restartOnUpdate, "restartOnUpdate"),
177
+ scheduledDailyRestart: cleanBoolean(value.scheduledDailyRestart, "scheduledDailyRestart"),
178
+ backoff: cleanBackoff(value.backoff),
179
+ };
180
+ if (mode === "on-demand") policy.onDemand = cleanOnDemand(value.onDemand);
181
+ if (value.binding !== undefined && value.binding !== null) policy.binding = cleanBinding(value.binding);
182
+ return policy;
183
+ }
184
+
185
+ function cleanBackoff(value: unknown): SpawnPolicy["backoff"] {
186
+ if (!isRecord(value)) throw new ValidationError("backoff must be an object");
187
+ if (!Array.isArray(value.schedule) || value.schedule.length === 0) {
188
+ throw new ValidationError("backoff.schedule must be a non-empty array");
189
+ }
190
+ if (value.schedule.length > 20) throw new ValidationError("backoff.schedule can contain at most 20 values");
191
+ return {
192
+ schedule: value.schedule.map((item, index) => cleanNumber(item, `backoff.schedule[${index}]`, { min: 1, max: 86_400 })),
193
+ resetAfterSeconds: cleanNumber(value.resetAfterSeconds, "backoff.resetAfterSeconds", { min: 1, max: 86_400 }),
194
+ };
195
+ }
196
+
197
+ function cleanOnDemand(value: unknown): NonNullable<SpawnPolicy["onDemand"]> {
198
+ if (!isRecord(value)) throw new ValidationError("onDemand must be an object when mode is on-demand");
199
+ const idleDefinition = cleanEnum(value.idleDefinition, "onDemand.idleDefinition", ["no-activity"] as const);
200
+ return {
201
+ keepaliveSeconds: cleanNumber(value.keepaliveSeconds, "onDemand.keepaliveSeconds", { min: 0, max: 2_592_000 }),
202
+ idleDefinition,
203
+ };
204
+ }
205
+
206
+ function cleanBinding(value: unknown): NonNullable<SpawnPolicy["binding"]> {
207
+ if (!isRecord(value)) throw new ValidationError("binding must be an object");
208
+ return {
209
+ type: cleanEnum(value.type, "binding.type", ["channel"] as const),
210
+ channelId: cleanString(value.channelId, "binding.channelId", { required: true, max: 200 })!,
211
+ };
212
+ }
213
+
214
+ function normalizeValue(namespace: string, key: string, value: unknown): unknown {
215
+ if (value === undefined) throw new ValidationError("value required");
216
+ if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
217
+ if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
218
+ return value;
219
+ }
220
+
221
+ export function getConfig<T = unknown>(namespace: string, key: string): ConfigEntry<T> | null {
222
+ const row = getDb().prepare("SELECT * FROM config WHERE namespace = ? AND key = ?").get(namespace, key) as ConfigRow | undefined;
223
+ return row ? rowToConfigEntry<T>(row) : null;
224
+ }
225
+
226
+ export function listConfig<T = unknown>(namespace: string): ConfigEntry<T>[] {
227
+ const rows = getDb()
228
+ .prepare("SELECT * FROM config WHERE namespace = ? ORDER BY key ASC")
229
+ .all(namespace) as ConfigRow[];
230
+ return rows.map(rowToConfigEntry<T>);
231
+ }
232
+
233
+ export function setConfig<T = unknown>(namespace: string, key: string, value: T, updatedBy?: string): ConfigEntry<T> {
234
+ const normalized = normalizeValue(namespace, key, value) as T;
235
+ const existing = getConfig(namespace, key);
236
+ const now = new Date().toISOString();
237
+ const nextVersion = existing ? existing.version + 1 : 1;
238
+
239
+ getDb().transaction(() => {
240
+ if (existing) {
241
+ getDb().prepare(`
242
+ INSERT INTO config_history (namespace, key, value, version, changed_at, changed_by)
243
+ VALUES (?, ?, ?, ?, ?, ?)
244
+ `).run(existing.namespace, existing.key, JSON.stringify(existing.value), existing.version, now, updatedBy ?? null);
245
+ }
246
+ getDb().prepare(`
247
+ INSERT INTO config (namespace, key, value, version, updated_at, updated_by)
248
+ VALUES (?, ?, ?, ?, ?, ?)
249
+ ON CONFLICT(namespace, key) DO UPDATE SET
250
+ value = excluded.value,
251
+ version = excluded.version,
252
+ updated_at = excluded.updated_at,
253
+ updated_by = excluded.updated_by
254
+ `).run(namespace, key, JSON.stringify(normalized), nextVersion, now, updatedBy ?? null);
255
+ pruneConfigHistory(namespace, key);
256
+ })();
257
+
258
+ return getConfig<T>(namespace, key)!;
259
+ }
260
+
261
+ export function deleteConfig(namespace: string, key: string, updatedBy?: string): boolean {
262
+ const existing = getConfig(namespace, key);
263
+ if (!existing) return false;
264
+ const now = new Date().toISOString();
265
+ getDb().transaction(() => {
266
+ getDb().prepare(`
267
+ INSERT INTO config_history (namespace, key, value, version, changed_at, changed_by)
268
+ VALUES (?, ?, ?, ?, ?, ?)
269
+ `).run(existing.namespace, existing.key, JSON.stringify(existing.value), existing.version, now, updatedBy ?? null);
270
+ getDb().prepare("DELETE FROM config WHERE namespace = ? AND key = ?").run(namespace, key);
271
+ pruneConfigHistory(namespace, key);
272
+ })();
273
+ return true;
274
+ }
275
+
276
+ export function getConfigHistory<T = unknown>(namespace: string, key: string, limit = CONFIG_HISTORY_LIMIT): ConfigHistoryEntry<T>[] {
277
+ const safeLimit = Math.min(Math.max(limit, 1), 500);
278
+ const rows = getDb()
279
+ .prepare("SELECT * FROM config_history WHERE namespace = ? AND key = ? ORDER BY version DESC, id DESC LIMIT ?")
280
+ .all(namespace, key, safeLimit) as ConfigHistoryRow[];
281
+ return rows.map(rowToConfigHistoryEntry<T>);
282
+ }
283
+
284
+ function pruneConfigHistory(namespace: string, key: string): void {
285
+ getDb().prepare(`
286
+ DELETE FROM config_history
287
+ WHERE namespace = ? AND key = ? AND id NOT IN (
288
+ SELECT id FROM config_history
289
+ WHERE namespace = ? AND key = ?
290
+ ORDER BY version DESC, id DESC
291
+ LIMIT ?
292
+ )
293
+ `).run(namespace, key, namespace, key, CONFIG_HISTORY_LIMIT);
294
+ }
295
+
296
+ export function getSpawnPolicy(name: string): ConfigEntry<SpawnPolicy> | null {
297
+ return getConfig<SpawnPolicy>(SPAWN_POLICY_NAMESPACE, name);
298
+ }
299
+
300
+ export function listSpawnPolicies(): ConfigEntry<SpawnPolicy>[] {
301
+ return listConfig<SpawnPolicy>(SPAWN_POLICY_NAMESPACE);
302
+ }
303
+
304
+ export function setSpawnPolicy(policy: SpawnPolicy, updatedBy?: string): ConfigEntry<SpawnPolicy> {
305
+ return setConfig(SPAWN_POLICY_NAMESPACE, policy.name, policy, updatedBy);
306
+ }
307
+
308
+ export function getManagedAgentState(policyName: string): ManagedAgentState | null {
309
+ const row = getDb().prepare("SELECT * FROM managed_agent_state WHERE policy_name = ?").get(policyName) as ManagedAgentStateRow | undefined;
310
+ return row ? rowToManagedAgentState(row) : null;
311
+ }
312
+
313
+ export function listManagedAgentStates(): ManagedAgentState[] {
314
+ const rows = getDb()
315
+ .prepare("SELECT * FROM managed_agent_state ORDER BY policy_name ASC")
316
+ .all() as ManagedAgentStateRow[];
317
+ return rows.map(rowToManagedAgentState);
318
+ }
319
+
320
+ export function upsertManagedAgentState(input: ManagedAgentStateInput): ManagedAgentState {
321
+ if (!VALID_MANAGED_STATUSES.includes(input.status)) throw new ValidationError("status must be a managed-agent status");
322
+ if (!VALID_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
323
+ const now = input.updatedAt ?? Date.now();
324
+ getDb().prepare(`
325
+ INSERT INTO managed_agent_state (
326
+ policy_name, status, agent_id, orchestrator_id, provider, tmux_session, spawn_request_id,
327
+ last_spawn_at, last_stop_at, healthy_since, restart_count, consecutive_failures,
328
+ backoff_until, last_error, updated_at
329
+ )
330
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
331
+ ON CONFLICT(policy_name) DO UPDATE SET
332
+ status = excluded.status,
333
+ agent_id = excluded.agent_id,
334
+ orchestrator_id = excluded.orchestrator_id,
335
+ provider = excluded.provider,
336
+ tmux_session = excluded.tmux_session,
337
+ spawn_request_id = excluded.spawn_request_id,
338
+ last_spawn_at = excluded.last_spawn_at,
339
+ last_stop_at = excluded.last_stop_at,
340
+ healthy_since = excluded.healthy_since,
341
+ restart_count = excluded.restart_count,
342
+ consecutive_failures = excluded.consecutive_failures,
343
+ backoff_until = excluded.backoff_until,
344
+ last_error = excluded.last_error,
345
+ updated_at = excluded.updated_at
346
+ `).run(
347
+ input.policyName,
348
+ input.status,
349
+ input.agentId ?? null,
350
+ input.orchestratorId,
351
+ input.provider,
352
+ input.tmuxSession ?? null,
353
+ input.spawnRequestId ?? null,
354
+ input.lastSpawnAt ?? null,
355
+ input.lastStopAt ?? null,
356
+ input.healthySince ?? null,
357
+ input.restartCount ?? 0,
358
+ input.consecutiveFailures ?? 0,
359
+ input.backoffUntil ?? null,
360
+ input.lastError ?? null,
361
+ now,
362
+ );
363
+ return getManagedAgentState(input.policyName)!;
364
+ }
365
+
366
+ export function updateManagedAgentState(policyName: string, patch: ManagedAgentStatePatch): ManagedAgentState | null {
367
+ const existing = getManagedAgentState(policyName);
368
+ if (!existing) return null;
369
+ return upsertManagedAgentState({
370
+ ...existing,
371
+ ...patch,
372
+ policyName,
373
+ updatedAt: patch.updatedAt ?? Date.now(),
374
+ });
375
+ }
376
+
377
+ export function deleteManagedAgentState(policyName: string): boolean {
378
+ const result = getDb().prepare("DELETE FROM managed_agent_state WHERE policy_name = ?").run(policyName);
379
+ return result.changes > 0;
380
+ }