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.
@@ -77,7 +77,7 @@ export function recordObservation(input: RecordObservationInput): InsightObserva
77
77
  const now = input.createdAt ?? Date.now();
78
78
 
79
79
  const result = getDb()
80
- .prepare(
80
+ .query(
81
81
  `INSERT INTO insights_observations (session_id, agent_id, project, signal, value, outcome, source, created_at)
82
82
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
83
83
  )
@@ -95,7 +95,7 @@ export function recordObservation(input: RecordObservationInput): InsightObserva
95
95
  }
96
96
 
97
97
  export function getObservation(id: number): InsightObservation | null {
98
- const row = getDb().prepare("SELECT * FROM insights_observations WHERE id = ?").get(id) as ObservationRow | undefined;
98
+ const row = getDb().query("SELECT * FROM insights_observations WHERE id = ?").get(id) as ObservationRow | undefined;
99
99
  return row ? rowToObservation(row) : null;
100
100
  }
101
101
 
@@ -124,7 +124,7 @@ export function listObservations(query: ListObservationsQuery = {}): InsightObse
124
124
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
125
125
  const limit = Math.min(Math.max(query.limit ?? 200, 1), 1000);
126
126
  const rows = getDb()
127
- .prepare(`SELECT * FROM insights_observations ${where} ORDER BY created_at DESC, id DESC LIMIT ?`)
127
+ .query(`SELECT * FROM insights_observations ${where} ORDER BY created_at DESC, id DESC LIMIT ?`)
128
128
  .all(...args, limit) as ObservationRow[];
129
129
  return rows.map(rowToObservation);
130
130
  }
@@ -139,7 +139,7 @@ export function getObservationStats(signal?: string): InsightsStats[] {
139
139
  const signalFilter = signal ? "WHERE signal = ?" : "";
140
140
  const args = signal ? [signal] : [];
141
141
  const perProject = getDb()
142
- .prepare(
142
+ .query(
143
143
  `SELECT signal, project,
144
144
  COUNT(*) AS count,
145
145
  AVG(CAST(json_extract(value, '$.ratio') AS REAL)) AS avg_ratio,
@@ -152,7 +152,7 @@ export function getObservationStats(signal?: string): InsightsStats[] {
152
152
  .all(...args) as Array<{ signal: string; project: string; count: number; avg_ratio: number | null; last_at: number | null }>;
153
153
 
154
154
  const global = getDb()
155
- .prepare(
155
+ .query(
156
156
  `SELECT signal,
157
157
  COUNT(*) AS count,
158
158
  AVG(CAST(json_extract(value, '$.ratio') AS REAL)) AS avg_ratio,
@@ -173,7 +173,7 @@ export function getObservationStats(signal?: string): InsightsStats[] {
173
173
  /** Distinct projects that have produced at least one observation — feeds per-project baselines. */
174
174
  export function listObservationProjects(): string[] {
175
175
  const rows = getDb()
176
- .prepare("SELECT DISTINCT project FROM insights_observations ORDER BY project ASC")
176
+ .query("SELECT DISTINCT project FROM insights_observations ORDER BY project ASC")
177
177
  .all() as Array<{ project: string }>;
178
178
  return rows.map((r) => r.project);
179
179
  }
@@ -472,7 +472,7 @@ export class LifecycleManager {
472
472
  }
473
473
 
474
474
  private lastActivityAt(agentId: string): number {
475
- const row = getDb().prepare(`
475
+ const row = getDb().query(`
476
476
  SELECT max(created_at) AS at
477
477
  FROM messages
478
478
  WHERE from_agent = ? OR to_target = ? OR resolved_to_agent = ?
@@ -481,12 +481,12 @@ export class LifecycleManager {
481
481
  }
482
482
 
483
483
  private hasQueuedMessages(policyName: string): boolean {
484
- const row = getDb().prepare("SELECT 1 FROM messages WHERE to_target = ? AND delivery_status = 'queued' LIMIT 1").get(`policy:${policyName}`);
484
+ const row = getDb().query("SELECT 1 FROM messages WHERE to_target = ? AND delivery_status = 'queued' LIMIT 1").get(`policy:${policyName}`);
485
485
  return Boolean(row);
486
486
  }
487
487
 
488
488
  private hasActiveCommand(target: string, type: string): boolean {
489
- const row = getDb().prepare("SELECT 1 FROM commands WHERE target = ? AND type = ? AND status IN ('pending', 'accepted', 'running') LIMIT 1").get(target, type);
489
+ const row = getDb().query("SELECT 1 FROM commands WHERE target = ? AND type = ? AND status IN ('pending', 'accepted', 'running') LIMIT 1").get(target, type);
490
490
  return Boolean(row);
491
491
  }
492
492
 
@@ -26,6 +26,7 @@ import {
26
26
  releaseExpiredClaims,
27
27
  releaseExpiredMergeLeases,
28
28
  releaseOrphanedTasks,
29
+ runDbMaintenance,
29
30
  sendMessage,
30
31
  sweepArtifacts,
31
32
  updateWorkspaceStatus,
@@ -56,6 +57,11 @@ const OUTBOX_RETENTION_MS = Number(process.env.AGENT_RELAY_OUTBOX_RETENTION_MS)
56
57
  const TOKEN_RECORD_RETENTION_SECONDS = Number(process.env.AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS) || 7 * 24 * 60 * 60;
57
58
  const CONFLICT_SCAN_INTERVAL_MS = Number(process.env.AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS) || 2 * 60 * 1000;
58
59
  const WORKSPACE_RETENTION_MS = Number(process.env.AGENT_RELAY_WORKSPACE_RETENTION_MS) || DAY_MS;
60
+ const DB_MAINTENANCE_INTERVAL_MS = Number(process.env.AGENT_RELAY_DB_MAINTENANCE_INTERVAL_MS) || DAY_MS;
61
+ // VACUUM rewrites the whole file (auto_vacuum is off), so run it sparingly.
62
+ // Default: every 7th db-maintenance pass (~weekly at the default daily interval).
63
+ const DB_VACUUM_EVERY = Number(process.env.AGENT_RELAY_DB_VACUUM_EVERY) || 7;
64
+ let dbMaintenanceRuns = 0;
59
65
  const WORKSPACE_REVIEW_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_REVIEW_TTL_MS) || 3 * DAY_MS;
60
66
  const WORKSPACE_GC_INTERVAL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_GC_INTERVAL_MS) || 60 * 60 * 1000;
61
67
  // Deterministic auto-land (Layer 0): merge clean fast-forwards with no human in
@@ -305,6 +311,19 @@ const definitions: MaintenanceJobDefinition[] = [
305
311
  return { prunedMessages: pruneOldMessages(retentionDays * DAY_MS) };
306
312
  },
307
313
  },
314
+ {
315
+ id: "db-maintenance",
316
+ title: "Database maintenance",
317
+ description: "Refresh planner stats (ANALYZE + optimize), truncate the WAL, and periodically VACUUM to reclaim space.",
318
+ intervalMs: DB_MAINTENANCE_INTERVAL_MS,
319
+ runOnStart: false,
320
+ timeoutMs: 5 * 60 * 1000,
321
+ handler() {
322
+ dbMaintenanceRuns += 1;
323
+ const vacuum = DB_VACUUM_EVERY > 0 && dbMaintenanceRuns % DB_VACUUM_EVERY === 0;
324
+ return runDbMaintenance({ vacuum });
325
+ },
326
+ },
308
327
  {
309
328
  id: "bus-outbox-prune",
310
329
  title: "Bus outbox prune",
@@ -867,7 +886,7 @@ export function startMaintenanceScheduler(): void {
867
886
 
868
887
  export function listMaintenanceJobs(): MaintenanceJob[] {
869
888
  ensureMaintenanceJobs();
870
- const rows = getDb().prepare("SELECT * FROM maintenance_jobs ORDER BY title COLLATE NOCASE ASC").all() as MaintenanceJobRow[];
889
+ const rows = getDb().query("SELECT * FROM maintenance_jobs ORDER BY title COLLATE NOCASE ASC").all() as MaintenanceJobRow[];
871
890
  const now = Date.now();
872
891
  return rows.map((row) => rowToJob(row, now));
873
892
  }
@@ -895,7 +914,7 @@ async function runMaintenanceJobsNow(ids: string[]): Promise<MaintenanceJobRun[]
895
914
  async function runDueMaintenanceJobs(): Promise<void> {
896
915
  ensureMaintenanceJobs();
897
916
  const now = Date.now();
898
- const rows = getDb().prepare(`
917
+ const rows = getDb().query(`
899
918
  SELECT id FROM maintenance_jobs
900
919
  WHERE enabled = 1
901
920
  AND next_run_at IS NOT NULL
@@ -914,7 +933,7 @@ async function runDueMaintenanceJobs(): Promise<void> {
914
933
  function ensureMaintenanceJobs(): void {
915
934
  const db = getDb();
916
935
  const now = Date.now();
917
- const stmt = db.prepare(`
936
+ const stmt = db.query(`
918
937
  INSERT INTO maintenance_jobs (id, title, description, interval_ms, timeout_ms, enabled, run_on_start, next_run_at, updated_at)
919
938
  VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
920
939
  ON CONFLICT(id) DO UPDATE SET
@@ -946,7 +965,7 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
946
965
  const owner = `${process.pid}-${randomUUID()}`;
947
966
  const timeoutMs = definition.timeoutMs ?? DEFAULT_TIMEOUT_MS;
948
967
  const leaseUntil = startedAt + timeoutMs + 5_000;
949
- const claim = db.prepare(`
968
+ const claim = db.query(`
950
969
  UPDATE maintenance_jobs
951
970
  SET lease_owner = ?, lease_until = ?, last_status = 'running', updated_at = ?
952
971
  WHERE id = ?
@@ -963,7 +982,7 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
963
982
  const result = await withTimeout(Promise.resolve(definition.handler()), timeoutMs);
964
983
  const finishedAt = Date.now();
965
984
  const durationMs = finishedAt - startedAt;
966
- db.prepare(`
985
+ db.query(`
967
986
  UPDATE maintenance_jobs
968
987
  SET last_run_at = ?, next_run_at = ?, last_duration_ms = ?, last_status = 'succeeded',
969
988
  last_error = NULL, last_result = ?, consecutive_failures = 0,
@@ -975,7 +994,7 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
975
994
  const finishedAt = Date.now();
976
995
  const durationMs = finishedAt - startedAt;
977
996
  const message = error instanceof Error ? error.message : String(error);
978
- db.prepare(`
997
+ db.query(`
979
998
  UPDATE maintenance_jobs
980
999
  SET last_run_at = ?, next_run_at = ?, last_duration_ms = ?, last_status = 'failed',
981
1000
  last_error = ?, consecutive_failures = consecutive_failures + 1,
@@ -1,5 +1,5 @@
1
1
  import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
2
- import { getAgentProfile } from "./config-store";
2
+ import { getAgentProfile, workspaceSpawnParams } from "./config-store";
3
3
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
4
4
  import type { SpawnPolicy, WorkspaceMode } from "./types";
5
5
 
@@ -55,6 +55,7 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
55
55
  ...resolvedModelParams(policy),
56
56
  ...(policy.profile ? { profile: policy.profile } : {}),
57
57
  ...(agentProfile ? { agentProfile } : {}),
58
+ ...workspaceSpawnParams(),
58
59
  label: policy.label,
59
60
  tags: policy.tags,
60
61
  capabilities: policy.capabilities,
@@ -86,10 +86,10 @@ export class SqliteMemoryBroker implements MemoryBroker {
86
86
  expiresAt: input.ttlMs === undefined ? undefined : now + input.ttlMs,
87
87
  }, { now });
88
88
  const contentHash = memoryContentHash(memory);
89
- const existing = this.db.prepare("SELECT * FROM memories WHERE content_hash = ? ORDER BY updated_at DESC LIMIT 1").get(contentHash) as MemoryRow | undefined;
89
+ const existing = this.db.query("SELECT * FROM memories WHERE content_hash = ? ORDER BY updated_at DESC LIMIT 1").get(contentHash) as MemoryRow | undefined;
90
90
  if (existing) return rowToMemory(existing);
91
91
 
92
- this.db.prepare(`
92
+ this.db.query(`
93
93
  INSERT INTO memories (
94
94
  id, type, scope, title, content, tags, visibility, sensitivity, confidence, redaction_state,
95
95
  relevance_score, source_agent, source_task, created_by, content_hash, metadata, access_count,
@@ -124,7 +124,7 @@ export class SqliteMemoryBroker implements MemoryBroker {
124
124
  }
125
125
 
126
126
  async get(id: string, _ctx: MemoryBrokerContext): Promise<Memory | null> {
127
- const row = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) as MemoryRow | undefined;
127
+ const row = this.db.query("SELECT * FROM memories WHERE id = ?").get(id) as MemoryRow | undefined;
128
128
  return row ? rowToMemory(row) : null;
129
129
  }
130
130
 
@@ -156,7 +156,7 @@ export class SqliteMemoryBroker implements MemoryBroker {
156
156
  }, { now: ctx.now });
157
157
  const contentHash = memoryContentHash(next);
158
158
 
159
- this.db.prepare(`
159
+ this.db.query(`
160
160
  UPDATE memories SET
161
161
  title = ?,
162
162
  content = ?,
@@ -191,12 +191,12 @@ export class SqliteMemoryBroker implements MemoryBroker {
191
191
  }
192
192
 
193
193
  async delete(id: string, _ctx: MemoryBrokerContext): Promise<void> {
194
- this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
194
+ this.db.query("DELETE FROM memories WHERE id = ?").run(id);
195
195
  }
196
196
 
197
197
  async stats(ctx: MemoryBrokerContext): Promise<MemoryStats> {
198
198
  const memories = filterMemoriesForQuery(
199
- (this.db.prepare("SELECT * FROM memories").all() as MemoryRow[]).map(rowToMemory),
199
+ (this.db.query("SELECT * FROM memories").all() as MemoryRow[]).map(rowToMemory),
200
200
  {},
201
201
  ctx,
202
202
  );
@@ -243,12 +243,12 @@ export class SqliteMemoryBroker implements MemoryBroker {
243
243
 
244
244
  async markInjected(agentId: string, memoryIds: string[], ctx: MemoryBrokerContext): Promise<void> {
245
245
  const uniqueIds = [...new Set(memoryIds)];
246
- const insert = this.db.prepare(`
246
+ const insert = this.db.query(`
247
247
  INSERT INTO agent_active_memories (agent_id, memory_id, loaded_at)
248
248
  VALUES (?, ?, ?)
249
249
  ON CONFLICT(agent_id, memory_id) DO UPDATE SET loaded_at = excluded.loaded_at
250
250
  `);
251
- const touch = this.db.prepare("UPDATE memories SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?");
251
+ const touch = this.db.query("UPDATE memories SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?");
252
252
  const tx = this.db.transaction(() => {
253
253
  for (const memoryId of uniqueIds) {
254
254
  insert.run(agentId, memoryId, ctx.now);
@@ -259,11 +259,11 @@ export class SqliteMemoryBroker implements MemoryBroker {
259
259
  }
260
260
 
261
261
  async clearActive(agentId: string, _reason: ActiveMemoryClearReason, _ctx: MemoryBrokerContext): Promise<void> {
262
- this.db.prepare("DELETE FROM agent_active_memories WHERE agent_id = ?").run(agentId);
262
+ this.db.query("DELETE FROM agent_active_memories WHERE agent_id = ?").run(agentId);
263
263
  }
264
264
 
265
265
  async listActive(agentId: string, ctx: MemoryBrokerContext): Promise<Memory[]> {
266
- const rows = this.db.prepare(`
266
+ const rows = this.db.query(`
267
267
  SELECT memories.*
268
268
  FROM agent_active_memories
269
269
  JOIN memories ON memories.id = agent_active_memories.memory_id
@@ -274,7 +274,7 @@ export class SqliteMemoryBroker implements MemoryBroker {
274
274
  }
275
275
 
276
276
  listActiveMemoryIds(agentId: string): string[] {
277
- return (this.db.prepare("SELECT memory_id FROM agent_active_memories WHERE agent_id = ? ORDER BY loaded_at DESC").all(agentId) as Array<{ memory_id: string }>).map((row) => row.memory_id);
277
+ return (this.db.query("SELECT memory_id FROM agent_active_memories WHERE agent_id = ? ORDER BY loaded_at DESC").all(agentId) as Array<{ memory_id: string }>).map((row) => row.memory_id);
278
278
  }
279
279
 
280
280
  private queryRows(query: MemoryQuery): MemoryRow[] {
@@ -293,7 +293,7 @@ export class SqliteMemoryBroker implements MemoryBroker {
293
293
  params.push(query.visibility);
294
294
  }
295
295
  sql += " ORDER BY relevance_score DESC, updated_at DESC";
296
- return this.db.prepare(sql).all(...params) as MemoryRow[];
296
+ return this.db.query(sql).all(...params) as MemoryRow[];
297
297
  }
298
298
 
299
299
  private get db(): Database {
@@ -36,7 +36,7 @@ interface ProviderModelOverrideInput {
36
36
 
37
37
  function listProviderModelOverrides(): ProviderModelOverride[] {
38
38
  const rows = getDb()
39
- .prepare("SELECT * FROM provider_model_overrides ORDER BY provider ASC, alias ASC")
39
+ .query("SELECT * FROM provider_model_overrides ORDER BY provider ASC, alias ASC")
40
40
  .all() as ProviderModelOverrideRow[];
41
41
  return rows.map(rowToOverride).filter((entry): entry is ProviderModelOverride => Boolean(entry));
42
42
  }
@@ -44,7 +44,7 @@ function listProviderModelOverrides(): ProviderModelOverride[] {
44
44
  export function upsertProviderModelOverride(input: ProviderModelOverrideInput): ProviderModelOverride {
45
45
  const normalized = normalizeOverrideInput(input);
46
46
  const now = Date.now();
47
- getDb().prepare(`
47
+ getDb().query(`
48
48
  INSERT INTO provider_model_overrides (provider, alias, entry, deleted, updated_at, updated_by)
49
49
  VALUES (?, ?, ?, ?, ?, ?)
50
50
  ON CONFLICT(provider, alias) DO UPDATE SET
package/src/recipe-db.ts CHANGED
@@ -53,7 +53,7 @@ export function validateRecipeArtifactRefs(refs: AttachmentRef[]): void {
53
53
  export function createRecipeInstance(input: CreateRecipeInstanceInput): RecipeInstance {
54
54
  const now = Date.now();
55
55
  const id = randomUUID();
56
- getDb().prepare(`
56
+ getDb().query(`
57
57
  INSERT INTO recipe_instances (id, recipe_name, recipe_source, cwd, orchestrator_id, status, started_by, started_at)
58
58
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
59
59
  `).run(id, input.recipeName, input.recipeSource, input.cwd, input.orchestratorId, "starting", input.startedBy, now);
@@ -81,13 +81,13 @@ export function linkRecipeArtifacts(instanceId: string, refs: AttachmentRef[], c
81
81
 
82
82
  export function listRecipeInstances(status?: RecipeInstanceStatus): RecipeInstance[] {
83
83
  const rows = status
84
- ? getDb().prepare("SELECT * FROM recipe_instances WHERE status = ? ORDER BY started_at DESC").all(status)
85
- : getDb().prepare("SELECT * FROM recipe_instances ORDER BY started_at DESC").all();
84
+ ? getDb().query("SELECT * FROM recipe_instances WHERE status = ? ORDER BY started_at DESC").all(status)
85
+ : getDb().query("SELECT * FROM recipe_instances ORDER BY started_at DESC").all();
86
86
  return (rows as RecipeInstanceRow[]).map(rowToRecipeInstance);
87
87
  }
88
88
 
89
89
  export function getRecipeInstance(id: string): RecipeInstance | null {
90
- const row = getDb().prepare("SELECT * FROM recipe_instances WHERE id = ?").get(id) as RecipeInstanceRow | undefined;
90
+ const row = getDb().query("SELECT * FROM recipe_instances WHERE id = ?").get(id) as RecipeInstanceRow | undefined;
91
91
  return row ? rowToRecipeInstance(row) : null;
92
92
  }
93
93
 
@@ -97,7 +97,7 @@ export function updateRecipeInstance(
97
97
  ): RecipeInstance | null {
98
98
  const existing = getRecipeInstance(id);
99
99
  if (!existing) return null;
100
- getDb().prepare(`
100
+ getDb().query(`
101
101
  UPDATE recipe_instances
102
102
  SET status = ?, error = ?, stopped_at = ?
103
103
  WHERE id = ?
@@ -111,7 +111,7 @@ export function updateRecipeInstance(
111
111
  }
112
112
 
113
113
  export function upsertRecipeAgentInstance(input: UpsertRecipeAgentInstanceInput): RecipeAgentInstance {
114
- getDb().prepare(`
114
+ getDb().query(`
115
115
  INSERT INTO recipe_agent_instances (instance_id, role, agent_id, provider, status, idx)
116
116
  VALUES (?, ?, ?, ?, ?, ?)
117
117
  ON CONFLICT(instance_id, role, agent_id) DO UPDATE SET
@@ -128,7 +128,7 @@ export function updateRecipeAgentInstance(
128
128
  agentId: string,
129
129
  status: RecipeAgentStatus,
130
130
  ): RecipeAgentInstance | null {
131
- const changed = getDb().prepare(`
131
+ const changed = getDb().query(`
132
132
  UPDATE recipe_agent_instances
133
133
  SET status = ?
134
134
  WHERE instance_id = ? AND agent_id = ?
@@ -143,7 +143,7 @@ export function replaceRecipeAgentInstanceAgentId(
143
143
  nextAgentId: string,
144
144
  status: RecipeAgentStatus,
145
145
  ): RecipeAgentInstance | null {
146
- const changed = getDb().prepare(`
146
+ const changed = getDb().query(`
147
147
  UPDATE recipe_agent_instances
148
148
  SET agent_id = ?, status = ?
149
149
  WHERE instance_id = ? AND agent_id = ?
@@ -153,7 +153,7 @@ export function replaceRecipeAgentInstanceAgentId(
153
153
  }
154
154
 
155
155
  function listRecipeAgentInstances(instanceId: string): RecipeAgentInstance[] {
156
- const rows = getDb().prepare(`
156
+ const rows = getDb().query(`
157
157
  SELECT * FROM recipe_agent_instances
158
158
  WHERE instance_id = ?
159
159
  ORDER BY role ASC, idx ASC, agent_id ASC
package/src/routes.ts CHANGED
@@ -108,6 +108,9 @@ import {
108
108
  getStewardConfigEntry,
109
109
  getInsightsConfigEntry,
110
110
  setInsightsConfig,
111
+ getWorkspaceConfigEntry,
112
+ setWorkspaceConfig,
113
+ workspaceSpawnParams,
111
114
  listAgentProfiles,
112
115
  listSpawnPolicies,
113
116
  listConfig,
@@ -594,6 +597,12 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
594
597
  }
595
598
  input.maxAgeSeconds = body.maxAgeSeconds;
596
599
  }
600
+ if (body.occurredAt !== undefined) {
601
+ if (typeof body.occurredAt !== "number" || !Number.isFinite(body.occurredAt) || body.occurredAt <= 0) {
602
+ throw new ValidationError("occurredAt must be a positive epoch-ms number");
603
+ }
604
+ input.occurredAt = body.occurredAt;
605
+ }
597
606
 
598
607
  const channel = cleanString(body.channel, "channel", { max: 120 });
599
608
  if (channel) input.channel = channel;
@@ -2296,6 +2305,7 @@ function restartSpawnParamsForAgent(
2296
2305
  workspaceMode,
2297
2306
  ...(profileName ? { profile: profileName } : {}),
2298
2307
  ...(agentProfile ? { agentProfile } : {}),
2308
+ ...workspaceSpawnParams(),
2299
2309
  ...(label ? { label } : {}),
2300
2310
  agentId: agent.id,
2301
2311
  tags: agent.tags,
@@ -3612,6 +3622,7 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3612
3622
  permissionMode: approvalMode,
3613
3623
  profile,
3614
3624
  agentProfile,
3625
+ ...workspaceSpawnParams(),
3615
3626
  providerArgs,
3616
3627
  prompt,
3617
3628
  systemPromptAppend,
@@ -4599,6 +4610,25 @@ const putStewardConfigRoute: Handler = async (req) => {
4599
4610
  }
4600
4611
  };
4601
4612
 
4613
+ const getWorkspaceConfigRoute: Handler = () => json(getWorkspaceConfigEntry());
4614
+
4615
+ const putWorkspaceConfigRoute: Handler = async (req) => {
4616
+ const parsed = await parseBody<unknown>(req);
4617
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4618
+ try {
4619
+ const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
4620
+ ? parsed.body.value
4621
+ : parsed.body;
4622
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
4623
+ const entry = setWorkspaceConfig(value, updatedBy);
4624
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
4625
+ return json(entry, entry.version === 1 ? 201 : 200);
4626
+ } catch (e) {
4627
+ if (e instanceof ValidationError) return error(e.message, 400);
4628
+ throw e;
4629
+ }
4630
+ };
4631
+
4602
4632
  // --- Insights / self-improvement (epic #183, docs/self-improvement.md) ---
4603
4633
 
4604
4634
  const getInsightsConfigRoute: Handler = () => json(getInsightsConfigEntry());
@@ -4635,6 +4665,14 @@ const getInsightsObservationsRoute: Handler = (req) => {
4635
4665
  });
4636
4666
  };
4637
4667
 
4668
+ // Accept a backfilled event time only when it's a sane epoch-ms value within a minute of
4669
+ // now (clock-skew guard); otherwise let recordObservation default to receive time.
4670
+ function sanitizeObservationOccurredAt(occurredAt: unknown): number | undefined {
4671
+ if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return undefined;
4672
+ if (occurredAt <= 0 || occurredAt > Date.now() + 60_000) return undefined;
4673
+ return Math.floor(occurredAt);
4674
+ }
4675
+
4638
4676
  const postInsightsObservationRoute: Handler = async (req) => {
4639
4677
  const parsed = await parseBody<unknown>(req);
4640
4678
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -4656,6 +4694,10 @@ const postInsightsObservationRoute: Handler = async (req) => {
4656
4694
  value: isRecord(body.value) ? body.value : {},
4657
4695
  outcome: isRecord(body.outcome) ? body.outcome : undefined,
4658
4696
  source: body.source === "server" ? "server" : "agent",
4697
+ // For insights the event time IS the record time (end-of-session). When the Runner
4698
+ // backfilled this through its durable outbox (#196), occurredAt preserves the real
4699
+ // moment rather than the later server-receive time.
4700
+ createdAt: sanitizeObservationOccurredAt(body.occurredAt),
4659
4701
  });
4660
4702
  return json(observation, 201);
4661
4703
  } catch (e) {
@@ -6569,6 +6611,8 @@ const routes: Route[] = [
6569
6611
  route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
6570
6612
  route("GET", "/api/steward-config", getStewardConfigRoute),
6571
6613
  route("PUT", "/api/steward-config", putStewardConfigRoute),
6614
+ route("GET", "/api/workspace-config", getWorkspaceConfigRoute),
6615
+ route("PUT", "/api/workspace-config", putWorkspaceConfigRoute),
6572
6616
  route("GET", "/api/insights/config", getInsightsConfigRoute),
6573
6617
  route("PUT", "/api/insights/config", putInsightsConfigRoute),
6574
6618
  route("GET", "/api/insights/observations", getInsightsObservationsRoute),
package/src/security.ts CHANGED
@@ -453,7 +453,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
453
453
 
454
454
  function isTokenRevoked(jti: string): boolean {
455
455
  try {
456
- const row = getDb().prepare("SELECT revoked_at FROM tokens WHERE jti = ?").get(jti) as { revoked_at?: number | null } | undefined;
456
+ const row = getDb().query("SELECT revoked_at FROM tokens WHERE jti = ?").get(jti) as { revoked_at?: number | null } | undefined;
457
457
  return Boolean(row?.revoked_at);
458
458
  } catch {
459
459
  return false;
package/src/token-db.ts CHANGED
@@ -143,7 +143,7 @@ export function createToken(input: {
143
143
  jti,
144
144
  };
145
145
  const token = signComponentToken(payload);
146
- getDb().prepare(`
146
+ getDb().query(`
147
147
  INSERT INTO tokens (jti, sub, role, scope, constraints, profile_id, issued_at, expires_at, created_by)
148
148
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
149
149
  `).run(jti, input.sub, role, JSON.stringify(scope), constraints ? JSON.stringify(constraints) : null, profile?.id ?? null, now, expiresAt ?? null, input.createdBy ?? null);
@@ -151,17 +151,17 @@ export function createToken(input: {
151
151
  }
152
152
 
153
153
  export function listTokens(): TokenRecord[] {
154
- const rows = getDb().prepare("SELECT * FROM tokens ORDER BY issued_at DESC").all() as TokenRow[];
154
+ const rows = getDb().query("SELECT * FROM tokens ORDER BY issued_at DESC").all() as TokenRow[];
155
155
  return rows.map(rowToToken);
156
156
  }
157
157
 
158
158
  export function getToken(jti: string): TokenRecord | null {
159
- const row = getDb().prepare("SELECT * FROM tokens WHERE jti = ?").get(jti) as TokenRow | undefined;
159
+ const row = getDb().query("SELECT * FROM tokens WHERE jti = ?").get(jti) as TokenRow | undefined;
160
160
  return row ? rowToToken(row) : null;
161
161
  }
162
162
 
163
163
  export function revokeToken(jti: string): boolean {
164
- return getDb().prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ? AND revoked_at IS NULL").run(Math.floor(Date.now() / 1000), jti).changes > 0;
164
+ return getDb().query("UPDATE tokens SET revoked_at = ? WHERE jti = ? AND revoked_at IS NULL").run(Math.floor(Date.now() / 1000), jti).changes > 0;
165
165
  }
166
166
 
167
167
  export function renewToken(input: {
@@ -192,7 +192,7 @@ export function pruneExpiredTokenRecords(input: {
192
192
  const retentionSeconds = input.retentionSeconds ?? 7 * 24 * 60 * 60;
193
193
  const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1000);
194
194
  const cutoff = nowSeconds - retentionSeconds;
195
- const rows = getDb().prepare(`
195
+ const rows = getDb().query(`
196
196
  DELETE FROM tokens
197
197
  WHERE (expires_at IS NOT NULL AND expires_at <= ?)
198
198
  OR (revoked_at IS NOT NULL AND revoked_at <= ?)
@@ -203,13 +203,13 @@ export function pruneExpiredTokenRecords(input: {
203
203
 
204
204
  export function listTokenProfiles(): TokenProfile[] {
205
205
  ensureBuiltInTokenProfiles();
206
- const rows = getDb().prepare("SELECT * FROM token_profiles ORDER BY built_in DESC, name COLLATE NOCASE ASC").all() as TokenProfileRow[];
206
+ const rows = getDb().query("SELECT * FROM token_profiles ORDER BY built_in DESC, name COLLATE NOCASE ASC").all() as TokenProfileRow[];
207
207
  return rows.map(rowToProfile);
208
208
  }
209
209
 
210
210
  export function getTokenProfile(id: string): TokenProfile | null {
211
211
  ensureBuiltInTokenProfiles();
212
- const row = getDb().prepare("SELECT * FROM token_profiles WHERE id = ?").get(id) as TokenProfileRow | undefined;
212
+ const row = getDb().query("SELECT * FROM token_profiles WHERE id = ?").get(id) as TokenProfileRow | undefined;
213
213
  return row ? rowToProfile(row) : null;
214
214
  }
215
215
 
@@ -219,7 +219,7 @@ export function upsertTokenProfile(input: CreateTokenProfileInput): TokenProfile
219
219
  const id = input.id ?? slugify(input.name);
220
220
  const existing = getTokenProfile(id);
221
221
  if (existing?.builtIn) throw new Error("built-in token profiles cannot be modified");
222
- getDb().prepare(`
222
+ getDb().query(`
223
223
  INSERT INTO token_profiles (id, name, description, role, scope, constraints, ttl_seconds, built_in, created_at, updated_at, created_by)
224
224
  VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
225
225
  ON CONFLICT(id) DO UPDATE SET
@@ -268,12 +268,12 @@ export function updateTokenProfile(id: string, patch: UpdateTokenProfileInput):
268
268
 
269
269
  export function deleteTokenProfile(id: string): boolean {
270
270
  ensureBuiltInTokenProfiles();
271
- return getDb().prepare("DELETE FROM token_profiles WHERE id = ? AND built_in = 0").run(id).changes > 0;
271
+ return getDb().query("DELETE FROM token_profiles WHERE id = ? AND built_in = 0").run(id).changes > 0;
272
272
  }
273
273
 
274
274
  function ensureBuiltInTokenProfiles(): void {
275
275
  const now = Date.now();
276
- const stmt = getDb().prepare(`
276
+ const stmt = getDb().query(`
277
277
  INSERT INTO token_profiles (id, name, description, role, scope, constraints, ttl_seconds, built_in, created_at, updated_at, created_by)
278
278
  VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
279
279
  ON CONFLICT(id) DO UPDATE SET