agent-relay-server 0.33.0 → 0.34.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.
@@ -0,0 +1,431 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
4
+ import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "../config.ts";
5
+ import { parseJson } from "../utils";
6
+ import { isLiveIsolatedWorkspace } from "../workspace-phase";
7
+ import {
8
+ CONTRACT_REQUIREMENTS,
9
+ contractCompatibility,
10
+ parseRuntimeCapabilities,
11
+ parseRuntimeContracts,
12
+ parseRuntimePackage,
13
+ type RuntimeContracts,
14
+ } from "../contracts";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
+ import { matchAgents } from "../agent-ref";
17
+ import { getDb } from "./connection.ts";
18
+ import { normalizeReactionEmoji } from "./mappers.ts";
19
+ import type {
20
+ AgentCard,
21
+ ActivityEvent,
22
+ ActivityEventInput,
23
+ AgentKind,
24
+ AgentSessionGuard,
25
+ Artifact,
26
+ ArtifactBlob,
27
+ ArtifactKind,
28
+ ArtifactLink,
29
+ ArtifactSensitivity,
30
+ ArtifactVisibility,
31
+ AttachmentRef,
32
+ ChannelBinding,
33
+ ChannelBindingMode,
34
+ ChannelRouteTarget,
35
+ ChatHistoryImport,
36
+ ChatHistoryImportEntry,
37
+ ChannelSummary,
38
+ ChannelTargetHealth,
39
+ CreatePairInput,
40
+ HealthCheck,
41
+ HealthReport,
42
+ ManagedAgent,
43
+ ManagedSessionExitDiagnostics,
44
+ Message,
45
+ MessageDeliveryAttempt,
46
+ MessageDeliveryStatus,
47
+ Orchestrator,
48
+ OrchestratorHealth,
49
+ OrchestratorRuntimeInput,
50
+ OrchestratorStatus,
51
+ OrchestratorUpgradeState,
52
+ PairActionInput,
53
+ PairMessageInput,
54
+ PairSession,
55
+ PairStatus,
56
+ RegisterAgentInput,
57
+ ReplyObligation,
58
+ RegisterOrchestratorInput,
59
+ SendMessageInput,
60
+ PollQuery,
61
+ SpawnApprovalMode,
62
+ SpawnProvider,
63
+ Task,
64
+ TaskEvent,
65
+ TaskSeverity,
66
+ TaskStatus,
67
+ IntegrationEventInput,
68
+ IntegrationSummary,
69
+ IntegrationTaskStats,
70
+ InboxDraft,
71
+ InboxState,
72
+ InboxThreadState,
73
+ ContextSnapshot,
74
+ ContextState,
75
+ ProviderCapabilities,
76
+ TaskStatusInput,
77
+ WorkspaceMetadata,
78
+ WorkspaceRecord,
79
+ WorkspaceStatus,
80
+ } from "../types";
81
+
82
+ // One-time normalization of legacy reaction rows, run on every open (idempotent).
83
+ function normalizeExistingMessageReactions(): void {
84
+ const rows = getDb().query("SELECT message_id, actor_id, emoji, created_at, updated_at FROM message_reactions").all() as Array<{
85
+ message_id: number;
86
+ actor_id: string;
87
+ emoji: string;
88
+ created_at: number;
89
+ updated_at: number;
90
+ }>;
91
+ const migrate = getDb().transaction((items: typeof rows) => {
92
+ const upsert = getDb().query(`
93
+ INSERT INTO message_reactions (message_id, actor_id, emoji, created_at, updated_at)
94
+ VALUES (?, ?, ?, ?, ?)
95
+ ON CONFLICT(message_id, actor_id, emoji) DO UPDATE SET
96
+ created_at = min(message_reactions.created_at, excluded.created_at),
97
+ updated_at = max(message_reactions.updated_at, excluded.updated_at)
98
+ `);
99
+ const remove = getDb().query("DELETE FROM message_reactions WHERE message_id = ? AND actor_id = ? AND emoji = ?");
100
+ for (const row of items) {
101
+ const normalized = normalizeReactionEmoji(row.emoji);
102
+ if (normalized === row.emoji) continue;
103
+ upsert.run(row.message_id, row.actor_id, normalized, row.created_at, row.updated_at);
104
+ remove.run(row.message_id, row.actor_id, row.emoji);
105
+ }
106
+ });
107
+ migrate(rows);
108
+ }
109
+
110
+
111
+ // Incremental schema migrations + one-shot backfills, applied after the base DDL
112
+ // in initDb(). Ordering matters: each block guards on the current column/table
113
+ // shape, so they must run in historical order. Built-in agent seeding and the
114
+ // read_by backfill live here too — both run unconditionally and idempotently.
115
+ export function applyMigrations(): void {
116
+ normalizeExistingMessageReactions();
117
+
118
+ // Migrations
119
+ const cols = getDb().query("PRAGMA table_info(messages)").all() as any[];
120
+ const colNames = cols.map((c: any) => c.name);
121
+ if (!colNames.includes("thread_id")) {
122
+ getDb().run("ALTER TABLE messages ADD COLUMN thread_id INTEGER");
123
+ getDb().run("ALTER TABLE messages ADD COLUMN reply_to INTEGER REFERENCES messages(id)");
124
+ }
125
+ if (!colNames.includes("reply_expected")) {
126
+ getDb().run("ALTER TABLE messages ADD COLUMN reply_expected INTEGER NOT NULL DEFAULT 1");
127
+ }
128
+ if (!colNames.includes("claimable")) {
129
+ getDb().run("ALTER TABLE messages ADD COLUMN claimable INTEGER NOT NULL DEFAULT 0");
130
+ getDb().run("ALTER TABLE messages ADD COLUMN claimed_by TEXT");
131
+ getDb().run("ALTER TABLE messages ADD COLUMN claimed_at INTEGER");
132
+ }
133
+ if (!colNames.includes("claim_expires_at")) {
134
+ getDb().run("ALTER TABLE messages ADD COLUMN claim_expires_at INTEGER");
135
+ }
136
+ if (!colNames.includes("idempotency_key")) {
137
+ getDb().run("ALTER TABLE messages ADD COLUMN idempotency_key TEXT");
138
+ }
139
+ if (!colNames.includes("delivery_status")) {
140
+ getDb().run("ALTER TABLE messages ADD COLUMN delivery_status TEXT NOT NULL DEFAULT 'pending'");
141
+ }
142
+ if (!colNames.includes("delivery_attempts")) {
143
+ getDb().run("ALTER TABLE messages ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0");
144
+ }
145
+ if (!colNames.includes("delivery_last_error")) {
146
+ getDb().run("ALTER TABLE messages ADD COLUMN delivery_last_error TEXT");
147
+ }
148
+ if (!colNames.includes("delivery_next_retry_at")) {
149
+ getDb().run("ALTER TABLE messages ADD COLUMN delivery_next_retry_at INTEGER");
150
+ }
151
+ if (!colNames.includes("delivery_poison_reason")) {
152
+ getDb().run("ALTER TABLE messages ADD COLUMN delivery_poison_reason TEXT");
153
+ }
154
+ if (!colNames.includes("delivery_updated_at")) {
155
+ getDb().run("ALTER TABLE messages ADD COLUMN delivery_updated_at INTEGER");
156
+ }
157
+ if (!colNames.includes("queued_at")) {
158
+ getDb().run("ALTER TABLE messages ADD COLUMN queued_at INTEGER");
159
+ }
160
+ if (!colNames.includes("max_age_seconds")) {
161
+ getDb().run("ALTER TABLE messages ADD COLUMN max_age_seconds INTEGER");
162
+ }
163
+ if (!colNames.includes("resolved_to_agent")) {
164
+ getDb().run("ALTER TABLE messages ADD COLUMN resolved_to_agent TEXT");
165
+ }
166
+ // Event time (#196): when a Runner queues a message in its durable outbox during an
167
+ // outage, occurred_at preserves when it really happened vs. the later receive time.
168
+ if (!colNames.includes("occurred_at")) {
169
+ getDb().run("ALTER TABLE messages ADD COLUMN occurred_at INTEGER");
170
+ }
171
+ getDb().query(
172
+ "UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
173
+ ).run(Date.now(), CLAIM_LEASE_MS);
174
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_thread ON messages(thread_id)");
175
+ getDb().run("CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_idempotency ON messages(from_agent, idempotency_key) WHERE idempotency_key IS NOT NULL");
176
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_delivery_status ON messages(delivery_status)");
177
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_resolved_to_agent ON messages(resolved_to_agent)");
178
+
179
+ const tokenCols = getDb().query("PRAGMA table_info(tokens)").all() as any[];
180
+ const tokenColNames = tokenCols.map((c: any) => c.name);
181
+ if (!tokenColNames.includes("constraints")) {
182
+ getDb().run("ALTER TABLE tokens ADD COLUMN constraints TEXT");
183
+ }
184
+ if (!tokenColNames.includes("profile_id")) {
185
+ getDb().run("ALTER TABLE tokens ADD COLUMN profile_id TEXT");
186
+ }
187
+
188
+ getDb().run(`
189
+ CREATE TABLE IF NOT EXISTS message_delivery_attempts (
190
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
191
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
192
+ agent_id TEXT,
193
+ action TEXT NOT NULL DEFAULT 'attempt',
194
+ status TEXT NOT NULL,
195
+ error TEXT,
196
+ next_retry_at INTEGER,
197
+ poison_reason TEXT,
198
+ created_at INTEGER NOT NULL
199
+ )
200
+ `);
201
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_mda_message ON message_delivery_attempts(message_id, created_at DESC)");
202
+
203
+ const channelBindingsSql = getDb().query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_bindings'").get() as { sql?: string } | undefined;
204
+ if (channelBindingsSql?.sql?.includes("UNIQUE(channel_id, conversation_key)")) {
205
+ getDb().transaction(() => {
206
+ getDb().run("ALTER TABLE channel_bindings RENAME TO channel_bindings_old");
207
+ getDb().run(`
208
+ CREATE TABLE channel_bindings (
209
+ id TEXT PRIMARY KEY,
210
+ channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
211
+ conversation_key TEXT NOT NULL DEFAULT '',
212
+ conversation_id TEXT,
213
+ target_type TEXT NOT NULL,
214
+ target_id TEXT NOT NULL,
215
+ mode TEXT NOT NULL DEFAULT 'exclusive',
216
+ priority INTEGER NOT NULL DEFAULT 0,
217
+ created_at INTEGER NOT NULL,
218
+ updated_at INTEGER NOT NULL
219
+ )
220
+ `);
221
+ getDb().run(`
222
+ INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at)
223
+ SELECT id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, created_at, updated_at
224
+ FROM channel_bindings_old
225
+ `);
226
+ getDb().run("DROP TABLE channel_bindings_old");
227
+ })();
228
+ }
229
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_channel_bindings_channel ON channel_bindings(channel_id, priority)");
230
+ getDb().run("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_bindings_target ON channel_bindings(channel_id, conversation_key, target_type, target_id)");
231
+
232
+ const bindingColNames = (getDb().query("PRAGMA table_info(channel_bindings)").all() as any[]).map((c: any) => c.name);
233
+ if (!bindingColNames.includes("pool_selector")) {
234
+ getDb().run("ALTER TABLE channel_bindings ADD COLUMN pool_selector TEXT");
235
+ getDb().run("ALTER TABLE channel_bindings ADD COLUMN pool_agent_id TEXT");
236
+ getDb().run("ALTER TABLE channel_bindings ADD COLUMN pool_agent_epoch INTEGER");
237
+ getDb().run("ALTER TABLE channel_bindings ADD COLUMN pool_claim_expires_at INTEGER");
238
+ }
239
+
240
+ if (!colNames.includes("kind")) {
241
+ getDb().run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'chat'");
242
+ }
243
+ if (!colNames.includes("payload")) {
244
+ getDb().run("ALTER TABLE messages ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'");
245
+ }
246
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
247
+
248
+ // Backfill thread_id for pre-migration rows (self-threaded).
249
+ getDb().run("UPDATE messages SET thread_id = id WHERE thread_id IS NULL");
250
+
251
+ const taskCols = getDb().query("PRAGMA table_info(tasks)").all() as any[];
252
+ const taskColNames = taskCols.map((c: any) => c.name);
253
+ if (!taskColNames.includes("claim_expires_at")) {
254
+ getDb().run("ALTER TABLE tasks ADD COLUMN claim_expires_at INTEGER");
255
+ }
256
+ getDb().query(
257
+ "UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
258
+ ).run(Date.now(), CLAIM_LEASE_MS);
259
+
260
+ // Migration: orchestrators.api_url
261
+ const orchCols = getDb().query("PRAGMA table_info(orchestrators)").all() as any[];
262
+ const orchColNames = orchCols.map((c: any) => c.name);
263
+ if (!orchColNames.includes("api_url")) {
264
+ getDb().run("ALTER TABLE orchestrators ADD COLUMN api_url TEXT");
265
+ }
266
+
267
+ const managedStateCols = getDb().query("PRAGMA table_info(managed_agent_state)").all() as any[];
268
+ const managedStateColNames = managedStateCols.map((c: any) => c.name);
269
+ if (!managedStateColNames.includes("workspace_id")) {
270
+ getDb().run("ALTER TABLE managed_agent_state ADD COLUMN workspace_id TEXT");
271
+ getDb().run("ALTER TABLE managed_agent_state ADD COLUMN workspace_path TEXT");
272
+ getDb().run("ALTER TABLE managed_agent_state ADD COLUMN workspace_branch TEXT");
273
+ }
274
+
275
+ // message_reads: relational replacement for the read_by JSON array.
276
+ getDb().run(`
277
+ CREATE TABLE IF NOT EXISTS message_reads (
278
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
279
+ agent_id TEXT NOT NULL,
280
+ read_at INTEGER NOT NULL,
281
+ PRIMARY KEY (message_id, agent_id)
282
+ );
283
+ CREATE INDEX IF NOT EXISTS idx_mr_agent ON message_reads(agent_id);
284
+ `);
285
+
286
+ // Migration: agents.label
287
+ const agentCols = getDb().query("PRAGMA table_info(agents)").all() as any[];
288
+ const agentColNames = agentCols.map((c: any) => c.name);
289
+ if (!agentColNames.includes("label")) {
290
+ getDb().run("ALTER TABLE agents ADD COLUMN label TEXT");
291
+ }
292
+ if (!agentColNames.includes("ready")) {
293
+ getDb().run("ALTER TABLE agents ADD COLUMN ready INTEGER NOT NULL DEFAULT 0");
294
+ }
295
+ if (!agentColNames.includes("instance_id")) {
296
+ getDb().run("ALTER TABLE agents ADD COLUMN instance_id TEXT");
297
+ }
298
+ if (!agentColNames.includes("epoch")) {
299
+ getDb().run("ALTER TABLE agents ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0");
300
+ }
301
+ if (!agentColNames.includes("kind")) {
302
+ getDb().run("ALTER TABLE agents ADD COLUMN kind TEXT NOT NULL DEFAULT 'provider'");
303
+ getDb().run(`
304
+ UPDATE agents
305
+ SET kind = CASE
306
+ WHEN id = 'user' THEN 'user'
307
+ WHEN id = 'system' THEN 'system'
308
+ WHEN json_extract(meta, '$.kind') = 'channel' THEN 'channel'
309
+ WHEN EXISTS (SELECT 1 FROM json_each(tags) WHERE value = 'channel') THEN 'channel'
310
+ ELSE 'provider'
311
+ END
312
+ `);
313
+ }
314
+ if (!agentColNames.includes("provider_capabilities")) {
315
+ getDb().run("ALTER TABLE agents ADD COLUMN provider_capabilities TEXT");
316
+ }
317
+ if (!agentColNames.includes("context_state")) {
318
+ getDb().run("ALTER TABLE agents ADD COLUMN context_state TEXT");
319
+ }
320
+ if (!agentColNames.includes("spawned_by")) {
321
+ getDb().run("ALTER TABLE agents ADD COLUMN spawned_by TEXT");
322
+ }
323
+ getDb().run(`
324
+ CREATE TABLE IF NOT EXISTS context_snapshots (
325
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
326
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
327
+ utilization REAL NOT NULL,
328
+ lifecycle_state TEXT NOT NULL,
329
+ tokens_used INTEGER,
330
+ tokens_max INTEGER,
331
+ source TEXT NOT NULL,
332
+ confidence TEXT NOT NULL,
333
+ context_state TEXT NOT NULL,
334
+ captured_at INTEGER NOT NULL
335
+ )
336
+ `);
337
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_context_snapshots_agent_time ON context_snapshots(agent_id, captured_at DESC)");
338
+
339
+ getDb().run(`
340
+ CREATE TABLE IF NOT EXISTS memories (
341
+ id TEXT PRIMARY KEY,
342
+ type TEXT NOT NULL,
343
+ scope TEXT NOT NULL,
344
+ title TEXT NOT NULL,
345
+ content TEXT NOT NULL,
346
+ tags TEXT NOT NULL DEFAULT '[]',
347
+ visibility TEXT NOT NULL DEFAULT 'project',
348
+ sensitivity TEXT NOT NULL DEFAULT 'normal',
349
+ confidence TEXT NOT NULL DEFAULT 'reported',
350
+ redaction_state TEXT NOT NULL DEFAULT 'raw',
351
+ relevance_score REAL DEFAULT 1.0,
352
+ source_agent TEXT,
353
+ source_task INTEGER REFERENCES tasks(id) ON DELETE SET NULL,
354
+ created_by TEXT,
355
+ content_hash TEXT,
356
+ metadata TEXT NOT NULL DEFAULT '{}',
357
+ access_count INTEGER DEFAULT 0,
358
+ last_accessed_at INTEGER,
359
+ created_at INTEGER NOT NULL,
360
+ updated_at INTEGER NOT NULL,
361
+ expires_at INTEGER
362
+ )
363
+ `);
364
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type)");
365
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope)");
366
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_memories_tags ON memories(tags)");
367
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_memories_relevance ON memories(relevance_score)");
368
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at)");
369
+ getDb().run(`
370
+ CREATE TABLE IF NOT EXISTS agent_active_memories (
371
+ agent_id TEXT NOT NULL,
372
+ memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
373
+ loaded_at INTEGER NOT NULL,
374
+ PRIMARY KEY (agent_id, memory_id)
375
+ )
376
+ `);
377
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_agent_active_memories_agent ON agent_active_memories(agent_id)");
378
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_label ON agents(label)");
379
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_kind ON agents(kind)");
380
+
381
+ getDb().run(`
382
+ CREATE TABLE IF NOT EXISTS integration_registry (
383
+ name TEXT PRIMARY KEY,
384
+ display_name TEXT,
385
+ description TEXT,
386
+ enabled INTEGER NOT NULL DEFAULT 1,
387
+ scopes TEXT NOT NULL DEFAULT '[]',
388
+ targets TEXT NOT NULL DEFAULT '[]',
389
+ channels TEXT NOT NULL DEFAULT '[]',
390
+ type TEXT,
391
+ icon TEXT,
392
+ accent_color TEXT,
393
+ tags TEXT NOT NULL DEFAULT '[]',
394
+ homepage_url TEXT,
395
+ repository_url TEXT,
396
+ docs_url TEXT,
397
+ manifest TEXT NOT NULL DEFAULT '{}',
398
+ source TEXT NOT NULL DEFAULT 'api',
399
+ created_at INTEGER NOT NULL,
400
+ updated_at INTEGER NOT NULL,
401
+ last_event_at INTEGER,
402
+ last_task_at INTEGER,
403
+ last_auth_success_at INTEGER,
404
+ last_auth_failure_at INTEGER
405
+ )
406
+ `);
407
+
408
+ // Built-in agents — registered unconditionally so sends from these ids
409
+ // pass the sendMessage validation. The reaper exempts these by checking
410
+ // meta.builtin (or by id for "user").
411
+ const now = Date.now();
412
+ const builtinStmt = getDb().query(`
413
+ INSERT INTO agents (id, name, kind, tags, machine, rig, capabilities, ready, status, meta, last_seen, created_at)
414
+ VALUES (?, ?, ?, ?, NULL, NULL, '[]', 1, 'online', '{"builtin":true}', ?, ?)
415
+ ON CONFLICT(id) DO UPDATE SET status = 'online', ready = 1, last_seen = excluded.last_seen
416
+ `);
417
+ builtinStmt.run("user", "User", "user", '["human"]', now, now);
418
+ builtinStmt.run("system", "System", "system", '["system"]', now, now);
419
+
420
+ // One-shot migration: backfill message_reads from legacy read_by JSON
421
+ // if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
422
+ if (colNames.includes("read_by")) {
423
+ getDb().run(`
424
+ INSERT OR IGNORE INTO message_reads (message_id, agent_id, read_at)
425
+ SELECT m.id, je.value, m.created_at
426
+ FROM messages m, json_each(m.read_by) je
427
+ WHERE json_valid(m.read_by)
428
+ `);
429
+ }
430
+
431
+ }