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,576 @@
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 { parseChannelRouteTarget } from "../channel-target.ts";
8
+ import {
9
+ CONTRACT_REQUIREMENTS,
10
+ contractCompatibility,
11
+ parseRuntimeCapabilities,
12
+ parseRuntimeContracts,
13
+ parseRuntimePackage,
14
+ type RuntimeContracts,
15
+ } from "../contracts";
16
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
17
+ import { matchAgents } from "../agent-ref";
18
+ import { getAgent, listAgents, parseStringArray } from "./agents.ts";
19
+ import { ValidationError, getDb } from "./connection.ts";
20
+ import { isDeliveryAgent } from "./delivery.ts";
21
+ import { runningAgentForPolicy } from "./messages.ts";
22
+ import type {
23
+ AgentCard,
24
+ ActivityEvent,
25
+ ActivityEventInput,
26
+ AgentKind,
27
+ AgentSessionGuard,
28
+ Artifact,
29
+ ArtifactBlob,
30
+ ArtifactKind,
31
+ ArtifactLink,
32
+ ArtifactSensitivity,
33
+ ArtifactVisibility,
34
+ AttachmentRef,
35
+ ChannelBinding,
36
+ ChannelBindingMode,
37
+ ChannelRouteTarget,
38
+ ChatHistoryImport,
39
+ ChatHistoryImportEntry,
40
+ ChannelSummary,
41
+ ChannelTargetHealth,
42
+ CreatePairInput,
43
+ HealthCheck,
44
+ HealthReport,
45
+ ManagedAgent,
46
+ ManagedSessionExitDiagnostics,
47
+ Message,
48
+ MessageDeliveryAttempt,
49
+ MessageDeliveryStatus,
50
+ Orchestrator,
51
+ OrchestratorHealth,
52
+ OrchestratorRuntimeInput,
53
+ OrchestratorStatus,
54
+ OrchestratorUpgradeState,
55
+ PairActionInput,
56
+ PairMessageInput,
57
+ PairSession,
58
+ PairStatus,
59
+ RegisterAgentInput,
60
+ ReplyObligation,
61
+ RegisterOrchestratorInput,
62
+ SendMessageInput,
63
+ PollQuery,
64
+ SpawnApprovalMode,
65
+ SpawnProvider,
66
+ Task,
67
+ TaskEvent,
68
+ TaskSeverity,
69
+ TaskStatus,
70
+ IntegrationEventInput,
71
+ IntegrationSummary,
72
+ IntegrationTaskStats,
73
+ InboxDraft,
74
+ InboxState,
75
+ InboxThreadState,
76
+ ContextSnapshot,
77
+ ContextState,
78
+ ProviderCapabilities,
79
+ TaskStatusInput,
80
+ WorkspaceMetadata,
81
+ WorkspaceRecord,
82
+ WorkspaceStatus,
83
+ } from "../types";
84
+
85
+ export function rowToChannelBinding(row: any): ChannelBinding {
86
+ const target = row.target_type === "broadcast"
87
+ ? { type: "broadcast" } as ChannelRouteTarget
88
+ : { type: row.target_type, id: row.target_id } as ChannelRouteTarget;
89
+ const binding: ChannelBinding = {
90
+ id: row.id,
91
+ channelId: row.channel_id,
92
+ conversationId: row.conversation_id ?? undefined,
93
+ target,
94
+ mode: row.mode as ChannelBindingMode,
95
+ priority: row.priority,
96
+ createdAt: row.created_at,
97
+ updatedAt: row.updated_at,
98
+ };
99
+ if (row.pool_selector) {
100
+ binding.poolSelector = row.pool_selector;
101
+ binding.poolAgentId = row.pool_agent_id ?? undefined;
102
+ binding.poolAgentEpoch = row.pool_agent_epoch ?? undefined;
103
+ binding.poolClaimExpiresAt = row.pool_claim_expires_at ?? undefined;
104
+ }
105
+ return binding;
106
+ }
107
+
108
+ export function bindingTargetToLegacyTarget(target: ChannelRouteTarget): string {
109
+ if (target.type === "agent") return target.id;
110
+ if (target.type === "label") return `label:${target.id}`;
111
+ if (target.type === "tag") return `tag:${target.id}`;
112
+ if (target.type === "capability") return `cap:${target.id}`;
113
+ if (target.type === "broadcast") return "broadcast";
114
+ if (target.type === "orchestrator") return `orchestrator:${target.id}`;
115
+ if (target.type === "pool") return `pool:${target.id}`;
116
+ if (target.type === "policy") return `policy:${target.id}`;
117
+ return "";
118
+ }
119
+
120
+ export function configuredChannelsForAgent(agent: AgentCard): string[] {
121
+ const channels = agent.meta?.channels;
122
+ if (!Array.isArray(channels)) return [];
123
+ return channels
124
+ .filter((item): item is string => typeof item === "string")
125
+ .map((item) => item.trim())
126
+ .filter((item) => item.length > 0);
127
+ }
128
+
129
+ export function channelEntryMatches(channelId: string, entry: string): boolean {
130
+ const normalized = entry.trim();
131
+ if (!normalized) return false;
132
+ if (normalized === channelId) return true;
133
+ if (normalized.endsWith(":*")) return channelId.startsWith(normalized.slice(0, -1));
134
+ return channelId.startsWith(`${normalized}:`);
135
+ }
136
+
137
+ export function agentCanServeChannel(agent: AgentCard, channelId?: string): boolean {
138
+ if (!channelId) return true;
139
+ const channels = configuredChannelsForAgent(agent);
140
+ if (channels.length === 0) return true;
141
+ return channels.some((entry) => channelEntryMatches(channelId, entry));
142
+ }
143
+
144
+ export function channelTargetMatches(target: ChannelRouteTarget, channelId?: string): AgentCard[] {
145
+ const candidates = listAgents().filter((agent) => (
146
+ agent.id !== "user" &&
147
+ agent.id !== "system" &&
148
+ agent.kind !== "channel" &&
149
+ agent.meta?.kind !== "channel"
150
+ ));
151
+ if (target.type === "agent" || target.type === "orchestrator") {
152
+ const agent = getAgent(target.id);
153
+ return agent ? [agent] : [];
154
+ }
155
+ if (target.type === "policy") {
156
+ const agentId = runningAgentForPolicy(target.id);
157
+ const agent = agentId ? getAgent(agentId) : null;
158
+ return agent ? [agent] : [];
159
+ }
160
+ const channelCandidates = candidates.filter((agent) => agentCanServeChannel(agent, channelId));
161
+ if (target.type === "pool") return poolSelectorMatches(target.id, channelCandidates);
162
+ if (target.type === "label") return channelCandidates.filter((agent) => agent.label === target.id);
163
+ if (target.type === "tag") return channelCandidates.filter((agent) => agent.tags.includes(target.id));
164
+ if (target.type === "capability") return channelCandidates.filter((agent) => agent.capabilities.includes(target.id));
165
+ if (target.type === "broadcast") return channelCandidates;
166
+ return [];
167
+ }
168
+
169
+ export function poolSelectorMatches(selector: string, candidates: AgentCard[]): AgentCard[] {
170
+ if (selector.startsWith("label:")) return candidates.filter((a) => a.label === selector.slice("label:".length));
171
+ if (selector.startsWith("tag:")) return candidates.filter((a) => a.tags.includes(selector.slice("tag:".length)));
172
+ if (selector.startsWith("cap:")) return candidates.filter((a) => a.capabilities.includes(selector.slice("cap:".length)));
173
+ const agent = getAgent(selector);
174
+ return agent ? [agent] : [];
175
+ }
176
+
177
+ export function channelTargetMatchSnapshot(agent: AgentCard): ChannelTargetHealth["matches"][number] {
178
+ return {
179
+ id: agent.id,
180
+ name: agent.name,
181
+ status: agent.status,
182
+ ready: agent.ready,
183
+ lastSeen: agent.lastSeen,
184
+ label: agent.label,
185
+ tags: agent.tags,
186
+ capabilities: agent.capabilities,
187
+ };
188
+ }
189
+
190
+ export function isHealthyChannelTarget(agent: AgentCard, now: number): boolean {
191
+ return isDeliveryAgent(agent) && agent.ready && agent.lastSeen > now - STALE_TTL_MS;
192
+ }
193
+
194
+ export function describeTarget(target: ChannelRouteTarget): string {
195
+ return bindingTargetToLegacyTarget(target);
196
+ }
197
+
198
+ export function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()): ChannelTargetHealth {
199
+ const target = binding.target;
200
+ const targetLabel = describeTarget(target);
201
+ const matches = channelTargetMatches(target, binding.channelId);
202
+ const snapshots = matches.map(channelTargetMatchSnapshot);
203
+
204
+ if (target.type === "pool") {
205
+ const healthyEligibles = matches.filter((a) => isHealthyChannelTarget(a, now));
206
+ if (!binding.poolAgentId) {
207
+ if (matches.length === 0) return { status: "error", detail: `Pool ${targetLabel}: no eligible agents`, target, matches: [] };
208
+ return { status: "error", detail: `Pool ${targetLabel}: ${healthyEligibles.length} eligible but slot unclaimed`, target, matches: snapshots };
209
+ }
210
+ const holder = getAgent(binding.poolAgentId);
211
+ if (!holder || holder.status === "offline") {
212
+ return { status: "error", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} is offline`, target, matches: snapshots };
213
+ }
214
+ if (!agentCanServeChannel(holder, binding.channelId)) {
215
+ return { status: "error", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} is not configured for ${binding.channelId}`, target, matches: snapshots };
216
+ }
217
+ if (!holder.ready) {
218
+ return { status: "warning", detail: `Pool ${targetLabel}: holder ${binding.poolAgentId} online but not ready`, target, matches: snapshots };
219
+ }
220
+ if (binding.poolAgentEpoch !== undefined && holder.epoch !== binding.poolAgentEpoch) {
221
+ return { status: "warning", detail: `Pool ${targetLabel}: holder epoch changed (stale claim)`, target, matches: snapshots };
222
+ }
223
+ return { status: "ok", detail: `Pool ${targetLabel}: claimed by ${binding.poolAgentId}`, target, matches: snapshots };
224
+ }
225
+
226
+ if (target.type === "agent" || target.type === "orchestrator" || target.type === "policy") {
227
+ const agent = matches[0];
228
+ if (!agent) {
229
+ return { status: "error", detail: `Target ${targetLabel} is not registered`, target, matches: [] };
230
+ }
231
+ if (agent.id !== "user" && agent.id !== "system" && (agent.kind === "channel" || agent.meta?.kind === "channel")) {
232
+ return { status: "error", detail: `Target ${targetLabel} is a channel, not a delivery agent`, target, matches: snapshots };
233
+ }
234
+ if (agent.status === "offline") {
235
+ return { status: "error", detail: `Target ${targetLabel} is offline`, target, matches: snapshots };
236
+ }
237
+ if (!agentCanServeChannel(agent, binding.channelId)) {
238
+ return { status: "error", detail: `Target ${targetLabel} is not configured for ${binding.channelId}`, target, matches: snapshots };
239
+ }
240
+ if (!agent.ready) {
241
+ return { status: "warning", detail: `Target ${targetLabel} is online but not ready`, target, matches: snapshots };
242
+ }
243
+ if (agent.id !== "user" && agent.id !== "system" && agent.lastSeen <= now - STALE_TTL_MS) {
244
+ return { status: "warning", detail: `Target ${targetLabel} heartbeat is stale`, target, matches: snapshots };
245
+ }
246
+ return { status: "ok", detail: `Target ${targetLabel} is online and ready`, target, matches: snapshots };
247
+ }
248
+
249
+ const healthyMatches = matches.filter((agent) => isHealthyChannelTarget(agent, now));
250
+ if (matches.length === 0) {
251
+ return { status: "error", detail: `Target ${targetLabel} has no matching agents`, target, matches: [] };
252
+ }
253
+ if (healthyMatches.length === 0) {
254
+ return { status: "error", detail: `Target ${targetLabel} has no online ready agents`, target, matches: snapshots };
255
+ }
256
+ if (binding.mode === "exclusive" && healthyMatches.length > 1) {
257
+ return {
258
+ status: "warning",
259
+ detail: `Target ${targetLabel} matches ${healthyMatches.length} online ready agents for an exclusive binding`,
260
+ target,
261
+ matches: snapshots,
262
+ };
263
+ }
264
+ return {
265
+ status: "ok",
266
+ detail: `Target ${targetLabel} has ${healthyMatches.length} online ready agent${healthyMatches.length === 1 ? "" : "s"}`,
267
+ target,
268
+ matches: snapshots,
269
+ };
270
+ }
271
+
272
+ export function rowToChannelSummary(row: any): ChannelSummary {
273
+ const binding = row.binding_id ? rowToChannelBinding({
274
+ id: row.binding_id,
275
+ channel_id: row.id,
276
+ conversation_id: row.binding_conversation_id,
277
+ target_type: row.binding_target_type,
278
+ target_id: row.binding_target_id,
279
+ mode: row.binding_mode,
280
+ priority: row.binding_priority,
281
+ created_at: row.binding_created_at,
282
+ updated_at: row.binding_updated_at,
283
+ pool_selector: row.binding_pool_selector,
284
+ pool_agent_id: row.binding_pool_agent_id,
285
+ pool_agent_epoch: row.binding_pool_agent_epoch,
286
+ pool_claim_expires_at: row.binding_pool_claim_expires_at,
287
+ }) : undefined;
288
+
289
+ return {
290
+ id: row.id,
291
+ name: row.display_name,
292
+ type: row.provider,
293
+ transport: row.transport,
294
+ agentId: row.agent_id,
295
+ accountId: row.account_id,
296
+ status: row.agent_status,
297
+ ready: row.agent_ready === 1,
298
+ direction: row.direction,
299
+ target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
300
+ binding,
301
+ targetHealth: binding ? channelTargetHealth(binding) : undefined,
302
+ topicChannels: parseStringArray(row.topic_channels),
303
+ capabilities: parseStringArray(row.channel_capabilities),
304
+ tags: parseStringArray(row.agent_tags),
305
+ lastSeen: row.agent_last_seen,
306
+ meta: parseJson(row.channel_meta, {}),
307
+ };
308
+ }
309
+
310
+
311
+ export function channelProviderForAgent(agent: AgentCard): string {
312
+ return stringValue(agent.meta?.provider) ??
313
+ stringValue(agent.meta?.channelType) ??
314
+ stringValue(agent.meta?.transport) ??
315
+ agent.tags.find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
316
+ "custom";
317
+ }
318
+
319
+ export function channelProviderForInput(input: Pick<RegisterAgentInput, "tags" | "meta">): string {
320
+ return stringValue(input.meta?.provider) ??
321
+ stringValue(input.meta?.channelType) ??
322
+ stringValue(input.meta?.transport) ??
323
+ (input.tags ?? []).find((tag) => tag.startsWith("channel:"))?.slice("channel:".length) ??
324
+ "custom";
325
+ }
326
+
327
+ export function channelAccountIdForAgent(agent: AgentCard, provider: string): string {
328
+ const accountId = stringValue(agent.meta?.accountId);
329
+ if (accountId) return accountId;
330
+ const prefix = `${provider}:`;
331
+ return agent.id.startsWith(prefix) ? agent.id.slice(prefix.length) : agent.id;
332
+ }
333
+
334
+ export function channelAccountIdForInput(input: Pick<RegisterAgentInput, "id" | "meta">, provider: string): string {
335
+ const accountId = stringValue(input.meta?.accountId);
336
+ if (accountId) return accountId;
337
+ const prefix = `${provider}:`;
338
+ return input.id.startsWith(prefix) ? input.id.slice(prefix.length) : input.id;
339
+ }
340
+
341
+ export function expectedChannelAgentId(input: Pick<RegisterAgentInput, "id" | "tags" | "meta">): string {
342
+ const provider = channelProviderForInput(input);
343
+ const accountId = channelAccountIdForInput(input, provider);
344
+ return `${provider}:${accountId}`;
345
+ }
346
+
347
+ export function channelDirectionForAgent(agent: AgentCard): ChannelSummary["direction"] {
348
+ const direction = stringValue(agent.meta?.direction);
349
+ return direction === "inbound" || direction === "outbound" || direction === "bidirectional" ? direction : "bidirectional";
350
+ }
351
+
352
+ // Parse an agent's configured channel target (meta.target / meta.configuredTarget).
353
+ // Delegates to the canonical parser — the previous inline copy dropped `policy:`
354
+ // and routed it as a literal agent id (#298). undefined target → no channel.
355
+ export function routeTargetFromLegacyTarget(target: string | undefined): ChannelRouteTarget | undefined {
356
+ if (!target) return undefined;
357
+ return parseChannelRouteTarget(target);
358
+ }
359
+
360
+ export function upsertChannelForAgent(agent: AgentCard): void {
361
+ const now = Date.now();
362
+ const provider = channelProviderForAgent(agent);
363
+ const accountId = channelAccountIdForAgent(agent, provider);
364
+ const channelId = `${provider}:${accountId}`;
365
+ const transport = stringValue(agent.meta?.transport) ?? provider;
366
+ const displayName = stringValue(agent.meta?.displayName) ?? agent.name;
367
+ const topicChannels = Array.isArray(agent.meta?.topicChannels)
368
+ ? (agent.meta.topicChannels as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)
369
+ : [];
370
+
371
+ getDb().query(`
372
+ INSERT INTO channels (id, provider, account_id, display_name, agent_id, transport, direction, topic_channels, capabilities, meta, created_at, updated_at)
373
+ VALUES ($id, $provider, $accountId, $displayName, $agentId, $transport, $direction, $topicChannels, $capabilities, $meta, $now, $now)
374
+ ON CONFLICT(provider, account_id) DO UPDATE SET
375
+ id = $id,
376
+ provider = $provider,
377
+ account_id = $accountId,
378
+ display_name = $displayName,
379
+ agent_id = $agentId,
380
+ transport = $transport,
381
+ direction = $direction,
382
+ topic_channels = $topicChannels,
383
+ capabilities = $capabilities,
384
+ meta = $meta,
385
+ updated_at = $now
386
+ `).run({
387
+ $id: channelId,
388
+ $provider: provider,
389
+ $accountId: accountId,
390
+ $displayName: displayName,
391
+ $agentId: agent.id,
392
+ $transport: transport,
393
+ $direction: channelDirectionForAgent(agent),
394
+ $topicChannels: JSON.stringify(topicChannels),
395
+ $capabilities: JSON.stringify(agent.capabilities),
396
+ $meta: JSON.stringify(agent.meta ?? {}),
397
+ $now: now,
398
+ });
399
+
400
+ const defaultTarget = routeTargetFromLegacyTarget(stringValue(agent.meta?.target) ?? stringValue(agent.meta?.configuredTarget));
401
+ if (defaultTarget) {
402
+ upsertChannelBinding({
403
+ channelId,
404
+ target: defaultTarget,
405
+ mode: defaultTarget.type === "broadcast" ? "broadcast" : "exclusive",
406
+ });
407
+ }
408
+ }
409
+
410
+ export function listChannels(): ChannelSummary[] {
411
+ const rows = getDb().query(`
412
+ SELECT
413
+ c.*,
414
+ c.capabilities AS channel_capabilities,
415
+ c.meta AS channel_meta,
416
+ a.status AS agent_status,
417
+ a.ready AS agent_ready,
418
+ a.tags AS agent_tags,
419
+ a.last_seen AS agent_last_seen,
420
+ b.id AS binding_id,
421
+ b.conversation_id AS binding_conversation_id,
422
+ b.target_type AS binding_target_type,
423
+ b.target_id AS binding_target_id,
424
+ b.mode AS binding_mode,
425
+ b.priority AS binding_priority,
426
+ b.created_at AS binding_created_at,
427
+ b.updated_at AS binding_updated_at,
428
+ b.pool_selector AS binding_pool_selector,
429
+ b.pool_agent_id AS binding_pool_agent_id,
430
+ b.pool_agent_epoch AS binding_pool_agent_epoch,
431
+ b.pool_claim_expires_at AS binding_pool_claim_expires_at
432
+ FROM channels c
433
+ JOIN agents a ON a.id = c.agent_id
434
+ LEFT JOIN channel_bindings b ON b.channel_id = c.id AND b.conversation_key = ''
435
+ ORDER BY a.ready DESC, c.display_name COLLATE NOCASE
436
+ `).all() as any[];
437
+ return rows.map(rowToChannelSummary);
438
+ }
439
+
440
+ export function getChannel(channelId: string): ChannelSummary | null {
441
+ return listChannels().find((channel) => channel.id === channelId) ?? null;
442
+ }
443
+
444
+ export function listChannelBindings(channelId?: string): ChannelBinding[] {
445
+ const rows = channelId
446
+ ? getDb().query("SELECT * FROM channel_bindings WHERE channel_id = ? ORDER BY priority DESC, updated_at DESC").all(channelId) as any[]
447
+ : getDb().query("SELECT * FROM channel_bindings ORDER BY channel_id, priority DESC, updated_at DESC").all() as any[];
448
+ return rows.map(rowToChannelBinding);
449
+ }
450
+
451
+ export function upsertChannelBinding(input: {
452
+ channelId: string;
453
+ conversationId?: string;
454
+ target: ChannelRouteTarget;
455
+ mode?: ChannelBindingMode;
456
+ priority?: number;
457
+ }): ChannelBinding {
458
+ if (!getChannel(input.channelId)) throw new ValidationError(`channel ${input.channelId} not found`);
459
+ const conversationKey = input.conversationId ?? "";
460
+ const targetId = input.target.type === "broadcast" ? "" : input.target.id;
461
+ const targetKey = input.target.type === "broadcast" ? "broadcast" : `${input.target.type}:${targetId}`;
462
+ const id = `${input.channelId}:${conversationKey || "default"}:${targetKey}`;
463
+ const mode = input.mode ?? "exclusive";
464
+ const isPool = input.target.type === "pool";
465
+ const poolSelector = isPool ? targetId : null;
466
+ const now = Date.now();
467
+ getDb().transaction(() => {
468
+ if (mode === "exclusive") {
469
+ getDb().query("DELETE FROM channel_bindings WHERE channel_id = ? AND conversation_key = ?").run(input.channelId, conversationKey);
470
+ }
471
+ getDb().query(`
472
+ INSERT INTO channel_bindings (id, channel_id, conversation_key, conversation_id, target_type, target_id, mode, priority, pool_selector, created_at, updated_at)
473
+ VALUES ($id, $channelId, $conversationKey, $conversationId, $targetType, $targetId, $mode, $priority, $poolSelector, $now, $now)
474
+ ON CONFLICT(channel_id, conversation_key, target_type, target_id) DO UPDATE SET
475
+ mode = $mode,
476
+ priority = $priority,
477
+ pool_selector = $poolSelector,
478
+ updated_at = $now
479
+ `).run({
480
+ $id: id,
481
+ $channelId: input.channelId,
482
+ $conversationKey: conversationKey,
483
+ $conversationId: input.conversationId ?? null,
484
+ $targetType: input.target.type,
485
+ $targetId: targetId,
486
+ $mode: mode,
487
+ $priority: input.priority ?? 0,
488
+ $poolSelector: poolSelector,
489
+ $now: now,
490
+ });
491
+ })();
492
+
493
+ evaluatePoolBindings(now);
494
+ const row = getDb().query("SELECT * FROM channel_bindings WHERE id = ?").get(id) as any;
495
+ return rowToChannelBinding(row);
496
+ }
497
+
498
+ export interface PoolBindingChange {
499
+ bindingId: string;
500
+ channelId: string;
501
+ previousAgentId: string | null;
502
+ newAgentId: string | null;
503
+ }
504
+
505
+ export function evaluatePoolBindings(now: number = Date.now()): PoolBindingChange[] {
506
+ const rows = getDb().query("SELECT * FROM channel_bindings WHERE target_type = 'pool'").all() as any[];
507
+ const changes: PoolBindingChange[] = [];
508
+
509
+ for (const row of rows) {
510
+ const bindingId = row.id as string;
511
+ const channelId = row.channel_id as string;
512
+ const selector = row.pool_selector ?? row.target_id;
513
+ const currentAgentId: string | null = row.pool_agent_id;
514
+ const currentEpoch: number | null = row.pool_agent_epoch;
515
+
516
+ let holderValid = false;
517
+ if (currentAgentId) {
518
+ const holder = getAgent(currentAgentId);
519
+ if (holder && holder.status !== "offline" && holder.ready && holder.lastSeen > now - STALE_TTL_MS && agentCanServeChannel(holder, channelId)) {
520
+ if (currentEpoch === null || holder.epoch === currentEpoch) {
521
+ getDb().query("UPDATE channel_bindings SET pool_claim_expires_at = ? WHERE id = ?")
522
+ .run(now + POOL_CLAIM_LEASE_MS, bindingId);
523
+ holderValid = true;
524
+ }
525
+ }
526
+ }
527
+
528
+ if (!holderValid && currentAgentId) {
529
+ getDb().query("UPDATE channel_bindings SET pool_agent_id = NULL, pool_agent_epoch = NULL, pool_claim_expires_at = NULL WHERE id = ?")
530
+ .run(bindingId);
531
+ changes.push({ bindingId, channelId, previousAgentId: currentAgentId, newAgentId: null });
532
+ }
533
+
534
+ if (!holderValid) {
535
+ const candidates = listAgents().filter((a) =>
536
+ a.id !== "user" && a.id !== "system" && a.kind !== "channel" && a.meta?.kind !== "channel"
537
+ );
538
+ const eligible = poolSelectorMatches(selector, candidates)
539
+ .filter((a) => agentCanServeChannel(a, channelId))
540
+ .filter((a) => a.status !== "offline" && a.ready && a.lastSeen > now - STALE_TTL_MS)
541
+ .sort((a, b) => b.lastSeen - a.lastSeen);
542
+
543
+ if (eligible.length > 0) {
544
+ const picked = eligible[0]!;
545
+ getDb().query("UPDATE channel_bindings SET pool_agent_id = ?, pool_agent_epoch = ?, pool_claim_expires_at = ? WHERE id = ?")
546
+ .run(picked.id, picked.epoch, now + POOL_CLAIM_LEASE_MS, bindingId);
547
+ const lastChange = changes[changes.length - 1];
548
+ if (lastChange && lastChange.bindingId === bindingId && lastChange.newAgentId === null) {
549
+ lastChange.newAgentId = picked.id;
550
+ } else {
551
+ changes.push({ bindingId, channelId, previousAgentId: currentAgentId, newAgentId: picked.id });
552
+ }
553
+ }
554
+ }
555
+ }
556
+
557
+ return changes;
558
+ }
559
+
560
+ export function resolveChannelRoutes(channelId: string, conversationId?: string): ChannelBinding[] {
561
+ const rows = conversationId
562
+ ? getDb().query(`
563
+ SELECT * FROM channel_bindings
564
+ WHERE channel_id = ? AND conversation_key IN (?, '')
565
+ ORDER BY CASE WHEN conversation_key = ? THEN 1 ELSE 0 END DESC, priority DESC, updated_at DESC
566
+ `).all(channelId, conversationId, conversationId) as any[]
567
+ : getDb().query(`
568
+ SELECT * FROM channel_bindings
569
+ WHERE channel_id = ? AND conversation_key = ''
570
+ ORDER BY priority DESC, updated_at DESC
571
+ `).all(channelId) as any[];
572
+ if (!conversationId) return rows.map(rowToChannelBinding);
573
+ const exact = rows.filter((row) => row.conversation_key === conversationId);
574
+ return (exact.length ? exact : rows.filter((row) => row.conversation_key === "")).map(rowToChannelBinding);
575
+ }
576
+
@@ -0,0 +1,71 @@
1
+ import { Database } from "bun:sqlite";
2
+
3
+ // The shared SQLite handle. Set once by initDb() (schema.ts) and read everywhere
4
+ // else through getDb(). Kept module-private so the only mutation path is setDb().
5
+ let db: Database;
6
+
7
+ export function setDb(conn: Database): void {
8
+ db = conn;
9
+ }
10
+
11
+ export function getDb(): Database {
12
+ if (!db) throw new Error("database not initialized");
13
+ return db;
14
+ }
15
+
16
+ // Connection-scoped pragmas. journal_mode and auto_vacuum persist in the file;
17
+ // the rest reset per connection, so they must be re-applied on every open.
18
+ export function applyConnectionPragmas(conn: Database): void {
19
+ conn.run("PRAGMA journal_mode = WAL");
20
+ // Wait up to 5s for a held lock instead of failing instantly with SQLITE_BUSY.
21
+ // Matters with many agents polling/writing concurrently.
22
+ conn.run("PRAGMA busy_timeout = 5000");
23
+ // NORMAL is the recommended durability level under WAL: as safe as FULL except
24
+ // for a power loss mid-checkpoint, and meaningfully faster on writes.
25
+ conn.run("PRAGMA synchronous = NORMAL");
26
+ conn.run("PRAGMA foreign_keys = ON");
27
+ // ~8MB page cache (negative = KiB) and 256MB mmap window for the read-heavy
28
+ // poll workload. Both are best-effort; SQLite ignores them if unsupported.
29
+ conn.run("PRAGMA cache_size = -8000");
30
+ conn.run("PRAGMA mmap_size = 268435456");
31
+ }
32
+
33
+ // Slow-query log. Off unless AGENT_RELAY_DB_SLOW_MS is set (>0). Wraps the
34
+ // execution of the heavy dynamic queries so the next slow query surfaces before
35
+ // it wedges something (see issue #197 — the 8.6s reply-obligation query).
36
+ const SLOW_QUERY_MS = Number(process.env.AGENT_RELAY_DB_SLOW_MS) || 0;
37
+ export function timedQuery<T>(label: string, run: () => T): T {
38
+ if (SLOW_QUERY_MS <= 0) return run();
39
+ const start = performance.now();
40
+ try {
41
+ return run();
42
+ } finally {
43
+ const ms = performance.now() - start;
44
+ if (ms >= SLOW_QUERY_MS) {
45
+ console.warn(`[db.slow] ${label} took ${ms.toFixed(1)}ms (threshold ${SLOW_QUERY_MS}ms)`);
46
+ }
47
+ }
48
+ }
49
+
50
+ // Periodic DB upkeep, invoked by the maintenance scheduler. ANALYZE refreshes
51
+ // planner statistics, PRAGMA optimize applies them, wal_checkpoint(TRUNCATE)
52
+ // bounds WAL growth, and VACUUM (opt-in) reclaims space since auto_vacuum is off.
53
+ export function runDbMaintenance(options: { vacuum?: boolean } = {}): {
54
+ analyzed: boolean;
55
+ checkpointed: boolean;
56
+ vacuumed: boolean;
57
+ } {
58
+ db.run("ANALYZE");
59
+ db.run("PRAGMA optimize");
60
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
61
+ let vacuumed = false;
62
+ if (options.vacuum) {
63
+ db.run("VACUUM");
64
+ vacuumed = true;
65
+ }
66
+ return { analyzed: true, checkpointed: true, vacuumed };
67
+ }
68
+
69
+ // Shared error types thrown across query domains and unwrapped at call sites.
70
+ export class ValidationError extends Error {}
71
+ export class ClaimError extends Error {}