@relaycast/engine 4.2.0 → 5.0.1

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 (255) 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 +9 -1
  9. package/dist/adapters/node/realtime.d.ts.map +1 -1
  10. package/dist/adapters/node/realtime.js +72 -10
  11. package/dist/adapters/node/realtime.js.map +1 -1
  12. package/dist/auth/index.d.ts.map +1 -1
  13. package/dist/auth/index.js +31 -9
  14. package/dist/auth/index.js.map +1 -1
  15. package/dist/auth/tokenKind.d.ts +40 -0
  16. package/dist/auth/tokenKind.d.ts.map +1 -0
  17. package/dist/auth/tokenKind.js +89 -0
  18. package/dist/auth/tokenKind.js.map +1 -0
  19. package/dist/bin/serve.js +2 -4
  20. package/dist/bin/serve.js.map +1 -1
  21. package/dist/db/migrations/0021_node_delivery_contracts.sql +70 -0
  22. package/dist/db/migrations/0022_http_push_redrive.sql +13 -0
  23. package/dist/db/migrations/0023_direct_ws_nodes.sql +124 -0
  24. package/dist/db/migrations/0024_binding_workspace_constraints.sql +109 -0
  25. package/dist/db/migrations/0025_node_kind_role_adapter.sql +45 -0
  26. package/dist/db/migrations/0026_observer_tokens.sql +32 -0
  27. package/dist/db/schema.d.ts +727 -59
  28. package/dist/db/schema.d.ts.map +1 -1
  29. package/dist/db/schema.js +78 -2
  30. package/dist/db/schema.js.map +1 -1
  31. package/dist/engine/action.d.ts +2 -9
  32. package/dist/engine/action.d.ts.map +1 -1
  33. package/dist/engine/action.js +182 -14
  34. package/dist/engine/action.js.map +1 -1
  35. package/dist/engine/activity.d.ts +6 -0
  36. package/dist/engine/activity.d.ts.map +1 -1
  37. package/dist/engine/activity.js +9 -2
  38. package/dist/engine/activity.js.map +1 -1
  39. package/dist/engine/agent.d.ts +0 -33
  40. package/dist/engine/agent.d.ts.map +1 -1
  41. package/dist/engine/agent.js +4 -153
  42. package/dist/engine/agent.js.map +1 -1
  43. package/dist/engine/attachments.d.ts +6 -0
  44. package/dist/engine/attachments.d.ts.map +1 -1
  45. package/dist/engine/attachments.js +37 -0
  46. package/dist/engine/attachments.js.map +1 -1
  47. package/dist/engine/console.d.ts +78 -1
  48. package/dist/engine/console.d.ts.map +1 -1
  49. package/dist/engine/console.js +175 -19
  50. package/dist/engine/console.js.map +1 -1
  51. package/dist/engine/delivery.d.ts +26 -0
  52. package/dist/engine/delivery.d.ts.map +1 -1
  53. package/dist/engine/delivery.js +194 -74
  54. package/dist/engine/delivery.js.map +1 -1
  55. package/dist/engine/deliveryWire.d.ts +4 -0
  56. package/dist/engine/deliveryWire.d.ts.map +1 -1
  57. package/dist/engine/deliveryWire.js +2 -0
  58. package/dist/engine/deliveryWire.js.map +1 -1
  59. package/dist/engine/deliveryWrites.d.ts +6 -0
  60. package/dist/engine/deliveryWrites.d.ts.map +1 -1
  61. package/dist/engine/deliveryWrites.js +58 -13
  62. package/dist/engine/deliveryWrites.js.map +1 -1
  63. package/dist/engine/dm.d.ts.map +1 -1
  64. package/dist/engine/dm.js +11 -33
  65. package/dist/engine/dm.js.map +1 -1
  66. package/dist/engine/dmAll.d.ts.map +1 -1
  67. package/dist/engine/dmAll.js +2 -0
  68. package/dist/engine/dmAll.js.map +1 -1
  69. package/dist/engine/groupDm.d.ts +2 -3
  70. package/dist/engine/groupDm.d.ts.map +1 -1
  71. package/dist/engine/groupDm.js +8 -26
  72. package/dist/engine/groupDm.js.map +1 -1
  73. package/dist/engine/invocationCompletion.d.ts +1 -1
  74. package/dist/engine/invocationCompletion.d.ts.map +1 -1
  75. package/dist/engine/invocationCompletion.js +10 -18
  76. package/dist/engine/invocationCompletion.js.map +1 -1
  77. package/dist/engine/message.d.ts.map +1 -1
  78. package/dist/engine/message.js +16 -44
  79. package/dist/engine/message.js.map +1 -1
  80. package/dist/engine/node.d.ts +97 -0
  81. package/dist/engine/node.d.ts.map +1 -1
  82. package/dist/engine/node.js +555 -27
  83. package/dist/engine/node.js.map +1 -1
  84. package/dist/engine/nodeContext.d.ts +22 -0
  85. package/dist/engine/nodeContext.d.ts.map +1 -0
  86. package/dist/engine/nodeContext.js +80 -0
  87. package/dist/engine/nodeContext.js.map +1 -0
  88. package/dist/engine/nodeDeliver.d.ts +23 -0
  89. package/dist/engine/nodeDeliver.d.ts.map +1 -0
  90. package/dist/engine/nodeDeliver.js +81 -0
  91. package/dist/engine/nodeDeliver.js.map +1 -0
  92. package/dist/engine/observerToken.d.ts +110 -0
  93. package/dist/engine/observerToken.d.ts.map +1 -0
  94. package/dist/engine/observerToken.js +528 -0
  95. package/dist/engine/observerToken.js.map +1 -0
  96. package/dist/engine/search.d.ts +4 -0
  97. package/dist/engine/search.d.ts.map +1 -1
  98. package/dist/engine/search.js +6 -1
  99. package/dist/engine/search.js.map +1 -1
  100. package/dist/engine/thread.d.ts +4 -3
  101. package/dist/engine/thread.d.ts.map +1 -1
  102. package/dist/engine/thread.js +45 -17
  103. package/dist/engine/thread.js.map +1 -1
  104. package/dist/engine/wsAuth.d.ts +32 -0
  105. package/dist/engine/wsAuth.d.ts.map +1 -0
  106. package/dist/engine/wsAuth.js +65 -0
  107. package/dist/engine/wsAuth.js.map +1 -0
  108. package/dist/engine/wsTransform.d.ts.map +1 -1
  109. package/dist/engine/wsTransform.js +52 -36
  110. package/dist/engine/wsTransform.js.map +1 -1
  111. package/dist/engine.d.ts.map +1 -1
  112. package/dist/engine.js +37 -86
  113. package/dist/engine.js.map +1 -1
  114. package/dist/entrypoints/node.d.ts.map +1 -1
  115. package/dist/entrypoints/node.js +20 -57
  116. package/dist/entrypoints/node.js.map +1 -1
  117. package/dist/env.d.ts +3 -1
  118. package/dist/env.d.ts.map +1 -1
  119. package/dist/index.d.ts +1 -0
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/index.js +5 -0
  122. package/dist/index.js.map +1 -1
  123. package/dist/lib/httpError.d.ts +5 -1
  124. package/dist/lib/httpError.d.ts.map +1 -1
  125. package/dist/lib/httpError.js +5 -2
  126. package/dist/lib/httpError.js.map +1 -1
  127. package/dist/lib/httpQuery.d.ts +28 -0
  128. package/dist/lib/httpQuery.d.ts.map +1 -0
  129. package/dist/lib/httpQuery.js +32 -0
  130. package/dist/lib/httpQuery.js.map +1 -0
  131. package/dist/lib/httpResponse.d.ts +15 -2
  132. package/dist/lib/httpResponse.d.ts.map +1 -1
  133. package/dist/lib/httpResponse.js +35 -7
  134. package/dist/lib/httpResponse.js.map +1 -1
  135. package/dist/middleware/auth.d.ts +8 -0
  136. package/dist/middleware/auth.d.ts.map +1 -1
  137. package/dist/middleware/auth.js +65 -2
  138. package/dist/middleware/auth.js.map +1 -1
  139. package/dist/middleware/idempotency.d.ts +30 -0
  140. package/dist/middleware/idempotency.d.ts.map +1 -1
  141. package/dist/middleware/idempotency.js +14 -0
  142. package/dist/middleware/idempotency.js.map +1 -1
  143. package/dist/middleware/logger.d.ts.map +1 -1
  144. package/dist/middleware/logger.js +4 -4
  145. package/dist/middleware/logger.js.map +1 -1
  146. package/dist/middleware/planLimits.d.ts.map +1 -1
  147. package/dist/middleware/planLimits.js +11 -9
  148. package/dist/middleware/planLimits.js.map +1 -1
  149. package/dist/middleware/rateLimit.d.ts.map +1 -1
  150. package/dist/middleware/rateLimit.js +15 -8
  151. package/dist/middleware/rateLimit.js.map +1 -1
  152. package/dist/middleware/usageTracker.d.ts.map +1 -1
  153. package/dist/middleware/usageTracker.js +2 -1
  154. package/dist/middleware/usageTracker.js.map +1 -1
  155. package/dist/ports/auth.d.ts +6 -2
  156. package/dist/ports/auth.d.ts.map +1 -1
  157. package/dist/ports/entitlements.d.ts +3 -1
  158. package/dist/ports/entitlements.d.ts.map +1 -1
  159. package/dist/ports/index.d.ts +0 -14
  160. package/dist/ports/index.d.ts.map +1 -1
  161. package/dist/ports/realtime.d.ts +4 -1
  162. package/dist/ports/realtime.d.ts.map +1 -1
  163. package/dist/providers/static-entitlements.js +4 -4
  164. package/dist/providers/static-entitlements.js.map +1 -1
  165. package/dist/routes/a2a.d.ts.map +1 -1
  166. package/dist/routes/a2a.js +41 -83
  167. package/dist/routes/a2a.js.map +1 -1
  168. package/dist/routes/action.d.ts.map +1 -1
  169. package/dist/routes/action.js +45 -42
  170. package/dist/routes/action.js.map +1 -1
  171. package/dist/routes/agent.d.ts.map +1 -1
  172. package/dist/routes/agent.js +172 -120
  173. package/dist/routes/agent.js.map +1 -1
  174. package/dist/routes/certify.d.ts.map +1 -1
  175. package/dist/routes/certify.js +21 -35
  176. package/dist/routes/certify.js.map +1 -1
  177. package/dist/routes/channel.d.ts.map +1 -1
  178. package/dist/routes/channel.js +69 -81
  179. package/dist/routes/channel.js.map +1 -1
  180. package/dist/routes/console.d.ts.map +1 -1
  181. package/dist/routes/console.js +60 -40
  182. package/dist/routes/console.js.map +1 -1
  183. package/dist/routes/delivery.d.ts.map +1 -1
  184. package/dist/routes/delivery.js +22 -54
  185. package/dist/routes/delivery.js.map +1 -1
  186. package/dist/routes/deliveryRouting.d.ts +6 -0
  187. package/dist/routes/deliveryRouting.d.ts.map +1 -1
  188. package/dist/routes/deliveryRouting.js +297 -34
  189. package/dist/routes/deliveryRouting.js.map +1 -1
  190. package/dist/routes/directory.d.ts.map +1 -1
  191. package/dist/routes/directory.js +47 -63
  192. package/dist/routes/directory.js.map +1 -1
  193. package/dist/routes/dm.d.ts.map +1 -1
  194. package/dist/routes/dm.js +21 -42
  195. package/dist/routes/dm.js.map +1 -1
  196. package/dist/routes/fanout.d.ts +2 -1
  197. package/dist/routes/fanout.d.ts.map +1 -1
  198. package/dist/routes/fanout.js +38 -20
  199. package/dist/routes/fanout.js.map +1 -1
  200. package/dist/routes/file.d.ts.map +1 -1
  201. package/dist/routes/file.js +48 -44
  202. package/dist/routes/file.js.map +1 -1
  203. package/dist/routes/groupDm.d.ts.map +1 -1
  204. package/dist/routes/groupDm.js +23 -48
  205. package/dist/routes/groupDm.js.map +1 -1
  206. package/dist/routes/inboundWebhook.d.ts.map +1 -1
  207. package/dist/routes/inboundWebhook.js +17 -35
  208. package/dist/routes/inboundWebhook.js.map +1 -1
  209. package/dist/routes/inbox.d.ts.map +1 -1
  210. package/dist/routes/inbox.js +2 -1
  211. package/dist/routes/inbox.js.map +1 -1
  212. package/dist/routes/message.d.ts.map +1 -1
  213. package/dist/routes/message.js +60 -53
  214. package/dist/routes/message.js.map +1 -1
  215. package/dist/routes/node.d.ts.map +1 -1
  216. package/dist/routes/node.js +173 -10
  217. package/dist/routes/node.js.map +1 -1
  218. package/dist/routes/observerToken.d.ts +4 -0
  219. package/dist/routes/observerToken.d.ts.map +1 -0
  220. package/dist/routes/observerToken.js +105 -0
  221. package/dist/routes/observerToken.js.map +1 -0
  222. package/dist/routes/presence.d.ts.map +1 -1
  223. package/dist/routes/presence.js +10 -6
  224. package/dist/routes/presence.js.map +1 -1
  225. package/dist/routes/reaction.d.ts.map +1 -1
  226. package/dist/routes/reaction.js +69 -28
  227. package/dist/routes/reaction.js.map +1 -1
  228. package/dist/routes/receipt.d.ts.map +1 -1
  229. package/dist/routes/receipt.js +20 -3
  230. package/dist/routes/receipt.js.map +1 -1
  231. package/dist/routes/routing.d.ts.map +1 -1
  232. package/dist/routes/routing.js +38 -51
  233. package/dist/routes/routing.js.map +1 -1
  234. package/dist/routes/search.d.ts.map +1 -1
  235. package/dist/routes/search.js +21 -15
  236. package/dist/routes/search.js.map +1 -1
  237. package/dist/routes/thread.d.ts.map +1 -1
  238. package/dist/routes/thread.js +36 -37
  239. package/dist/routes/thread.js.map +1 -1
  240. package/dist/routes/workspace.d.ts.map +1 -1
  241. package/dist/routes/workspace.js +57 -225
  242. package/dist/routes/workspace.js.map +1 -1
  243. package/package.json +3 -3
  244. package/dist/lib/fleetNodes.d.ts +0 -9
  245. package/dist/lib/fleetNodes.d.ts.map +0 -1
  246. package/dist/lib/fleetNodes.js +0 -66
  247. package/dist/lib/fleetNodes.js.map +0 -1
  248. package/dist/lib/workspaceStream.d.ts +0 -9
  249. package/dist/lib/workspaceStream.d.ts.map +0 -1
  250. package/dist/lib/workspaceStream.js +0 -53
  251. package/dist/lib/workspaceStream.js.map +0 -1
  252. package/dist/middleware/fleetNodes.d.ts +0 -10
  253. package/dist/middleware/fleetNodes.d.ts.map +0 -1
  254. package/dist/middleware/fleetNodes.js +0 -18
  255. 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,15 @@ 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
