agent-relay-server 0.15.1 → 0.17.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.
@@ -12,6 +12,7 @@ import type {
12
12
  SpawnPolicy,
13
13
  SpawnProvider,
14
14
  StewardConfig,
15
+ WorkspaceConfig,
15
16
  } from "./types";
16
17
 
17
18
  const CONFIG_HISTORY_LIMIT = 50;
@@ -21,6 +22,8 @@ const STEWARD_NAMESPACE = "steward";
21
22
  const STEWARD_KEY = "default";
22
23
  const INSIGHTS_NAMESPACE = "insights";
23
24
  const INSIGHTS_KEY = "default";
25
+ const WORKSPACE_NAMESPACE = "workspace";
26
+ const WORKSPACE_KEY = "default";
24
27
  const VALID_PROVIDERS = ["claude", "codex"] as const;
25
28
  const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
26
29
  const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
@@ -472,24 +475,46 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
472
475
  };
473
476
  }
474
477
 
478
+ // Global workspace provisioning config for isolated worktrees (#159 follow-up).
479
+ // Defaults seed the two untracked paths an isolated agent almost always needs:
480
+ // the agent guide and the rig config, both gitignored so a fresh worktree lacks them.
481
+ const WORKSPACE_CONFIG_DEFAULTS: WorkspaceConfig = {
482
+ symlinkPaths: ["AGENTS.md", ".claude-rig"],
483
+ };
484
+
485
+ function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
486
+ if (!isRecord(value)) throw new ValidationError("workspace config value must be an object");
487
+ const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths");
488
+ // Reject absolute paths and parent-traversal up front: symlink sources must stay
489
+ // inside the main checkout. The orchestrator re-checks containment at link time,
490
+ // but failing here gives the operator immediate feedback in the dashboard.
491
+ for (const entry of symlinkPaths) {
492
+ if (entry.startsWith("/") || entry.split(/[\\/]/).includes("..")) {
493
+ throw new ValidationError(`symlinkPaths entry must be a relative path within the repo: ${entry}`);
494
+ }
495
+ }
496
+ return { symlinkPaths };
497
+ }
498
+
475
499
  function normalizeValue(namespace: string, key: string, value: unknown): unknown {
476
500
  if (value === undefined) throw new ValidationError("value required");
477
501
  if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
478
502
  if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
479
503
  if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
480
504
  if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
505
+ if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
481
506
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
482
507
  return value;
483
508
  }
484
509
 
485
510
  export function getConfig<T = unknown>(namespace: string, key: string): ConfigEntry<T> | null {
486
- const row = getDb().prepare("SELECT * FROM config WHERE namespace = ? AND key = ?").get(namespace, key) as ConfigRow | undefined;
511
+ const row = getDb().query("SELECT * FROM config WHERE namespace = ? AND key = ?").get(namespace, key) as ConfigRow | undefined;
487
512
  return row ? rowToConfigEntry<T>(row) : null;
488
513
  }
489
514
 
490
515
  export function listConfig<T = unknown>(namespace: string): ConfigEntry<T>[] {
491
516
  const rows = getDb()
492
- .prepare("SELECT * FROM config WHERE namespace = ? ORDER BY key ASC")
517
+ .query("SELECT * FROM config WHERE namespace = ? ORDER BY key ASC")
493
518
  .all(namespace) as ConfigRow[];
494
519
  return rows.map(rowToConfigEntry<T>);
495
520
  }
