@relaycast/engine 4.1.6 → 5.0.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.
Files changed (190) hide show
  1. package/dist/adapters/node/index.d.ts.map +1 -1
  2. package/dist/adapters/node/index.js +23 -1
  3. package/dist/adapters/node/index.js.map +1 -1
  4. package/dist/adapters/node/presence.d.ts +7 -3
  5. package/dist/adapters/node/presence.d.ts.map +1 -1
  6. package/dist/adapters/node/presence.js +10 -8
  7. package/dist/adapters/node/presence.js.map +1 -1
  8. package/dist/adapters/node/realtime.d.ts.map +1 -1
  9. package/dist/adapters/node/realtime.js +8 -3
  10. package/dist/adapters/node/realtime.js.map +1 -1
  11. package/dist/bin/serve.js +2 -4
  12. package/dist/bin/serve.js.map +1 -1
  13. package/dist/db/migrations/0021_node_delivery_contracts.sql +70 -0
  14. package/dist/db/migrations/0022_http_push_redrive.sql +13 -0
  15. package/dist/db/migrations/0023_direct_ws_nodes.sql +124 -0
  16. package/dist/db/migrations/0024_binding_workspace_constraints.sql +109 -0
  17. package/dist/db/schema.d.ts +339 -0
  18. package/dist/db/schema.d.ts.map +1 -1
  19. package/dist/db/schema.js +48 -1
  20. package/dist/db/schema.js.map +1 -1
  21. package/dist/engine/action.d.ts +0 -7
  22. package/dist/engine/action.d.ts.map +1 -1
  23. package/dist/engine/action.js +0 -7
  24. package/dist/engine/action.js.map +1 -1
  25. package/dist/engine/agent.d.ts.map +1 -1
  26. package/dist/engine/agent.js +7 -1
  27. package/dist/engine/agent.js.map +1 -1
  28. package/dist/engine/delivery.d.ts +24 -0
  29. package/dist/engine/delivery.d.ts.map +1 -1
  30. package/dist/engine/delivery.js +190 -74
  31. package/dist/engine/delivery.js.map +1 -1
  32. package/dist/engine/deliveryWrites.d.ts +5 -0
  33. package/dist/engine/deliveryWrites.d.ts.map +1 -1
  34. package/dist/engine/deliveryWrites.js +51 -11
  35. package/dist/engine/deliveryWrites.js.map +1 -1
  36. package/dist/engine/invocationCompletion.d.ts +1 -1
  37. package/dist/engine/invocationCompletion.d.ts.map +1 -1
  38. package/dist/engine/invocationCompletion.js +1 -16
  39. package/dist/engine/invocationCompletion.js.map +1 -1
  40. package/dist/engine/message.d.ts.map +1 -1
  41. package/dist/engine/message.js +1 -0
  42. package/dist/engine/message.js.map +1 -1
  43. package/dist/engine/node.d.ts +85 -0
  44. package/dist/engine/node.d.ts.map +1 -1
  45. package/dist/engine/node.js +473 -14
  46. package/dist/engine/node.js.map +1 -1
  47. package/dist/engine/nodeContext.d.ts +22 -0
  48. package/dist/engine/nodeContext.d.ts.map +1 -0
  49. package/dist/engine/nodeContext.js +104 -0
  50. package/dist/engine/nodeContext.js.map +1 -0
  51. package/dist/engine/thread.d.ts.map +1 -1
  52. package/dist/engine/thread.js +37 -17
  53. package/dist/engine/thread.js.map +1 -1
  54. package/dist/engine/wsAuth.d.ts +36 -0
  55. package/dist/engine/wsAuth.d.ts.map +1 -0
  56. package/dist/engine/wsAuth.js +57 -0
  57. package/dist/engine/wsAuth.js.map +1 -0
  58. package/dist/engine.d.ts.map +1 -1
  59. package/dist/engine.js +46 -74
  60. package/dist/engine.js.map +1 -1
  61. package/dist/entrypoints/node.d.ts.map +1 -1
  62. package/dist/entrypoints/node.js +27 -51
  63. package/dist/entrypoints/node.js.map +1 -1
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +5 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/lib/httpError.d.ts +5 -1
  69. package/dist/lib/httpError.d.ts.map +1 -1
  70. package/dist/lib/httpError.js +9 -5
  71. package/dist/lib/httpError.js.map +1 -1
  72. package/dist/lib/httpQuery.d.ts +28 -0
  73. package/dist/lib/httpQuery.d.ts.map +1 -0
  74. package/dist/lib/httpQuery.js +32 -0
  75. package/dist/lib/httpQuery.js.map +1 -0
  76. package/dist/lib/httpResponse.d.ts +116 -0
  77. package/dist/lib/httpResponse.d.ts.map +1 -0
  78. package/dist/lib/httpResponse.js +65 -0
  79. package/dist/lib/httpResponse.js.map +1 -0
  80. package/dist/middleware/auth.d.ts.map +1 -1
  81. package/dist/middleware/auth.js +3 -2
  82. package/dist/middleware/auth.js.map +1 -1
  83. package/dist/middleware/idempotency.d.ts +30 -0
  84. package/dist/middleware/idempotency.d.ts.map +1 -1
  85. package/dist/middleware/idempotency.js +14 -0
  86. package/dist/middleware/idempotency.js.map +1 -1
  87. package/dist/middleware/planLimits.d.ts.map +1 -1
  88. package/dist/middleware/planLimits.js +2 -7
  89. package/dist/middleware/planLimits.js.map +1 -1
  90. package/dist/middleware/rateLimit.d.ts.map +1 -1
  91. package/dist/middleware/rateLimit.js +2 -7
  92. package/dist/middleware/rateLimit.js.map +1 -1
  93. package/dist/ports/index.d.ts +0 -14
  94. package/dist/ports/index.d.ts.map +1 -1
  95. package/dist/routes/a2a.d.ts.map +1 -1
  96. package/dist/routes/a2a.js +41 -83
  97. package/dist/routes/a2a.js.map +1 -1
  98. package/dist/routes/action.d.ts.map +1 -1
  99. package/dist/routes/action.js +32 -37
  100. package/dist/routes/action.js.map +1 -1
  101. package/dist/routes/agent.d.ts.map +1 -1
  102. package/dist/routes/agent.js +69 -73
  103. package/dist/routes/agent.js.map +1 -1
  104. package/dist/routes/certify.d.ts.map +1 -1
  105. package/dist/routes/certify.js +21 -35
  106. package/dist/routes/certify.js.map +1 -1
  107. package/dist/routes/channel.d.ts.map +1 -1
  108. package/dist/routes/channel.js +33 -71
  109. package/dist/routes/channel.js.map +1 -1
  110. package/dist/routes/console.d.ts.map +1 -1
  111. package/dist/routes/console.js +22 -32
  112. package/dist/routes/console.js.map +1 -1
  113. package/dist/routes/delivery.d.ts.map +1 -1
  114. package/dist/routes/delivery.js +22 -54
  115. package/dist/routes/delivery.js.map +1 -1
  116. package/dist/routes/deliveryRouting.d.ts +6 -0
  117. package/dist/routes/deliveryRouting.d.ts.map +1 -1
  118. package/dist/routes/deliveryRouting.js +289 -35
  119. package/dist/routes/deliveryRouting.js.map +1 -1
  120. package/dist/routes/directory.d.ts.map +1 -1
  121. package/dist/routes/directory.js +47 -63
  122. package/dist/routes/directory.js.map +1 -1
  123. package/dist/routes/dm.d.ts.map +1 -1
  124. package/dist/routes/dm.js +21 -42
  125. package/dist/routes/dm.js.map +1 -1
  126. package/dist/routes/eventSubscription.d.ts.map +1 -1
  127. package/dist/routes/eventSubscription.js +15 -21
  128. package/dist/routes/eventSubscription.js.map +1 -1
  129. package/dist/routes/fanout.d.ts +2 -1
  130. package/dist/routes/fanout.d.ts.map +1 -1
  131. package/dist/routes/fanout.js +25 -15
  132. package/dist/routes/fanout.js.map +1 -1
  133. package/dist/routes/file.d.ts.map +1 -1
  134. package/dist/routes/file.js +34 -40
  135. package/dist/routes/file.js.map +1 -1
  136. package/dist/routes/groupDm.d.ts.map +1 -1
  137. package/dist/routes/groupDm.js +23 -48
  138. package/dist/routes/groupDm.js.map +1 -1
  139. package/dist/routes/inboundWebhook.d.ts.map +1 -1
  140. package/dist/routes/inboundWebhook.js +17 -35
  141. package/dist/routes/inboundWebhook.js.map +1 -1
  142. package/dist/routes/inbox.d.ts.map +1 -1
  143. package/dist/routes/inbox.js +2 -1
  144. package/dist/routes/inbox.js.map +1 -1
  145. package/dist/routes/message.d.ts.map +1 -1
  146. package/dist/routes/message.js +39 -50
  147. package/dist/routes/message.js.map +1 -1
  148. package/dist/routes/node.d.ts.map +1 -1
  149. package/dist/routes/node.js +125 -12
  150. package/dist/routes/node.js.map +1 -1
  151. package/dist/routes/presence.d.ts.map +1 -1
  152. package/dist/routes/presence.js +4 -3
  153. package/dist/routes/presence.js.map +1 -1
  154. package/dist/routes/reaction.d.ts.map +1 -1
  155. package/dist/routes/reaction.js +10 -21
  156. package/dist/routes/reaction.js.map +1 -1
  157. package/dist/routes/receipt.d.ts.map +1 -1
  158. package/dist/routes/receipt.js +7 -15
  159. package/dist/routes/receipt.js.map +1 -1
  160. package/dist/routes/routing.d.ts.map +1 -1
  161. package/dist/routes/routing.js +38 -51
  162. package/dist/routes/routing.js.map +1 -1
  163. package/dist/routes/search.d.ts.map +1 -1
  164. package/dist/routes/search.js +15 -12
  165. package/dist/routes/search.js.map +1 -1
  166. package/dist/routes/systemPrompt.d.ts.map +1 -1
  167. package/dist/routes/systemPrompt.js +8 -13
  168. package/dist/routes/systemPrompt.js.map +1 -1
  169. package/dist/routes/thread.d.ts.map +1 -1
  170. package/dist/routes/thread.js +17 -35
  171. package/dist/routes/thread.js.map +1 -1
  172. package/dist/routes/trigger.d.ts.map +1 -1
  173. package/dist/routes/trigger.js +15 -14
  174. package/dist/routes/trigger.js.map +1 -1
  175. package/dist/routes/workspace.d.ts.map +1 -1
  176. package/dist/routes/workspace.js +39 -220
  177. package/dist/routes/workspace.js.map +1 -1
  178. package/package.json +3 -3
  179. package/dist/lib/fleetNodes.d.ts +0 -9
  180. package/dist/lib/fleetNodes.d.ts.map +0 -1
  181. package/dist/lib/fleetNodes.js +0 -66
  182. package/dist/lib/fleetNodes.js.map +0 -1
  183. package/dist/lib/workspaceStream.d.ts +0 -9
  184. package/dist/lib/workspaceStream.d.ts.map +0 -1
  185. package/dist/lib/workspaceStream.js +0 -53
  186. package/dist/lib/workspaceStream.js.map +0 -1
  187. package/dist/middleware/fleetNodes.d.ts +0 -10
  188. package/dist/middleware/fleetNodes.d.ts.map +0 -1
  189. package/dist/middleware/fleetNodes.js +0 -18
  190. package/dist/middleware/fleetNodes.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { and, eq, inArray, ne, or, sql } from 'drizzle-orm';