+ }
18
+ function isImplicitDirectLocation(agent) {
19
+ return agent.locationNodeId === directNodeIdForAgent(agent.id);
20
+ }
12
21
  function capabilityName(capability) {
13
22
  if (typeof capability === 'string')
14
23
  return capability;
@@ -25,6 +34,10 @@ function publicNode(row) {
25
34
  return {
26
35
  id: row.id,
27
36
  name: row.name,
37
+ kind: row.kind,
38
+ role: row.role,
39
+ delivery_adapter: row.deliveryAdapter,
40
+ delivery: redactDeliveryConfig(row.deliveryConfig),
28
41
  capabilities: row.capabilities,
29
42
  tags: row.tags,
30
43
  version: row.version,
@@ -38,6 +51,65 @@ function publicNode(row) {
38
51
  created_at: row.createdAt.toISOString(),
39
52
  };
40
53
  }
54
+ const SENSITIVE_DELIVERY_KEY = /api[_-]?key|authorization|cookie|credential|headers|password|private[_-]?key|secret|token/i;
55
+ function redactDeliveryValue(key, value) {
56
+ if (key && /^headers$/i.test(key) && value && typeof value === 'object' && !Array.isArray(value)) {
57
+ return Object.fromEntries(Object.keys(value).map((name) => [name, '[redacted]']));
58
+ }
59
+ if (key && SENSITIVE_DELIVERY_KEY.test(key)) {
60
+ return '[redacted]';
61
+ }
62
+ if (Array.isArray(value)) {
63
+ return value.map((item) => redactDeliveryValue(null, item));
64
+ }
65
+ if (value && typeof value === 'object') {
66
+ return Object.fromEntries(Object.entries(value).map(([childKey, childValue]) => [
67
+ childKey,
68
+ redactDeliveryValue(childKey, childValue),
69
+ ]));
70
+ }
71
+ return value;
72
+ }
73
+ function redactDeliveryConfig(config) {
74
+ if (!config)
75
+ return null;
76
+ return redactDeliveryValue(null, config);
77
+ }
78
+ function normalizeLegacyNodeShape(kind, role) {
79
+ if (kind === 'fleet_ws')
80
+ return { kind: 'ws', role: 'broker' };
81
+ if (kind === 'direct_ws')
82
+ return { kind: 'ws', role: 'direct' };
83
+ if (kind === 'http_push')
84
+ return { kind: 'http_push', role: role === 'broker' ? 'broker' : 'direct' };
85
+ if (kind === 'poll')
86
+ return { kind: 'poll', role: role === 'broker' ? 'broker' : 'direct' };
87
+ return { kind: 'ws', role: role === 'direct' ? 'direct' : 'broker' };
88
+ }
89
+ function defaultAdapter(kind, deliveryConfig) {
90
+ if (kind === 'ws')
91
+ return 'ws.node.v1';
92
+ if (kind === 'poll')
93
+ return 'poll.v1';
94
+ const auth = deliveryConfig?.auth;
95
+ const authType = auth && typeof auth === 'object' && !Array.isArray(auth)
96
+ ? auth.type
97
+ : undefined;
98
+ if (authType === 'hmac_sha256')
99
+ return 'http.hmac.v1';
100
+ if (authType === 'bearer')
101
+ return 'http.bearer.v1';
102
+ if (authType === 'static_headers')
103
+ return 'http.static_headers.v1';
104
+ return 'http.basic.v1';
105
+ }
106
+ function normalizeLegacyAdapter(adapter) {
107
+ if (!adapter)
108
+ return undefined;
109
+ if (adapter === 'fleet.ws.v1' || adapter === 'direct.ws.v1')
110
+ return 'ws.node.v1';
111
+ return adapter;
112
+ }
41
113
  async function ensureCapabilityActions(db, workspaceId, nodeId, capabilities) {
42
114
  for (const capability of capabilities) {
43
115
  const name = capabilityName(capability);
@@ -73,13 +145,36 @@ export async function createNodeToken(db, workspaceId, data) {
73
145
  const tokenHash = await sha256Hex(token);
74
146
  const existing = await getNodeByName(db, workspaceId, data.name);
75
147
  const now = new Date();
148
+ const existingShape = existing ? normalizeLegacyNodeShape(existing.kind, existing.role) : null;
149
+ const normalized = normalizeLegacyNodeShape(data.kind ?? existing?.kind ?? 'ws', data.role ?? existing?.role ?? (data.max_agents !== undefined && data.max_agents > 1 ? 'broker' : undefined));
150
+ const kind = normalized.kind;
151
+ const role = normalized.role;
152
+ // When the node shape (transport or role) changes on update, recompute the
153
+ // delivery adapter and capacity instead of reusing the stale values: rotating
154
+ // an http_push node to ws must not keep an http.* adapter, and switching a
155
+ // broker to direct must not retain a capacity > 1.
156
+ const shapeChanged = !!existingShape && (existingShape.kind !== kind || existingShape.role !== role);
157
+ const deliveryConfig = data.delivery === undefined ? existing?.deliveryConfig ?? null : data.delivery;
158
+ const deliveryAdapter = data.delivery_adapter
159
+ ?? (!shapeChanged && data.delivery === undefined ? normalizeLegacyAdapter(existing?.deliveryAdapter) : undefined)
160
+ ?? defaultAdapter(kind, deliveryConfig);
161
+ const maxAgents = role === 'direct'
162
+ ? (data.max_agents ?? 1)
163
+ : (data.max_agents ?? existing?.maxAgents ?? 0);
164
+ if (role === 'direct' && maxAgents !== 1) {
165
+ throw codedError('Direct nodes can bind at most one agent', 'direct_node_capacity_exceeded', 400);
166
+ }
76
167
  if (existing) {
77
168
  const [updated] = await db
78
169
  .update(nodes)
79
170
  .set({
80
171
  tokenHash,
172
+ kind,
173
+ role,
174
+ deliveryAdapter,
175
+ deliveryConfig,
81
176
  capabilities: normalizeCapabilities(data.capabilities ?? existing.capabilities),
82
- maxAgents: data.max_agents ?? existing.maxAgents,
177
+ maxAgents,
83
178
  tags: data.tags ?? existing.tags,
84
179
  version: data.version ?? existing.version,
85
180
  status: 'offline',
@@ -96,8 +191,12 @@ export async function createNodeToken(db, workspaceId, data) {
96
191
  workspaceId,
97
192
  name: data.name,
98
193
  tokenHash,
194
+ kind,
195
+ role,
196
+ deliveryAdapter,
197
+ deliveryConfig,
99
198
  capabilities: normalizeCapabilities(data.capabilities ?? []),
100
- maxAgents: data.max_agents ?? 0,
199
+ maxAgents,
101
200
  tags: data.tags ?? [],
102
201
  version: data.version ?? 'unknown',
103
202
  status: 'offline',
@@ -126,6 +225,13 @@ export async function registerNode(db, workspaceId, authenticatedNodeId, message
126
225
  throw codedError('node_id does not match the authenticated node token', 'node_id_mismatch', 403);
127
226
  }
128
227
  const now = new Date();
228
+ const [existing] = await db
229
+ .select()
230
+ .from(nodes)
231
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, authenticatedNodeId)));
232
+ if (!existing) {
233
+ throw codedError('Node token is not enrolled in this workspace', 'node_not_found', 404);
234
+ }
129
235
  const [existingByName] = await db
130
236
  .select()
131
237
  .from(nodes)
@@ -133,24 +239,31 @@ export async function registerNode(db, workspaceId, authenticatedNodeId, message
133
239
  if (existingByName && existingByName.id !== authenticatedNodeId) {
134
240
  throw codedError(`Node name "${message.name}" is already enrolled`, 'node_name_conflict', 409);
135
241
  }
242
+ const role = existing.role === 'direct' ? 'direct' : 'broker';
243
+ const capabilities = role === 'direct' ? [] : normalizeCapabilities(message.capabilities);
244
+ const maxAgents = role === 'direct' ? 1 : message.max_agents;
136
245
  const [updated] = await db
137
246
  .update(nodes)
138
247
  .set({
139
248
  name: message.name,
140
- capabilities: message.capabilities,
141
- maxAgents: message.max_agents,
142
- tags: message.tags,
249
+ capabilities,
250
+ kind: 'ws',
251
+ role,
252
+ deliveryAdapter: 'ws.node.v1',
253
+ deliveryConfig: role === 'direct' ? existing.deliveryConfig : null,
254
+ maxAgents,
255
+ activeAgents: role === 'direct' ? 1 : existing.activeAgents,
256
+ tags: role === 'direct' ? existing.tags : message.tags,
143
257
  version: message.version,
144
258
  status: 'online',
145
- handlersLive: message.capabilities.length > 0,
259
+ handlersLive: role === 'broker' && capabilities.length > 0,
146
260
  lastHeartbeatAt: now,
147
261
  })
148
262
  .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, authenticatedNodeId)))
149
263
  .returning();
150
- if (!updated) {
151
- throw codedError('Node token is not enrolled in this workspace', 'node_not_found', 404);
264
+ if (role === 'broker') {
265
+ await ensureCapabilityActions(db, workspaceId, updated.id, capabilities);
152
266
  }
153
- await ensureCapabilityActions(db, workspaceId, updated.id, message.capabilities);
154
267
  return publicNode(updated);
155
268
  }
156
269
  export async function heartbeatNode(db, workspaceId, nodeId, message) {
@@ -168,10 +281,21 @@ export async function heartbeatNode(db, workspaceId, nodeId, message) {
168
281
  if (message.name !== undefined)
169
282
  rosterUpdate.name = message.name;
170
283
  if (message.capabilities !== undefined) {
171
- rosterUpdate.capabilities = normalizeCapabilities(message.capabilities);
284
+ const [node] = await db
285
+ .select({ role: nodes.role })
286
+ .from(nodes)
287
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
288
+ if (node?.role !== 'direct') {
289
+ rosterUpdate.capabilities = normalizeCapabilities(message.capabilities);
290
+ }
291
+ }
292
+ if (message.max_agents !== undefined) {
293
+ const [node] = await db
294
+ .select({ role: nodes.role })
295
+ .from(nodes)
296
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
297
+ rosterUpdate.maxAgents = node?.role === 'direct' ? 1 : message.max_agents;
172
298
  }
173
- if (message.max_agents !== undefined)
174
- rosterUpdate.maxAgents = message.max_agents;
175
299
  if (message.version !== undefined)
176
300
  rosterUpdate.version = message.version;
177
301
  const [updated] = await db
@@ -186,7 +310,7 @@ export async function heartbeatNode(db, workspaceId, nodeId, message) {
186
310
  })
187
311
  .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
188
312
  .returning();
189
- if (updated && message.capabilities !== undefined) {
313
+ if (updated && updated.role !== 'direct' && message.capabilities !== undefined) {
190
314
  await ensureCapabilityActions(db, workspaceId, updated.id, message.capabilities);
191
315
  }
192
316
  return updated ? publicNode(updated) : null;
@@ -232,8 +356,345 @@ async function autoJoinGeneral(db, workspaceId, agentId) {
232
356
  }).onConflictDoNothing();
233
357
  }
234
358
  }
359
+ async function upsertAgentNodeBinding(db, workspaceId, agent, nodeId, opts = {}) {
360
+ if (opts.deactivateExisting ?? true) {
361
+ await db
362
+ .update(agentNodeBindings)
363
+ .set({ status: 'inactive', updatedAt: new Date() })
364
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.agentId, agent.id), eq(agentNodeBindings.status, 'active'), ne(agentNodeBindings.nodeId, nodeId)));
365
+ }
366
+ await db
367
+ .insert(agentNodeBindings)
368
+ .values({
369
+ id: `anb_${generateId()}`,
370
+ workspaceId,
371
+ agentId: agent.id,
372
+ nodeId,
373
+ status: 'active',
374
+ sessionRef: opts.sessionRef ?? null,
375
+ priority: opts.priority ?? 0,
376
+ updatedAt: new Date(),
377
+ })
378
+ .onConflictDoUpdate({
379
+ target: [agentNodeBindings.agentId, agentNodeBindings.nodeId],
380
+ set: {
381
+ status: 'active',
382
+ sessionRef: opts.sessionRef ?? null,
383
+ priority: opts.priority ?? 0,
384
+ updatedAt: new Date(),
385
+ },
386
+ });
387
+ await db
388
+ .update(agents)
389
+ .set({
390
+ locationType: 'via_node',
391
+ locationNodeId: nodeId,
392
+ sessionRef: opts.sessionRef ?? undefined,
393
+ status: 'active',
394
+ lastSeen: new Date(),
395
+ })
396
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, agent.id)));
397
+ }
398
+ async function activeBindingNodeIdsForAgent(db, workspaceId, agentId) {
399
+ const rows = await db
400
+ .select({ nodeId: agentNodeBindings.nodeId })
401
+ .from(agentNodeBindings)
402
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.agentId, agentId), eq(agentNodeBindings.status, 'active')));
403
+ return rows.map((row) => row.nodeId);
404
+ }
405
+ async function reserveNodeAgentSlot(db, workspaceId, node) {
406
+ const [reserved] = await db
407
+ .update(nodes)
408
+ .set({
409
+ activeAgents: sql `${nodes.activeAgents} + 1`,
410
+ })
411
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, node.id), or(eq(nodes.maxAgents, 0), sql `${nodes.activeAgents} < ${nodes.maxAgents}`)))
412
+ .returning({ id: nodes.id });
413
+ if (!reserved) {
414
+ throw codedError(`Node "${node.name}" is at capacity`, 'node_capacity_exceeded', 409);
415
+ }
416
+ }
417
+ async function releaseNodeAgentSlots(db, workspaceId, nodeIds) {
418
+ const uniqueNodeIds = [...new Set(nodeIds)];
419
+ if (uniqueNodeIds.length === 0)
420
+ return;
421
+ await db
422
+ .update(nodes)
423
+ .set({
424
+ activeAgents: sql `CASE WHEN ${nodes.activeAgents} > 0 THEN ${nodes.activeAgents} - 1 ELSE 0 END`,
425
+ })
426
+ .where(and(eq(nodes.workspaceId, workspaceId), inArray(nodes.id, uniqueNodeIds)));
427
+ }
428
+ export async function ensureDirectNodeForAgent(db, workspaceId, agent, opts = {}) {
429
+ return runAtomic(db, (tx) => ensureDirectNodeForAgentInTx(tx, workspaceId, agent, opts));
430
+ }
431
+ async function ensureDirectNodeForAgentInTx(db, workspaceId, agent, opts = {}) {
432
+ const activeNodeIds = await activeBindingNodeIdsForAgent(db, workspaceId, agent.id);
433
+ const nodeId = directNodeIdForAgent(agent.id);
434
+ const alreadyDirect = activeNodeIds.includes(nodeId);
435
+ const explicitNodeIds = activeNodeIds.filter((activeNodeId) => activeNodeId !== nodeId);
436
+ if (!opts.force && explicitNodeIds.length > 0) {
437
+ return null;
438
+ }
439
+ const now = new Date();
440
+ const [existingDirect] = await db
441
+ .select()
442
+ .from(nodes)
443
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
444
+ if (!opts.force && alreadyDirect && existingDirect && agent.locationNodeId === nodeId) {
445
+ const update = {
446
+ name: directNodeNameForAgent(agent.id),
447
+ kind: 'ws',
448
+ role: 'direct',
449
+ deliveryAdapter: 'ws.node.v1',
450
+ deliveryConfig: {
451
+ implicit: true,
452
+ agent_id: agent.id,
453
+ agent_name: agent.name,
454
+ },
455
+ capabilities: [],
456
+ maxAgents: 1,
457
+ activeAgents: 1,
458
+ tags: ['implicit', 'direct'],
459
+ version: 'implicit',
460
+ handlersLive: false,
461
+ load: 0,
462
+ };
463
+ if (opts.online) {
464
+ update.status = 'online';
465
+ update.lastHeartbeatAt = now;
466
+ }
467
+ const [updatedDirect] = await db
468
+ .update(nodes)
469
+ .set(update)
470
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
471
+ .returning();
472
+ return publicNode(updatedDirect ?? {
473
+ ...existingDirect,
474
+ ...update,
475
+ activeAgents: 1,
476
+ status: opts.online ? 'online' : existingDirect.status,
477
+ lastHeartbeatAt: opts.online ? now : existingDirect.lastHeartbeatAt,
478
+ });
479
+ }
480
+ let directNode = existingDirect;
481
+ if (!directNode) {
482
+ const tokenHash = await sha256Hex(`implicit_direct:${workspaceId}:${agent.id}:${randomHex(16)}`);
483
+ [directNode] = await db
484
+ .insert(nodes)
485
+ .values({
486
+ id: nodeId,
487
+ workspaceId,
488
+ name: directNodeNameForAgent(agent.id),
489
+ tokenHash,
490
+ kind: 'ws',
491
+ role: 'direct',
492
+ deliveryAdapter: 'ws.node.v1',
493
+ deliveryConfig: {
494
+ implicit: true,
495
+ agent_id: agent.id,
496
+ agent_name: agent.name,
497
+ },
498
+ capabilities: [],
499
+ maxAgents: 1,
500
+ activeAgents: 0,
501
+ tags: ['implicit', 'direct'],
502
+ version: 'implicit',
503
+ status: opts.online ? 'online' : 'offline',
504
+ handlersLive: false,
505
+ load: 0,
506
+ lastHeartbeatAt: opts.online ? now : null,
507
+ createdAt: now,
508
+ })
509
+ .returning();
510
+ }
511
+ else {
512
+ const update = {
513
+ name: directNodeNameForAgent(agent.id),
514
+ kind: 'ws',
515
+ role: 'direct',
516
+ deliveryAdapter: 'ws.node.v1',
517
+ deliveryConfig: {
518
+ implicit: true,
519
+ agent_id: agent.id,
520
+ agent_name: agent.name,
521
+ },
522
+ capabilities: [],
523
+ maxAgents: 1,
524
+ tags: ['implicit', 'direct'],
525
+ version: 'implicit',
526
+ handlersLive: false,
527
+ load: 0,
528
+ };
529
+ if (opts.online) {
530
+ update.status = 'online';
531
+ update.lastHeartbeatAt = now;
532
+ }
533
+ [directNode] = await db
534
+ .update(nodes)
535
+ .set(update)
536
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
537
+ .returning();
538
+ }
539
+ await upsertAgentNodeBinding(db, workspaceId, agent, nodeId, {
540
+ sessionRef: opts.sessionRef ?? null,
541
+ deactivateExisting: true,
542
+ });
543
+ await releaseNodeAgentSlots(db, workspaceId, explicitNodeIds);
544
+ await db
545
+ .update(nodes)
546
+ .set({
547
+ activeAgents: 1,
548
+ status: opts.online ? 'online' : directNode.status,
549
+ lastHeartbeatAt: opts.online ? now : directNode.lastHeartbeatAt,
550
+ })
551
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
552
+ return publicNode({
553
+ ...directNode,
554
+ activeAgents: 1,
555
+ status: opts.online ? 'online' : directNode.status,
556
+ lastHeartbeatAt: opts.online ? now : directNode.lastHeartbeatAt,
557
+ });
558
+ }
559
+ export async function markDirectNodeOfflineForAgent(db, workspaceId, agentId) {
560
+ const nodeId = directNodeIdForAgent(agentId);
561
+ await db
562
+ .update(nodes)
563
+ .set({
564
+ status: 'offline',
565
+ handlersLive: false,
566
+ load: 0,
567
+ lastHeartbeatAt: new Date(),
568
+ })
569
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
570
+ }
571
+ function serializeBinding(row) {
572
+ return {
573
+ id: row.id,
574
+ agent_id: row.agentId,
575
+ agent_name: row.agentName,
576
+ node_id: row.nodeId,
577
+ node_name: row.nodeName,
578
+ node_kind: row.nodeKind,
579
+ node_role: row.nodeRole,
580
+ status: row.status,
581
+ session_ref: row.sessionRef,
582
+ priority: row.priority,
583
+ created_at: row.createdAt.toISOString(),
584
+ updated_at: row.updatedAt?.toISOString() ?? null,
585
+ };
586
+ }
587
+ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts = {}) {
588
+ return runAtomic(db, async (tx) => {
589
+ const node = await getNodeByName(tx, workspaceId, nodeName);
590
+ if (!node)
591
+ throw codedError(`Node "${nodeName}" not found`, 'node_not_found', 404);
592
+ const [agent] = await tx
593
+ .select()
594
+ .from(agents)
595
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, agentName)));
596
+ if (!agent)
597
+ throw codedError(`Agent "${agentName}" not found`, 'agent_not_found', 404);
598
+ const activeNodeIds = await activeBindingNodeIdsForAgent(tx, workspaceId, agent.id);
599
+ const targetWasActive = activeNodeIds.includes(node.id);
600
+ let reservedTargetSlot = false;
601
+ try {
602
+ if (!targetWasActive) {
603
+ await reserveNodeAgentSlot(tx, workspaceId, node);
604
+ reservedTargetSlot = true;
605
+ }
606
+ await upsertAgentNodeBinding(tx, workspaceId, agent, node.id, {
607
+ sessionRef: opts.session_ref ?? null,
608
+ priority: opts.priority ?? 0,
609
+ });
610
+ await releaseNodeAgentSlots(tx, workspaceId, activeNodeIds.filter((nodeId) => nodeId !== node.id));
611
+ }
612
+ catch (err) {
613
+ if (reservedTargetSlot) {
614
+ await releaseNodeAgentSlots(tx, workspaceId, [node.id]);
615
+ }
616
+ throw err;
617
+ }
618
+ const [binding] = await tx
619
+ .select({
620
+ id: agentNodeBindings.id,
621
+ agentId: agentNodeBindings.agentId,
622
+ agentName: agents.name,
623
+ nodeId: agentNodeBindings.nodeId,
624
+ nodeName: nodes.name,
625
+ nodeKind: nodes.kind,
626
+ nodeRole: nodes.role,
627
+ status: agentNodeBindings.status,
628
+ sessionRef: agentNodeBindings.sessionRef,
629
+ priority: agentNodeBindings.priority,
630
+ createdAt: agentNodeBindings.createdAt,
631
+ updatedAt: agentNodeBindings.updatedAt,
632
+ })
633
+ .from(agentNodeBindings)
634
+ .innerJoin(agents, eq(agentNodeBindings.agentId, agents.id))
635
+ .innerJoin(nodes, eq(agentNodeBindings.nodeId, nodes.id))
636
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.agentId, agent.id), eq(agentNodeBindings.nodeId, node.id)));
637
+ return serializeBinding(binding);
638
+ });
639
+ }
640
+ export async function unbindAgentFromNode(db, workspaceId, nodeName, agentName) {
641
+ return runAtomic(db, async (tx) => {
642
+ const node = await getNodeByName(tx, workspaceId, nodeName);
643
+ if (!node)
644
+ return false;
645
+ const [agent] = await tx
646
+ .select()
647
+ .from(agents)
648
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, agentName)));
649
+ if (!agent)
650
+ return false;
651
+ const updated = await tx
652
+ .update(agentNodeBindings)
653
+ .set({ status: 'inactive', updatedAt: new Date() })
654
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.nodeId, node.id), eq(agentNodeBindings.agentId, agent.id), eq(agentNodeBindings.status, 'active')))
655
+ .returning({ id: agentNodeBindings.id });
656
+ if (updated.length === 0)
657
+ return false;
658
+ await releaseNodeAgentSlots(tx, workspaceId, [node.id]);
659
+ if (agent.locationNodeId === node.id) {
660
+ await ensureDirectNodeForAgentInTx(tx, workspaceId, agent, { force: true });
661
+ }
662
+ return true;
663
+ });
664
+ }
665
+ export async function listNodeAgents(db, workspaceId, nodeName) {
666
+ const node = await getNodeByName(db, workspaceId, nodeName);
667
+ if (!node)
668
+ return null;
669
+ const rows = await db
670
+ .select({
671
+ id: agentNodeBindings.id,
672
+ agentId: agentNodeBindings.agentId,
673
+ agentName: agents.name,
674
+ nodeId: agentNodeBindings.nodeId,
675
+ nodeName: nodes.name,
676
+ nodeKind: nodes.kind,
677
+ nodeRole: nodes.role,
678
+ status: agentNodeBindings.status,
679
+ sessionRef: agentNodeBindings.sessionRef,
680
+ priority: agentNodeBindings.priority,
681
+ createdAt: agentNodeBindings.createdAt,
682
+ updatedAt: agentNodeBindings.updatedAt,
683
+ })
684
+ .from(agentNodeBindings)
685
+ .innerJoin(agents, eq(agentNodeBindings.agentId, agents.id))
686
+ .innerJoin(nodes, eq(agentNodeBindings.nodeId, nodes.id))
687
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.nodeId, node.id), eq(agentNodeBindings.status, 'active')));
688
+ return rows.map(serializeBinding);
689
+ }
235
690
  export async function registerAgentViaNode(db, workspaceId, nodeId, message) {
236
691
  return runAtomic(db, async (tx) => {
692
+ const [node] = await tx
693
+ .select()
694
+ .from(nodes)
695
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
696
+ if (!node)
697
+ throw codedError(`Node "${nodeId}" not found`, 'node_not_found', 404);
237
698
  const token = `at_live_${randomHex(16)}`;
238
699
  const tokenHash = await sha256Hex(token);
239
700
  const now = new Date().toISOString();
@@ -273,13 +734,33 @@ export async function registerAgentViaNode(db, workspaceId, nodeId, message) {
273
734
  resumable: message.resumable ?? false,
274
735
  sessionRef: message.session_ref ?? null,
275
736
  },
276
- setWhere: or(ne(agents.status, 'active'), and(eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId))),
737
+ setWhere: or(ne(agents.status, 'active'), and(eq(agents.locationType, 'via_node'), or(eq(agents.locationNodeId, nodeId), sql `${agents.locationNodeId} = 'node_direct_' || ${agents.id}`))),
277
738
  })