@@ -502,12 +527,12 @@ export function setConfig<T = unknown>(namespace: string, key: string, value: T,
502
527
 
503
528
  getDb().transaction(() => {
504
529
  if (existing) {
505
- getDb().prepare(`
530
+ getDb().query(`
506
531
  INSERT INTO config_history (namespace, key, value, version, changed_at, changed_by)
507
532
  VALUES (?, ?, ?, ?, ?, ?)
508
533
  `).run(existing.namespace, existing.key, JSON.stringify(existing.value), existing.version, now, updatedBy ?? null);
509
534
  }
510
- getDb().prepare(`
535
+ getDb().query(`
511
536
  INSERT INTO config (namespace, key, value, version, updated_at, updated_by)
512
537
  VALUES (?, ?, ?, ?, ?, ?)
513
538
  ON CONFLICT(namespace, key) DO UPDATE SET
@@ -527,11 +552,11 @@ export function deleteConfig(namespace: string, key: string, updatedBy?: string)
527
552
  if (!existing) return false;
528
553
  const now = new Date().toISOString();
529
554
  getDb().transaction(() => {
530
- getDb().prepare(`
555
+ getDb().query(`
531
556
  INSERT INTO config_history (namespace, key, value, version, changed_at, changed_by)
532
557
  VALUES (?, ?, ?, ?, ?, ?)
533
558
  `).run(existing.namespace, existing.key, JSON.stringify(existing.value), existing.version, now, updatedBy ?? null);
534
- getDb().prepare("DELETE FROM config WHERE namespace = ? AND key = ?").run(namespace, key);
559
+ getDb().query("DELETE FROM config WHERE namespace = ? AND key = ?").run(namespace, key);
535
560
  pruneConfigHistory(namespace, key);
536
561
  })();
537
562
  return true;
@@ -540,13 +565,13 @@ export function deleteConfig(namespace: string, key: string, updatedBy?: string)
540
565
  export function getConfigHistory<T = unknown>(namespace: string, key: string, limit = CONFIG_HISTORY_LIMIT): ConfigHistoryEntry<T>[] {
541
566
  const safeLimit = Math.min(Math.max(limit, 1), 500);
542
567
  const rows = getDb()
543
- .prepare("SELECT * FROM config_history WHERE namespace = ? AND key = ? ORDER BY version DESC, id DESC LIMIT ?")
568
+ .query("SELECT * FROM config_history WHERE namespace = ? AND key = ? ORDER BY version DESC, id DESC LIMIT ?")
544
569
  .all(namespace, key, safeLimit) as ConfigHistoryRow[];
545
570
  return rows.map(rowToConfigHistoryEntry<T>);
546
571
  }
547
572
 
548
573
  function pruneConfigHistory(namespace: string, key: string): void {
549
- getDb().prepare(`
574
+ getDb().query(`
550
575
  DELETE FROM config_history
551
576
  WHERE namespace = ? AND key = ? AND id NOT IN (
552
577
  SELECT id FROM config_history
@@ -614,6 +639,40 @@ export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEnt
614
639
  return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
615
640
  }
616
641
 
642
+ /** Global workspace config, merged over defaults (always returns a usable value). */
643
+ export function getWorkspaceConfig(): WorkspaceConfig {
644
+ const entry = getConfig<Partial<WorkspaceConfig>>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
645
+ if (!entry) return { ...WORKSPACE_CONFIG_DEFAULTS };
646
+ return validateWorkspaceConfig({ ...WORKSPACE_CONFIG_DEFAULTS, ...entry.value });
647
+ }
648
+
649
+ export function getWorkspaceConfigEntry(): ConfigEntry<WorkspaceConfig> {
650
+ const entry = getConfig<WorkspaceConfig>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
651
+ return entry ?? {
652
+ namespace: WORKSPACE_NAMESPACE,
653
+ key: WORKSPACE_KEY,
654
+ value: { ...WORKSPACE_CONFIG_DEFAULTS },
655
+ version: 0,
656
+ updatedAt: "default",
657
+ updatedBy: "system",
658
+ };
659
+ }
660
+
661
+ export function setWorkspaceConfig(value: unknown, updatedBy?: string): ConfigEntry<WorkspaceConfig> {
662
+ return setConfig(WORKSPACE_NAMESPACE, WORKSPACE_KEY, value as WorkspaceConfig, updatedBy);
663
+ }
664
+
665
+ /**
666
+ * Spawn-param fragment carrying the global workspace symlink list to the orchestrator.
667
+ * Spread into every spawn command's params next to `agentProfile` so any isolated
668
+ * worktree — direct spawn, managed policy, restart, automation — provisions the same
669
+ * untracked paths. Returns `{}` when nothing is configured (keeps params lean).
670
+ */
671
+ export function workspaceSpawnParams(): { workspaceSymlinks?: string[] } {
672
+ const { symlinkPaths } = getWorkspaceConfig();
673
+ return symlinkPaths.length ? { workspaceSymlinks: symlinkPaths } : {};
674
+ }
675
+
617
676
  function builtInProfileEntry(profile: AgentProfile): ConfigEntry<AgentProfile> {
618
677
  return {
619
678
  namespace: AGENT_PROFILE_NAMESPACE,
@@ -649,13 +708,13 @@ export function deleteAgentProfile(name: string, updatedBy?: string): boolean {
649
708
  }
650
709
 
651
710
  export function getManagedAgentState(policyName: string): ManagedAgentState | null {
652
- const row = getDb().prepare("SELECT * FROM managed_agent_state WHERE policy_name = ?").get(policyName) as ManagedAgentStateRow | undefined;
711
+ const row = getDb().query("SELECT * FROM managed_agent_state WHERE policy_name = ?").get(policyName) as ManagedAgentStateRow | undefined;
653
712
  return row ? rowToManagedAgentState(row) : null;
654
713
  }
655
714
 
656
715
  function listManagedAgentStates(): ManagedAgentState[] {
657
716
  const rows = getDb()
658
- .prepare("SELECT * FROM managed_agent_state ORDER BY policy_name ASC")
717
+ .query("SELECT * FROM managed_agent_state ORDER BY policy_name ASC")
659
718
  .all() as ManagedAgentStateRow[];
660
719
  return rows.map(rowToManagedAgentState);
661
720
  }
@@ -664,7 +723,7 @@ export function upsertManagedAgentState(input: ManagedAgentStateInput): ManagedA
664
723
  if (!VALID_MANAGED_STATUSES.includes(input.status)) throw new ValidationError("status must be a managed-agent status");
665
724
  if (!VALID_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
666
725
  const now = input.updatedAt ?? Date.now();
667
- getDb().prepare(`
726
+ getDb().query(`
668
727
  INSERT INTO managed_agent_state (
669
728
  policy_name, status, agent_id, orchestrator_id, provider, tmux_session, spawn_request_id, workspace_id, workspace_path, workspace_branch,
670
729
  last_spawn_at, last_stop_at, healthy_since, restart_count, consecutive_failures,
@@ -724,6 +783,6 @@ export function updateManagedAgentState(policyName: string, patch: ManagedAgentS
724
783
  }
725
784
 
726
785
  function deleteManagedAgentState(policyName: string): boolean {
727
- const result = getDb().prepare("DELETE FROM managed_agent_state WHERE policy_name = ?").run(policyName);
786
+ const result = getDb().query("DELETE FROM managed_agent_state WHERE policy_name = ?").run(policyName);
728
787
  return result.changes > 0;
729
788
  }