2
2
  import { parseFleetBrokerToRelaycastMessage } from '@relaycast/types';
3
- import { actionInvocations, actions, agents, channelMembers, channels, nodes } from '../db/schema.js';
3
+ import { actionInvocations, actions, agents, agentNodeBindings, channelMembers, channels, nodes } from '../db/schema.js';
4
4
  import { randomHex, sha256Hex } from '../lib/crypto.js';
5
5
  import { codedError } from '../lib/httpError.js';
6
6
  import { runAtomic } from '../ports/database.js';
@@ -9,6 +9,12 @@ import { isNodeLive, nodeHasCapability } from './placement.js';
9
9
  import { completeNodeInvocation, rescheduleInvocationsForLostNode, rescheduleNodeInvocation } from './action.js';
10
10
  import { emitInvocationCompletionEffects } from './invocationCompletion.js';
11
11
  import { ackDeliveriesUpToSeq, deliverPendingToNode } from './delivery.js';
12
+ export function directNodeIdForAgent(agentId) {
13
+ return `node_direct_${agentId}`;
14
+ }
15
+ function directNodeNameForAgent(agentId) {
16
+ return `direct-${agentId}`;
17
+ }
12
18
  function capabilityName(capability) {
13
19
  if (typeof capability === 'string')
14
20
  return capability;
@@ -25,6 +31,9 @@ function publicNode(row) {
25
31
  return {
26
32
  id: row.id,
27
33
  name: row.name,
34
+ kind: row.kind,
35
+ delivery_adapter: row.deliveryAdapter,
36
+ delivery: redactDeliveryConfig(row.deliveryConfig),
28
37
  capabilities: row.capabilities,
29
38
  tags: row.tags,
30
39
  version: row.version,
@@ -38,6 +47,49 @@ function publicNode(row) {
38
47
  created_at: row.createdAt.toISOString(),
39
48
  };
40
49
  }
50
+ const SENSITIVE_DELIVERY_KEY = /api[_-]?key|authorization|cookie|credential|headers|password|private[_-]?key|secret|token/i;
51
+ function redactDeliveryValue(key, value) {
52
+ if (key && /^headers$/i.test(key) && value && typeof value === 'object' && !Array.isArray(value)) {
53
+ return Object.fromEntries(Object.keys(value).map((name) => [name, '[redacted]']));
54
+ }
55
+ if (key && SENSITIVE_DELIVERY_KEY.test(key)) {
56
+ return '[redacted]';
57
+ }
58
+ if (Array.isArray(value)) {
59
+ return value.map((item) => redactDeliveryValue(null, item));
60
+ }
61
+ if (value && typeof value === 'object') {
62
+ return Object.fromEntries(Object.entries(value).map(([childKey, childValue]) => [
63
+ childKey,
64
+ redactDeliveryValue(childKey, childValue),
65
+ ]));
66
+ }
67
+ return value;
68
+ }
69
+ function redactDeliveryConfig(config) {
70
+ if (!config)
71
+ return null;
72
+ return redactDeliveryValue(null, config);
73
+ }
74
+ function defaultAdapter(kind, deliveryConfig) {
75
+ if (kind === 'fleet_ws')
76
+ return 'fleet.ws.v1';
77
+ if (kind === 'direct_ws')
78
+ return 'direct.ws.v1';
79
+ if (kind === 'poll')
80
+ return 'poll.v1';
81
+ const auth = deliveryConfig?.auth;
82
+ const authType = auth && typeof auth === 'object' && !Array.isArray(auth)
83
+ ? auth.type
84
+ : undefined;
85
+ if (authType === 'hmac_sha256')
86
+ return 'http.hmac.v1';
87
+ if (authType === 'bearer')
88
+ return 'http.bearer.v1';
89
+ if (authType === 'static_headers')
90
+ return 'http.static_headers.v1';
91
+ return 'http.basic.v1';
92
+ }
41
93
  async function ensureCapabilityActions(db, workspaceId, nodeId, capabilities) {
42
94
  for (const capability of capabilities) {
43
95
  const name = capabilityName(capability);
@@ -73,13 +125,22 @@ export async function createNodeToken(db, workspaceId, data) {
73
125
  const tokenHash = await sha256Hex(token);
74
126
  const existing = await getNodeByName(db, workspaceId, data.name);
75
127
  const now = new Date();
128
+ const kind = data.kind ?? existing?.kind ?? 'fleet_ws';
129
+ const deliveryConfig = data.delivery === undefined ? existing?.deliveryConfig ?? null : data.delivery;
130
+ const deliveryAdapter = data.delivery_adapter
131
+ ?? (data.delivery === undefined ? existing?.deliveryAdapter : undefined)
132
+ ?? defaultAdapter(kind, deliveryConfig);
133
+ const maxAgents = data.max_agents ?? existing?.maxAgents ?? (kind === 'http_push' ? 1 : 0);
76
134
  if (existing) {
77
135
  const [updated] = await db
78
136
  .update(nodes)
79
137
  .set({
80
138
  tokenHash,
139
+ kind,
140
+ deliveryAdapter,
141
+ deliveryConfig,
81
142
  capabilities: normalizeCapabilities(data.capabilities ?? existing.capabilities),
82
- maxAgents: data.max_agents ?? existing.maxAgents,
143
+ maxAgents,
83
144
  tags: data.tags ?? existing.tags,
84
145
  version: data.version ?? existing.version,
85
146
  status: 'offline',
@@ -96,8 +157,11 @@ export async function createNodeToken(db, workspaceId, data) {
96
157
  workspaceId,
97
158
  name: data.name,
98
159
  tokenHash,
160
+ kind,
161
+ deliveryAdapter,
162
+ deliveryConfig,
99
163
  capabilities: normalizeCapabilities(data.capabilities ?? []),
100
- maxAgents: data.max_agents ?? 0,
164
+ maxAgents,
101
165
  tags: data.tags ?? [],
102
166
  version: data.version ?? 'unknown',
103
167
  status: 'offline',
@@ -138,6 +202,9 @@ export async function registerNode(db, workspaceId, authenticatedNodeId, message
138
202
  .set({
139
203
  name: message.name,
140
204
  capabilities: message.capabilities,
205
+ kind: 'fleet_ws',
206
+ deliveryAdapter: 'fleet.ws.v1',
207
+ deliveryConfig: null,
141
208
  maxAgents: message.max_agents,
142
209
  tags: message.tags,
143
210
  version: message.version,
@@ -232,8 +299,339 @@ async function autoJoinGeneral(db, workspaceId, agentId) {
232
299
  }).onConflictDoNothing();
233
300
  }
234
301
  }
302
+ async function upsertAgentNodeBinding(db, workspaceId, agent, nodeId, opts = {}) {
303
+ if (opts.deactivateExisting ?? true) {
304
+ await db
305
+ .update(agentNodeBindings)
306
+ .set({ status: 'inactive', updatedAt: new Date() })
307
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.agentId, agent.id), eq(agentNodeBindings.status, 'active'), ne(agentNodeBindings.nodeId, nodeId)));
308
+ }
309
+ await db
310
+ .insert(agentNodeBindings)
311
+ .values({
312
+ id: `anb_${generateId()}`,
313
+ workspaceId,
314
+ agentId: agent.id,
315
+ nodeId,
316
+ status: 'active',
317
+ sessionRef: opts.sessionRef ?? null,
318
+ priority: opts.priority ?? 0,
319
+ updatedAt: new Date(),
320
+ })
321
+ .onConflictDoUpdate({
322
+ target: [agentNodeBindings.agentId, agentNodeBindings.nodeId],
323
+ set: {
324
+ status: 'active',
325
+ sessionRef: opts.sessionRef ?? null,
326
+ priority: opts.priority ?? 0,
327
+ updatedAt: new Date(),
328
+ },
329
+ });
330
+ await db
331
+ .update(agents)
332
+ .set({
333
+ locationType: 'via_node',
334
+ locationNodeId: nodeId,
335
+ sessionRef: opts.sessionRef ?? undefined,
336
+ status: 'active',
337
+ lastSeen: new Date(),
338
+ })
339
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, agent.id)));
340
+ }
341
+ async function activeBindingNodeIdsForAgent(db, workspaceId, agentId) {
342
+ const rows = await db
343
+ .select({ nodeId: agentNodeBindings.nodeId })
344
+ .from(agentNodeBindings)
345
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.agentId, agentId), eq(agentNodeBindings.status, 'active')));
346
+ return rows.map((row) => row.nodeId);
347
+ }
348
+ async function reserveNodeAgentSlot(db, workspaceId, node) {
349
+ const [reserved] = await db
350
+ .update(nodes)
351
+ .set({
352
+ activeAgents: sql `${nodes.activeAgents} + 1`,
353
+ })
354
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, node.id), or(eq(nodes.maxAgents, 0), sql `${nodes.activeAgents} < ${nodes.maxAgents}`)))
355
+ .returning({ id: nodes.id });
356
+ if (!reserved) {
357
+ throw codedError(`Node "${node.name}" is at capacity`, 'node_capacity_exceeded', 409);
358
+ }
359
+ }
360
+ async function releaseNodeAgentSlots(db, workspaceId, nodeIds) {
361
+ const uniqueNodeIds = [...new Set(nodeIds)];
362
+ if (uniqueNodeIds.length === 0)
363
+ return;
364
+ await db
365
+ .update(nodes)
366
+ .set({
367
+ activeAgents: sql `CASE WHEN ${nodes.activeAgents} > 0 THEN ${nodes.activeAgents} - 1 ELSE 0 END`,
368
+ })
369
+ .where(and(eq(nodes.workspaceId, workspaceId), inArray(nodes.id, uniqueNodeIds)));
370
+ }
371
+ export async function ensureDirectNodeForAgent(db, workspaceId, agent, opts = {}) {
372
+ return runAtomic(db, (tx) => ensureDirectNodeForAgentInTx(tx, workspaceId, agent, opts));
373
+ }
374
+ async function ensureDirectNodeForAgentInTx(db, workspaceId, agent, opts = {}) {
375
+ const activeNodeIds = await activeBindingNodeIdsForAgent(db, workspaceId, agent.id);
376
+ const nodeId = directNodeIdForAgent(agent.id);
377
+ const alreadyDirect = activeNodeIds.includes(nodeId);
378
+ const explicitNodeIds = activeNodeIds.filter((activeNodeId) => activeNodeId !== nodeId);
379
+ if (!opts.force && explicitNodeIds.length > 0) {
380
+ return null;
381
+ }
382
+ const now = new Date();
383
+ const [existingDirect] = await db
384
+ .select()
385
+ .from(nodes)
386
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
387
+ if (!opts.force && alreadyDirect && existingDirect && agent.locationNodeId === nodeId) {
388
+ const update = {
389
+ name: directNodeNameForAgent(agent.id),
390
+ kind: 'direct_ws',
391
+ deliveryAdapter: 'direct.ws.v1',
392
+ deliveryConfig: {
393
+ implicit: true,
394
+ agent_id: agent.id,
395
+ agent_name: agent.name,
396
+ },
397
+ capabilities: [],
398
+ maxAgents: 1,
399
+ activeAgents: 1,
400
+ tags: ['implicit', 'direct_ws'],
401
+ version: 'implicit',
402
+ handlersLive: false,
403
+ load: 0,
404
+ };
405
+ if (opts.online) {
406
+ update.status = 'online';
407
+ update.lastHeartbeatAt = now;
408
+ }
409
+ const [updatedDirect] = await db
410
+ .update(nodes)
411
+ .set(update)
412
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
413
+ .returning();
414
+ return publicNode(updatedDirect ?? {
415
+ ...existingDirect,
416
+ ...update,
417
+ activeAgents: 1,
418
+ status: opts.online ? 'online' : existingDirect.status,
419
+ lastHeartbeatAt: opts.online ? now : existingDirect.lastHeartbeatAt,
420
+ });
421
+ }
422
+ let directNode = existingDirect;
423
+ if (!directNode) {
424
+ const tokenHash = await sha256Hex(`implicit_direct:${workspaceId}:${agent.id}:${randomHex(16)}`);
425
+ [directNode] = await db
426
+ .insert(nodes)
427
+ .values({
428
+ id: nodeId,
429
+ workspaceId,
430
+ name: directNodeNameForAgent(agent.id),
431
+ tokenHash,
432
+ kind: 'direct_ws',
433
+ deliveryAdapter: 'direct.ws.v1',
434
+ deliveryConfig: {
435
+ implicit: true,
436
+ agent_id: agent.id,
437
+ agent_name: agent.name,
438
+ },
439
+ capabilities: [],
440
+ maxAgents: 1,
441
+ activeAgents: 0,
442
+ tags: ['implicit', 'direct_ws'],
443
+ version: 'implicit',
444
+ status: opts.online ? 'online' : 'offline',
445
+ handlersLive: false,
446
+ load: 0,
447
+ lastHeartbeatAt: opts.online ? now : null,
448
+ createdAt: now,
449
+ })
450
+ .returning();
451
+ }
452
+ else {
453
+ const update = {
454
+ name: directNodeNameForAgent(agent.id),
455
+ kind: 'direct_ws',
456
+ deliveryAdapter: 'direct.ws.v1',
457
+ deliveryConfig: {
458
+ implicit: true,
459
+ agent_id: agent.id,
460
+ agent_name: agent.name,
461
+ },
462
+ capabilities: [],
463
+ maxAgents: 1,
464
+ tags: ['implicit', 'direct_ws'],
465
+ version: 'implicit',
466
+ handlersLive: false,
467
+ load: 0,
468
+ };
469
+ if (opts.online) {
470
+ update.status = 'online';
471
+ update.lastHeartbeatAt = now;
472
+ }
473
+ [directNode] = await db
474
+ .update(nodes)
475
+ .set(update)
476
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
477
+ .returning();
478
+ }
479
+ await upsertAgentNodeBinding(db, workspaceId, agent, nodeId, {
480
+ sessionRef: opts.sessionRef ?? null,
481
+ deactivateExisting: true,
482
+ });
483
+ await releaseNodeAgentSlots(db, workspaceId, explicitNodeIds);
484
+ await db
485
+ .update(nodes)
486
+ .set({
487
+ activeAgents: 1,
488
+ status: opts.online ? 'online' : directNode.status,
489
+ lastHeartbeatAt: opts.online ? now : directNode.lastHeartbeatAt,
490
+ })
491
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
492
+ return publicNode({
493
+ ...directNode,
494
+ activeAgents: 1,
495
+ status: opts.online ? 'online' : directNode.status,
496
+ lastHeartbeatAt: opts.online ? now : directNode.lastHeartbeatAt,
497
+ });
498
+ }
499
+ export async function markDirectNodeOfflineForAgent(db, workspaceId, agentId) {
500
+ const nodeId = directNodeIdForAgent(agentId);
501
+ await db
502
+ .update(nodes)
503
+ .set({
504
+ status: 'offline',
505
+ handlersLive: false,
506
+ load: 0,
507
+ lastHeartbeatAt: new Date(),
508
+ })
509
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
510
+ }
511
+ function serializeBinding(row) {
512
+ return {
513
+ id: row.id,
514
+ agent_id: row.agentId,
515
+ agent_name: row.agentName,
516
+ node_id: row.nodeId,
517
+ node_name: row.nodeName,
518
+ node_kind: row.nodeKind,
519
+ status: row.status,
520
+ session_ref: row.sessionRef,
521
+ priority: row.priority,
522
+ created_at: row.createdAt.toISOString(),
523
+ updated_at: row.updatedAt?.toISOString() ?? null,
524
+ };
525
+ }
526
+ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts = {}) {
527
+ return runAtomic(db, async (tx) => {
528
+ const node = await getNodeByName(tx, workspaceId, nodeName);
529
+ if (!node)
530
+ throw codedError(`Node "${nodeName}" not found`, 'node_not_found', 404);
531
+ const [agent] = await tx
532
+ .select()
533
+ .from(agents)
534
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, agentName)));
535
+ if (!agent)
536
+ throw codedError(`Agent "${agentName}" not found`, 'agent_not_found', 404);
537
+ const activeNodeIds = await activeBindingNodeIdsForAgent(tx, workspaceId, agent.id);
538
+ const targetWasActive = activeNodeIds.includes(node.id);
539
+ let reservedTargetSlot = false;
540
+ try {
541
+ if (!targetWasActive) {
542
+ await reserveNodeAgentSlot(tx, workspaceId, node);
543
+ reservedTargetSlot = true;
544
+ }
545
+ await upsertAgentNodeBinding(tx, workspaceId, agent, node.id, {
546
+ sessionRef: opts.session_ref ?? null,
547
+ priority: opts.priority ?? 0,
548
+ });
549
+ await releaseNodeAgentSlots(tx, workspaceId, activeNodeIds.filter((nodeId) => nodeId !== node.id));
550
+ }
551
+ catch (err) {
552
+ if (reservedTargetSlot) {
553
+ await releaseNodeAgentSlots(tx, workspaceId, [node.id]);
554
+ }
555
+ throw err;
556
+ }
557
+ const [binding] = await tx
558
+ .select({
559
+ id: agentNodeBindings.id,
560
+ agentId: agentNodeBindings.agentId,
561
+ agentName: agents.name,
562
+ nodeId: agentNodeBindings.nodeId,
563
+ nodeName: nodes.name,
564
+ nodeKind: nodes.kind,
565
+ status: agentNodeBindings.status,
566
+ sessionRef: agentNodeBindings.sessionRef,
567
+ priority: agentNodeBindings.priority,
568
+ createdAt: agentNodeBindings.createdAt,
569
+ updatedAt: agentNodeBindings.updatedAt,
570
+ })
571
+ .from(agentNodeBindings)
572
+ .innerJoin(agents, eq(agentNodeBindings.agentId, agents.id))
573
+ .innerJoin(nodes, eq(agentNodeBindings.nodeId, nodes.id))
574
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.agentId, agent.id), eq(agentNodeBindings.nodeId, node.id)));
575
+ return serializeBinding(binding);
576
+ });
577
+ }
578
+ export async function unbindAgentFromNode(db, workspaceId, nodeName, agentName) {
579
+ return runAtomic(db, async (tx) => {
580
+ const node = await getNodeByName(tx, workspaceId, nodeName);
581
+ if (!node)
582
+ return false;
583
+ const [agent] = await tx
584
+ .select()
585
+ .from(agents)
586
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, agentName)));
587
+ if (!agent)
588
+ return false;
589
+ const updated = await tx
590
+ .update(agentNodeBindings)
591
+ .set({ status: 'inactive', updatedAt: new Date() })
592
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.nodeId, node.id), eq(agentNodeBindings.agentId, agent.id), eq(agentNodeBindings.status, 'active')))
593
+ .returning({ id: agentNodeBindings.id });
594
+ if (updated.length === 0)
595
+ return false;
596
+ await releaseNodeAgentSlots(tx, workspaceId, [node.id]);
597
+ if (agent.locationNodeId === node.id) {
598
+ await ensureDirectNodeForAgentInTx(tx, workspaceId, agent, { force: true });
599
+ }
600
+ return true;
601
+ });
602
+ }
603
+ export async function listNodeAgents(db, workspaceId, nodeName) {
604
+ const node = await getNodeByName(db, workspaceId, nodeName);
605
+ if (!node)
606
+ return null;
607
+ const rows = await db
608
+ .select({
609
+ id: agentNodeBindings.id,
610
+ agentId: agentNodeBindings.agentId,
611
+ agentName: agents.name,
612
+ nodeId: agentNodeBindings.nodeId,
613
+ nodeName: nodes.name,
614
+ nodeKind: nodes.kind,
615
+ status: agentNodeBindings.status,
616
+ sessionRef: agentNodeBindings.sessionRef,
617
+ priority: agentNodeBindings.priority,
618
+ createdAt: agentNodeBindings.createdAt,
619
+ updatedAt: agentNodeBindings.updatedAt,
620
+ })
621
+ .from(agentNodeBindings)
622
+ .innerJoin(agents, eq(agentNodeBindings.agentId, agents.id))
623
+ .innerJoin(nodes, eq(agentNodeBindings.nodeId, nodes.id))
624
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.nodeId, node.id), eq(agentNodeBindings.status, 'active')));
625
+ return rows.map(serializeBinding);
626
+ }
235
627
  export async function registerAgentViaNode(db, workspaceId, nodeId, message) {
236
628
  return runAtomic(db, async (tx) => {
629
+ const [node] = await tx
630
+ .select()
631
+ .from(nodes)
632
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
633
+ if (!node)
634
+ throw codedError(`Node "${nodeId}" not found`, 'node_not_found', 404);
237
635
  const token = `at_live_${randomHex(16)}`;
238
636
  const tokenHash = await sha256Hex(token);
239
637
  const now = new Date().toISOString();
@@ -280,6 +678,26 @@ export async function registerAgentViaNode(db, workspaceId, nodeId, message) {
280
678
  throw codedError(`Agent "${message.name}" already has an active location`, 'agent_location_conflict', 409);
281
679
  }
282
680
  await autoJoinGeneral(tx, workspaceId, result.id);
681
+ const activeNodeIds = await activeBindingNodeIdsForAgent(tx, workspaceId, result.id);
682
+ const targetWasActive = activeNodeIds.includes(nodeId);
683
+ let reservedTargetSlot = false;
684
+ try {
685
+ if (!targetWasActive) {
686
+ await reserveNodeAgentSlot(tx, workspaceId, node);
687
+ reservedTargetSlot = true;
688
+ }
689
+ await upsertAgentNodeBinding(tx, workspaceId, result, nodeId, {
690
+ sessionRef: message.session_ref ?? null,
691
+ deactivateExisting: true,
692
+ });
693
+ await releaseNodeAgentSlots(tx, workspaceId, activeNodeIds.filter((activeNodeId) => activeNodeId !== nodeId));
694
+ }
695
+ catch (err) {
696
+ if (reservedTargetSlot) {
697
+ await releaseNodeAgentSlots(tx, workspaceId, [nodeId]);
698
+ }
699
+ throw err;
700
+ }
283
701
  return {
284
702
  agent_id: result.id,
285
703
  name: result.name,
@@ -307,12 +725,33 @@ export async function deregisterAgentViaNode(db, workspaceId, nodeId, message) {
307
725
  .set({ status: 'offline', lastSeen: new Date() })
308
726
  .where(and(...conditions))
309
727
  .returning();
728
+ if (updated) {
729
+ const activeNodeIds = await activeBindingNodeIdsForAgent(db, workspaceId, updated.id);
730
+ await db
731
+ .update(agentNodeBindings)
732
+ .set({ status: 'inactive', updatedAt: new Date() })
733
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.nodeId, nodeId), eq(agentNodeBindings.agentId, updated.id), eq(agentNodeBindings.status, 'active')));
734
+ await releaseNodeAgentSlots(db, workspaceId, [nodeId]);
735
+ await ensureDirectNodeForAgent(db, workspaceId, updated, { force: true });
736
+ await db
737
+ .update(agents)
738
+ .set({ status: 'offline', lastSeen: new Date() })
739
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, updated.id)));
740
+ await markDirectNodeOfflineForAgent(db, workspaceId, updated.id);
741
+ await releaseNodeAgentSlots(db, workspaceId, activeNodeIds.filter((activeNodeId) => activeNodeId !== nodeId));
742
+ }
310
743
  return updated ?? null;