278
739
  .returning();
279
740
  if (!result) {
280
741
  throw codedError(`Agent "${message.name}" already has an active location`, 'agent_location_conflict', 409);
281
742
  }
282
743
  await autoJoinGeneral(tx, workspaceId, result.id);
744
+ const activeNodeIds = await activeBindingNodeIdsForAgent(tx, workspaceId, result.id);
745
+ const targetWasActive = activeNodeIds.includes(nodeId);
746
+ let reservedTargetSlot = false;
747
+ try {
748
+ if (!targetWasActive) {
749
+ await reserveNodeAgentSlot(tx, workspaceId, node);
750
+ reservedTargetSlot = true;
751
+ }
752
+ await upsertAgentNodeBinding(tx, workspaceId, result, nodeId, {
753
+ sessionRef: message.session_ref ?? null,
754
+ deactivateExisting: true,
755
+ });
756
+ await releaseNodeAgentSlots(tx, workspaceId, activeNodeIds.filter((activeNodeId) => activeNodeId !== nodeId));
757
+ }
758
+ catch (err) {
759
+ if (reservedTargetSlot) {
760
+ await releaseNodeAgentSlots(tx, workspaceId, [nodeId]);
761
+ }
762
+ throw err;
763
+ }
283
764
  return {
284
765
  agent_id: result.id,
285
766
  name: result.name,
@@ -307,12 +788,33 @@ export async function deregisterAgentViaNode(db, workspaceId, nodeId, message) {
307
788
  .set({ status: 'offline', lastSeen: new Date() })
308
789
  .where(and(...conditions))
309
790
  .returning();
791
+ if (updated) {
792
+ const activeNodeIds = await activeBindingNodeIdsForAgent(db, workspaceId, updated.id);
793
+ await db
794
+ .update(agentNodeBindings)
795
+ .set({ status: 'inactive', updatedAt: new Date() })
796
+ .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.nodeId, nodeId), eq(agentNodeBindings.agentId, updated.id), eq(agentNodeBindings.status, 'active')));
797
+ await releaseNodeAgentSlots(db, workspaceId, [nodeId]);
798
+ await ensureDirectNodeForAgent(db, workspaceId, updated, { force: true });
799
+ await db
800
+ .update(agents)
801
+ .set({ status: 'offline', lastSeen: new Date() })
802
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, updated.id)));
803
+ await markDirectNodeOfflineForAgent(db, workspaceId, updated.id);
804
+ await releaseNodeAgentSlots(db, workspaceId, activeNodeIds.filter((activeNodeId) => activeNodeId !== nodeId));
805
+ }
310
806
  return updated ?? null;
311
807
  }
312
808
  export async function reconcileInventory(db, registry, workspaceId, nodeId, inventoryAgents, completionDeps) {
313
809
  const names = new Set(inventoryAgents.map((agent) => agent.name));
314
810
  const liveInvocationIds = new Set(inventoryAgents.flatMap((agent) => (agent.invocation_id ? [agent.invocation_id] : [])));
315
811
  let completedInvocations = 0;
812
+ const [node] = await db
813
+ .select()
814
+ .from(nodes)
815
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
816
+ if (!node)
817
+ throw codedError(`Node "${nodeId}" not found`, 'node_not_found', 404);
316
818
  // Pre-validate every item against the current state BEFORE mutating anything,
317
819
  // so a conflict on a later item can't leave earlier items partially
318
820
  // reconciled (the control handler turns a throw into an error reply). Existing
@@ -333,7 +835,9 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, inve
333
835
  .from(nodes)
334
836
  .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, existing.locationNodeId ?? '')));
335
837
  const boundNodeLive = !!boundNode && isNodeLive(boundNode);
336
- const conflict = existing.locationType !== 'via_node' || !existing.locationNodeId || (existing.locationNodeId !== nodeId && boundNodeLive);
838
+ const conflict = existing.locationType !== 'via_node'
839
+ || !existing.locationNodeId
840
+ || (existing.locationNodeId !== nodeId && boundNodeLive && !isImplicitDirectLocation(existing));
337
841
  if (conflict) {
338
842
  console.warn('[node.inventory] rejected active-name claim', {
339
843
  workspace_id: workspaceId,
@@ -349,17 +853,37 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, inve
349
853
  for (const item of inventoryAgents) {
350
854
  const existing = existingByName.get(item.name);
351
855
  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));
856
+ const activeNodeIds = await activeBindingNodeIdsForAgent(db, workspaceId, existing.id);
857
+ const targetWasActive = activeNodeIds.includes(nodeId);
858
+ let reservedTargetSlot = false;
859
+ try {
860
+ if (!targetWasActive) {
861
+ await reserveNodeAgentSlot(db, workspaceId, node);
862
+ reservedTargetSlot = true;
863
+ }
864
+ await db
865
+ .update(agents)
866
+ .set({
867
+ status: 'active',
868
+ lastSeen: new Date(),
869
+ locationType: 'via_node',
870
+ locationNodeId: nodeId,
871
+ originNodeId: existing.originNodeId ?? nodeId,
872
+ sessionRef: item.session_ref ?? existing.sessionRef,
873
+ })
874
+ .where(eq(agents.id, existing.id));
875
+ await upsertAgentNodeBinding(db, workspaceId, existing, nodeId, {
876
+ sessionRef: item.session_ref ?? existing.sessionRef,
877
+ deactivateExisting: true,
878
+ });
879
+ await releaseNodeAgentSlots(db, workspaceId, activeNodeIds.filter((activeNodeId) => activeNodeId !== nodeId));
880
+ }
881
+ catch (err) {
882
+ if (reservedTargetSlot) {
883
+ await releaseNodeAgentSlots(db, workspaceId, [nodeId]);
884
+ }
885
+ throw err;
886
+ }
363
887
  }
364
888
  if (!item.invocation_id)
365
889
  continue;
@@ -482,6 +1006,10 @@ export async function handleNodeControlMessage(args) {
482
1006
  // Node is now marked online: flush any queued action.invoke frames so
483
1007
  // spawns queued while it was offline can reserve capacity and dispatch.
484
1008
  await args.registry.drainNode(args.workspaceId, args.nodeId);
1009
+ // The success reply was already sent above; keep the pending flush
1010
+ // best-effort so a delivery error cannot trigger a second error reply
1011
+ // for the same request id from the outer catch.
1012
+ await deliverPendingToNode(args.db, args.registry, args.workspaceId, args.nodeId).catch(() => { });
485
1013
  return;
486
1014
  }
487
1015
  case 'node.heartbeat':