311
744
  }
312
745
  export async function reconcileInventory(db, registry, workspaceId, nodeId, inventoryAgents, completionDeps) {
313
746
  const names = new Set(inventoryAgents.map((agent) => agent.name));
314
747
  const liveInvocationIds = new Set(inventoryAgents.flatMap((agent) => (agent.invocation_id ? [agent.invocation_id] : [])));
315
748
  let completedInvocations = 0;
749
+ const [node] = await db
750
+ .select()
751
+ .from(nodes)
752
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
753
+ if (!node)
754
+ throw codedError(`Node "${nodeId}" not found`, 'node_not_found', 404);
316
755
  // Pre-validate every item against the current state BEFORE mutating anything,
317
756
  // so a conflict on a later item can't leave earlier items partially
318
757
  // reconciled (the control handler turns a throw into an error reply). Existing
@@ -349,17 +788,37 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, inve
349
788
  for (const item of inventoryAgents) {
350
789
  const existing = existingByName.get(item.name);
351
790
  if (existing) {
352
- await db
353
- .update(agents)
354
- .set({
355
- status: 'active',
356
- lastSeen: new Date(),
357
- locationType: 'via_node',
358
- locationNodeId: nodeId,
359
- originNodeId: existing.originNodeId ?? nodeId,
360
- sessionRef: item.session_ref ?? existing.sessionRef,
361
- })
362
- .where(eq(agents.id, existing.id));
791
+ const activeNodeIds = await activeBindingNodeIdsForAgent(db, workspaceId, existing.id);
792
+ const targetWasActive = activeNodeIds.includes(nodeId);
793
+ let reservedTargetSlot = false;
794
+ try {
795
+ if (!targetWasActive) {
796
+ await reserveNodeAgentSlot(db, workspaceId, node);
797
+ reservedTargetSlot = true;
798
+ }
799
+ await db
800
+ .update(agents)
801
+ .set({
802
+ status: 'active',
803
+ lastSeen: new Date(),
804
+ locationType: 'via_node',
805
+ locationNodeId: nodeId,
806
+ originNodeId: existing.originNodeId ?? nodeId,
807
+ sessionRef: item.session_ref ?? existing.sessionRef,
808
+ })
809
+ .where(eq(agents.id, existing.id));
810
+ await upsertAgentNodeBinding(db, workspaceId, existing, nodeId, {
811
+ sessionRef: item.session_ref ?? existing.sessionRef,
812
+ deactivateExisting: true,
813
+ });
814
+ await releaseNodeAgentSlots(db, workspaceId, activeNodeIds.filter((activeNodeId) => activeNodeId !== nodeId));
815
+ }
816
+ catch (err) {
817
+ if (reservedTargetSlot) {
818
+ await releaseNodeAgentSlots(db, workspaceId, [nodeId]);
819
+ }
820
+ throw err;
821
+ }
363
822
  }
364
823
  if (!item.invocation_id)
365
824
  continue;