@relaycast/engine 5.1.1 → 6.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 (56) hide show
  1. package/dist/adapters/node/realtime.d.ts +28 -1
  2. package/dist/adapters/node/realtime.d.ts.map +1 -1
  3. package/dist/adapters/node/realtime.js +229 -50
  4. package/dist/adapters/node/realtime.js.map +1 -1
  5. package/dist/auth/tokenKind.d.ts +1 -1
  6. package/dist/auth/tokenKind.d.ts.map +1 -1
  7. package/dist/auth/tokenKind.js +4 -1
  8. package/dist/auth/tokenKind.js.map +1 -1
  9. package/dist/db/migrations/0028_node_providers.sql +55 -0
  10. package/dist/db/schema.d.ts +376 -0
  11. package/dist/db/schema.d.ts.map +1 -1
  12. package/dist/db/schema.js +59 -1
  13. package/dist/db/schema.js.map +1 -1
  14. package/dist/engine/action.d.ts +44 -1
  15. package/dist/engine/action.d.ts.map +1 -1
  16. package/dist/engine/action.js +237 -18
  17. package/dist/engine/action.js.map +1 -1
  18. package/dist/engine/delivery.d.ts.map +1 -1
  19. package/dist/engine/delivery.js +2 -1
  20. package/dist/engine/delivery.js.map +1 -1
  21. package/dist/engine/node.d.ts +71 -28
  22. package/dist/engine/node.d.ts.map +1 -1
  23. package/dist/engine/node.js +230 -106
  24. package/dist/engine/node.js.map +1 -1
  25. package/dist/engine/nodeContext.d.ts.map +1 -1
  26. package/dist/engine/nodeContext.js +17 -6
  27. package/dist/engine/nodeContext.js.map +1 -1
  28. package/dist/engine/nodeDeliver.d.ts.map +1 -1
  29. package/dist/engine/nodeDeliver.js +4 -1
  30. package/dist/engine/nodeDeliver.js.map +1 -1
  31. package/dist/engine/nodeProvider.d.ts +62 -0
  32. package/dist/engine/nodeProvider.d.ts.map +1 -0
  33. package/dist/engine/nodeProvider.js +256 -0
  34. package/dist/engine/nodeProvider.js.map +1 -0
  35. package/dist/middleware/auth.d.ts +6 -0
  36. package/dist/middleware/auth.d.ts.map +1 -1
  37. package/dist/middleware/auth.js +6 -0
  38. package/dist/middleware/auth.js.map +1 -1
  39. package/dist/node-control.d.ts +1 -1
  40. package/dist/node-control.d.ts.map +1 -1
  41. package/dist/node-control.js +11 -1
  42. package/dist/node-control.js.map +1 -1
  43. package/dist/ports/auth.d.ts +1 -1
  44. package/dist/ports/auth.d.ts.map +1 -1
  45. package/dist/ports/realtime.d.ts +24 -2
  46. package/dist/ports/realtime.d.ts.map +1 -1
  47. package/dist/routes/deliveryRouting.d.ts.map +1 -1
  48. package/dist/routes/deliveryRouting.js +6 -1
  49. package/dist/routes/deliveryRouting.js.map +1 -1
  50. package/dist/routes/message.d.ts.map +1 -1
  51. package/dist/routes/message.js +44 -14
  52. package/dist/routes/message.js.map +1 -1
  53. package/dist/routes/node.d.ts.map +1 -1
  54. package/dist/routes/node.js +89 -3
  55. package/dist/routes/node.js.map +1 -1
  56. package/package.json +3 -3
@@ -1,14 +1,20 @@
1
1
  import { and, eq, inArray, ne, or, sql } from 'drizzle-orm';
2
2
  import { parseFleetBrokerToRelaycastMessage } from '@relaycast/types';
3
- import { actionInvocations, actions, agents, agentNodeBindings, channelMembers, channels, nodes } from '../db/schema.js';
3
+ import { actionInvocations, agents, agentNodeBindings, channelMembers, channels, nodeProviders, 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';
7
7
  import { generateId } from './snowflake.js';
8
8
  import { isNodeLive, nodeHasCapability } from './placement.js';
9
- import { completeNodeInvocation, rescheduleInvocationsForLostNode, rescheduleNodeInvocation } from './action.js';
9
+ import { DEFAULT_PROVIDER_NAME, capabilityKind, heartbeatProvider, markProviderOffline, materializeProviderActions, recomputeNodeAggregate, removeProvider, upsertProvider, } from './nodeProvider.js';
10
+ import { completeNodeInvocation, dispatchCapacitySpawn, rescheduleInvocationsForLostNode, rescheduleNodeInvocation, } from './action.js';
10
11
  import { emitInvocationCompletionEffects } from './invocationCompletion.js';
11
12
  import { ackDeliveriesUpToSeq, deliverPendingToNode } from './delivery.js';
13
+ function resolveProviderIdentity(message, connectionId) {
14
+ if (message.provider)
15
+ return message.provider;
16
+ return { name: DEFAULT_PROVIDER_NAME, instance_id: connectionId ?? generateId() };
17
+ }
12
18
  export function directNodeIdForAgent(agentId) {
13
19
  return `node_direct_${agentId}`;
14
20
  }
@@ -18,11 +24,6 @@ function directNodeNameForAgent(agentId) {
18
24
  function isImplicitDirectLocation(agent) {
19
25
  return agent.locationNodeId === directNodeIdForAgent(agent.id);
20
26
  }
21
- function capabilityName(capability) {
22
- if (typeof capability === 'string')
23
- return capability;
24
- return capability?.name ?? null;
25
- }
26
27
  function normalizeCapabilities(capabilities) {
27
28
  return capabilities.map((capability) => (typeof capability === 'string' ? { name: capability } : capability));
28
29
  }
@@ -110,36 +111,6 @@ function normalizeLegacyAdapter(adapter) {
110
111
  return 'ws.node.v1';
111
112
  return adapter;
112
113
  }
113
- async function ensureCapabilityActions(db, workspaceId, nodeId, capabilities) {
114
- for (const capability of capabilities) {
115
- const name = capabilityName(capability);
116
- if (!name || name.startsWith('spawn:'))
117
- continue;
118
- const [existing] = await db
119
- .select()
120
- .from(actions)
121
- .where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, name)));
122
- if (!existing) {
123
- await db.insert(actions).values({
124
- id: `act_${generateId()}`,
125
- workspaceId,
126
- name,
127
- description: `Node handler ${name}`,
128
- handlerAgentId: null,
129
- handlerNodeId: nodeId,
130
- inputSchema: {},
131
- outputSchema: {},
132
- availableTo: null,
133
- });
134
- }
135
- else if (!existing.handlerAgentId && (!existing.handlerNodeId || existing.handlerNodeId === nodeId)) {
136
- await db
137
- .update(actions)
138
- .set({ handlerNodeId: nodeId, isActive: true })
139
- .where(eq(actions.id, existing.id));
140
- }
141
- }
142
- }
143
114
  export async function createNodeToken(db, workspaceId, data) {
144
115
  const token = `nt_live_${randomHex(24)}`;
145
116
  const tokenHash = await sha256Hex(token);
@@ -220,7 +191,7 @@ export async function getNodeByName(db, workspaceId, name) {
220
191
  .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.name, normalized)));
221
192
  return node ?? null;
222
193
  }
223
- export async function registerNode(db, workspaceId, authenticatedNodeId, message) {
194
+ export async function registerNode(db, workspaceId, authenticatedNodeId, message, provider) {
224
195
  if (message.node_id !== authenticatedNodeId) {
225
196
  throw codedError('node_id does not match the authenticated node token', 'node_id_mismatch', 403);
226
197
  }
@@ -239,41 +210,66 @@ export async function registerNode(db, workspaceId, authenticatedNodeId, message
239
210
  if (existingByName && existingByName.id !== authenticatedNodeId) {
240
211
  throw codedError(`Node name "${message.name}" is already enrolled`, 'node_name_conflict', 409);
241
212
  }
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;
245
- const [updated] = await db
246
- .update(nodes)
247
- .set({
248
- name: message.name,
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,
257
- version: message.version,
258
- status: 'online',
259
- handlersLive: role === 'broker' && capabilities.length > 0,
260
- lastHeartbeatAt: now,
261
- })
262
- .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, authenticatedNodeId)))
263
- .returning();
264
- if (role === 'broker') {
265
- await ensureCapabilityActions(db, workspaceId, updated.id, capabilities);
213
+ // Direct nodes are the per-agent delivery hosts: they never carry providers.
214
+ if (existing.role === 'direct') {
215
+ const [updated] = await db
216
+ .update(nodes)
217
+ .set({
218
+ name: message.name,
219
+ capabilities: [],
220
+ kind: 'ws',
221
+ role: 'direct',
222
+ deliveryAdapter: 'ws.node.v1',
223
+ deliveryConfig: existing.deliveryConfig,
224
+ maxAgents: 1,
225
+ activeAgents: 1,
226
+ tags: existing.tags,
227
+ version: message.version,
228
+ status: 'online',
229
+ handlersLive: false,
230
+ lastHeartbeatAt: now,
231
+ })
232
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, authenticatedNodeId)))
233
+ .returning();
234
+ return { node: publicNode(updated), acceptance: [], provider };
266
235
  }
267
- return publicNode(updated);
236
+ const capabilities = normalizeCapabilities(message.capabilities);
237
+ await runAtomic(db, async (tx) => {
238
+ await upsertProvider(tx, workspaceId, authenticatedNodeId, {
239
+ name: provider.name,
240
+ instanceId: provider.instance_id,
241
+ capabilities,
242
+ maxAgents: message.max_agents,
243
+ version: message.version,
244
+ handlersLive: capabilities.length > 0,
245
+ });
246
+ await materializeProviderActions(tx, workspaceId, authenticatedNodeId, provider.name, capabilities);
247
+ await tx
248
+ .update(nodes)
249
+ .set({ name: message.name, kind: 'ws', role: 'broker', deliveryAdapter: 'ws.node.v1', deliveryConfig: null, tags: message.tags })
250
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, authenticatedNodeId)));
251
+ await recomputeNodeAggregate(tx, workspaceId, authenticatedNodeId, {
252
+ version: message.version,
253
+ machineId: message.machine_id ?? null,
254
+ });
255
+ });
256
+ const [updated] = await db
257
+ .select()
258
+ .from(nodes)
259
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, authenticatedNodeId)));
260
+ const acceptance = capabilities.map((cap) => ({
261
+ name: cap.name,
262
+ kind: capabilityKind(cap),
263
+ accepted: true,
264
+ }));
265
+ return { node: publicNode(updated), acceptance, provider };
268
266
  }
269
- export async function heartbeatNode(db, workspaceId, nodeId, message) {
270
- // A heartbeat may carry the node's roster snapshot so the engine can keep its
271
- // node descriptor fresh between (or in the absence of a fresh) node.register —
272
- // e.g. after an engine restart where the broker keeps heartbeating an
273
- // already-registered node. The roster fields are optional; a minimal heartbeat
274
- // updates only liveness/capacity. lastHeartbeatAt is always stamped
275
- // server-side here as the authoritative receipt time — the broker never sends
276
- // it, and any client-supplied value would be ignored.
267
+ export async function heartbeatNode(db, workspaceId, nodeId, providerName, message) {
268
+ // A heartbeat is provider-scoped by connection. It may carry the provider's
269
+ // roster snapshot so the engine can refresh the descriptor between (or in the
270
+ // absence of a fresh) node.register — e.g. after an engine restart where the
271
+ // provider keeps heartbeating. lastHeartbeatAt is always stamped server-side
272
+ // as the authoritative receipt time; the client never sends it.
277
273
  if (message.node_id !== undefined && message.node_id !== nodeId) {
278
274
  throw codedError('node_id does not match the authenticated node token', 'node_id_mismatch', 403);
279
275
  }
@@ -281,35 +277,66 @@ export async function heartbeatNode(db, workspaceId, nodeId, message) {
281
277
  .select({ role: nodes.role })
282
278
  .from(nodes)
283
279
  .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
284
- const role = nodeState?.role === 'direct' ? 'direct' : 'broker';
285
- const rosterUpdate = {};
286
- if (message.name !== undefined)
287
- rosterUpdate.name = message.name;
288
- if (message.capabilities !== undefined) {
289
- if (role !== 'direct') {
290
- rosterUpdate.capabilities = normalizeCapabilities(message.capabilities);
291
- }
292
- }
293
- if (message.max_agents !== undefined) {
294
- rosterUpdate.maxAgents = role === 'direct' ? 1 : message.max_agents;
280
+ if (!nodeState)
281
+ return null;
282
+ if (nodeState.role === 'direct') {
283
+ const rosterUpdate = {};
284
+ if (message.name !== undefined)
285
+ rosterUpdate.name = message.name;
286
+ if (message.version !== undefined)
287
+ rosterUpdate.version = message.version;
288
+ const [updated] = await db
289
+ .update(nodes)
290
+ .set({
291
+ ...rosterUpdate,
292
+ status: 'online',
293
+ load: message.load,
294
+ activeAgents: 1,
295
+ handlersLive: false,
296
+ lastHeartbeatAt: new Date(),
297
+ })
298
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
299
+ .returning();
300
+ return updated ? publicNode(updated) : null;
295
301
  }
296
- if (message.version !== undefined)
297
- rosterUpdate.version = message.version;
302
+ await runAtomic(db, async (tx) => {
303
+ if (message.capabilities !== undefined) {
304
+ const caps = normalizeCapabilities(message.capabilities);
305
+ const [existingProvider] = await tx
306
+ .select({ instanceId: nodeProviders.instanceId, maxAgents: nodeProviders.maxAgents, version: nodeProviders.version })
307
+ .from(nodeProviders)
308
+ .where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), eq(nodeProviders.name, providerName)));
309
+ await upsertProvider(tx, workspaceId, nodeId, {
310
+ name: providerName,
311
+ instanceId: existingProvider?.instanceId ?? `${providerName}:heartbeat`,
312
+ capabilities: caps,
313
+ maxAgents: message.max_agents ?? existingProvider?.maxAgents ?? 0,
314
+ version: message.version ?? existingProvider?.version ?? 'unknown',
315
+ handlersLive: message.handlers_live,
316
+ });
317
+ await heartbeatProvider(tx, workspaceId, nodeId, providerName, {
318
+ load: message.load,
319
+ activeAgents: message.active_agents,
320
+ handlersLive: message.handlers_live,
321
+ });
322
+ await materializeProviderActions(tx, workspaceId, nodeId, providerName, caps);
323
+ }
324
+ else {
325
+ await heartbeatProvider(tx, workspaceId, nodeId, providerName, {
326
+ load: message.load,
327
+ activeAgents: message.active_agents,
328
+ handlersLive: message.handlers_live,
329
+ });
330
+ }
331
+ if (message.name !== undefined) {
332
+ await tx.update(nodes).set({ name: message.name }).where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
333
+ }
334
+ await recomputeNodeAggregate(tx, workspaceId, nodeId, message.version !== undefined ? { version: message.version } : {});
335
+ });
298
336
  const [updated] = await db
299
- .update(nodes)
300
- .set({
301
- ...rosterUpdate,
302
- status: 'online',
303
- load: message.load,
304
- activeAgents: role === 'direct' ? 1 : message.active_agents,
305
- handlersLive: role !== 'direct' && message.handlers_live,
306
- lastHeartbeatAt: new Date(),
307
- })
308
- .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)))
309
- .returning();
310
- if (updated && updated.role !== 'direct' && message.capabilities !== undefined) {
311
- await ensureCapabilityActions(db, workspaceId, updated.id, message.capabilities);
312
- }
337
+ .select()
338
+ .from(nodes)
339
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
313
340
  return updated ? publicNode(updated) : null;
314
341
  }
315
342
  export async function markNodeOffline(db, registry, workspaceId, nodeId) {
@@ -323,14 +350,62 @@ export async function markNodeOffline(db, registry, workspaceId, nodeId) {
323
350
  lastHeartbeatAt: new Date(),
324
351
  })
325
352
  .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
353
+ // Provider capability sets persist (offline nodes still show their manifest);
354
+ // only their liveness flips. Zero activeAgents too, so a later
355
+ // recomputeNodeAggregate never resurrects a dropped provider's agent count.
356
+ await db
357
+ .update(nodeProviders)
358
+ .set({ status: 'offline', handlersLive: false, load: 0, activeAgents: 0, lastHeartbeatAt: new Date() })
359
+ .where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId)));
326
360
  await db
327
361
  .update(agents)
328
362
  .set({ status: 'offline', lastSeen: new Date() })
329
363
  .where(and(eq(agents.workspaceId, workspaceId), eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId)));
330
364
  await rescheduleInvocationsForLostNode(db, registry, workspaceId, nodeId);
331
365
  }
332
- export async function deregisterNode(db, registry, workspaceId, nodeId) {
333
- await markNodeOffline(db, registry, workspaceId, nodeId);
366
+ /**
367
+ * A provider's control connection dropped. Its capability set persists (marked
368
+ * offline) so the node still shows the manifest; agents it hosted go offline.
369
+ * When it was the node's last connection the whole node goes offline.
370
+ */
371
+ export async function handleProviderDisconnect(db, registry, workspaceId, nodeId, providerName, hasRemainingConnections) {
372
+ if (!hasRemainingConnections) {
373
+ await markNodeOffline(db, registry, workspaceId, nodeId);
374
+ return;
375
+ }
376
+ await markProviderOffline(db, workspaceId, nodeId, providerName);
377
+ await db
378
+ .update(agents)
379
+ .set({ status: 'offline', lastSeen: new Date() })
380
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId), eq(agents.providerName, providerName)));
381
+ await recomputeNodeAggregate(db, workspaceId, nodeId);
382
+ }
383
+ /**
384
+ * Provider-scoped deregister: removes the provider's attachment, persisted
385
+ * capability set, and materialized actions. When it was the node's last
386
+ * provider the node goes fully offline.
387
+ */
388
+ export async function deregisterProvider(db, registry, workspaceId, nodeId, providerName) {
389
+ await removeProvider(db, workspaceId, nodeId, providerName);
390
+ registry.detachProvider(workspaceId, nodeId, providerName);
391
+ await db
392
+ .update(agents)
393
+ .set({ status: 'offline', lastSeen: new Date() })
394
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId), eq(agents.providerName, providerName)));
395
+ const [remaining] = await db
396
+ .select({ count: sql `count(*)` })
397
+ .from(nodeProviders)
398
+ .where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId)));
399
+ if (!remaining || remaining.count === 0) {
400
+ await markNodeOffline(db, registry, workspaceId, nodeId);
401
+ }
402
+ else {
403
+ // Other providers remain: recompute the node aggregate but don't reschedule
404
+ // node-wide — that would disturb the surviving providers' in-flight invokes.
405
+ // Work dispatched to the removed provider is caught by the dispatch-timeout
406
+ // sweep, matching the provider-disconnect path.
407
+ await recomputeNodeAggregate(db, workspaceId, nodeId);
408
+ }
334
409
  }
335
410
  export async function sweepOfflineNodes(db, registry) {
336
411
  const rows = await db.select().from(nodes).where(eq(nodes.status, 'online'));
@@ -684,7 +759,7 @@ export async function listNodeAgents(db, workspaceId, nodeName) {
684
759
  .where(and(eq(agentNodeBindings.workspaceId, workspaceId), eq(agentNodeBindings.nodeId, node.id), eq(agentNodeBindings.status, 'active')));
685
760
  return rows.map(serializeBinding);
686
761
  }
687
- export async function registerAgentViaNode(db, workspaceId, nodeId, message) {
762
+ export async function registerAgentViaNode(db, workspaceId, nodeId, providerName, message) {
688
763
  return runAtomic(db, async (tx) => {
689
764
  const [node] = await tx
690
765
  .select()
@@ -714,6 +789,7 @@ export async function registerAgentViaNode(db, workspaceId, nodeId, message) {
714
789
  metadata: { fleet: { node_id: nodeId, invocation_id: message.invocation_id ?? null, registered_at: now } },
715
790
  locationType: 'via_node',
716
791
  locationNodeId: nodeId,
792
+ providerName,
717
793
  originNodeId: nodeId,
718
794
  resumable: message.resumable ?? false,
719
795
  sessionRef: message.session_ref ?? null,
@@ -727,6 +803,7 @@ export async function registerAgentViaNode(db, workspaceId, nodeId, message) {
727
803
  metadata: sql `json_patch(COALESCE(${agents.metadata}, '{}'), ${fleetMetadata})`,
728
804
  locationType: 'via_node',
729
805
  locationNodeId: nodeId,
806
+ providerName,
730
807
  originNodeId: sql `COALESCE(${agents.originNodeId}, ${nodeId})`,
731
808
  resumable: message.resumable ?? false,
732
809
  sessionRef: message.session_ref ?? null,
@@ -919,6 +996,7 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, inve
919
996
  input: actionInvocations.input,
920
997
  status: actionInvocations.status,
921
998
  dispatchedNodeId: actionInvocations.dispatchedNodeId,
999
+ spawnReservedAt: actionInvocations.spawnReservedAt,
922
1000
  attemptedNodeIds: actionInvocations.attemptedNodeIds,
923
1001
  dispatchAttempts: actionInvocations.dispatchAttempts,
924
1002
  })
@@ -989,16 +1067,43 @@ export async function handleNodeControlMessage(args) {
989
1067
  });
990
1068
  return;
991
1069
  }
1070
+ // A provider binds its name to the connection at node.register; later frames
1071
+ // (heartbeat, deregister, agent.register, results) resolve it from the
1072
+ // connection. Absent a bound provider (or when driven without a registry
1073
+ // connection), fall back to the synthetic `default` provider.
1074
+ const boundProviderName = args.connectionId
1075
+ ? args.registry.providerNameForConnection?.(args.connectionId)
1076
+ : undefined;
1077
+ // The connection's registered identity is authoritative — a provider can't act
1078
+ // on another provider by putting a different name in the frame. The frame's
1079
+ // provider is only a hint for unbound/back-compat traffic.
1080
+ const frameProviderName = boundProviderName
1081
+ ?? ('provider' in message && message.provider ? message.provider.name : undefined)
1082
+ ?? DEFAULT_PROVIDER_NAME;
992
1083
  try {
993
1084
  switch (message.type) {
994
1085
  case 'node.register': {
995
- const registered = await registerNode(args.db, args.workspaceId, args.nodeId, message);
1086
+ const provider = resolveProviderIdentity(message, args.connectionId);
1087
+ if (args.connectionId && args.registry.providerAttachConflict) {
1088
+ const conflict = args.registry.providerAttachConflict(args.workspaceId, args.nodeId, provider.name, provider.instance_id, args.connectionId);
1089
+ if (conflict) {
1090
+ throw codedError(conflict.message, conflict.code, 409);
1091
+ }
1092
+ }
1093
+ const registered = await registerNode(args.db, args.workspaceId, args.nodeId, message, provider);
1094
+ if (args.connectionId && args.registry.attachProvider) {
1095
+ args.registry.attachProvider(args.workspaceId, args.nodeId, provider.name, provider.instance_id, args.connectionId);
1096
+ }
996
1097
  sendControl(args.socket, {
997
1098
  v: 1,
998
1099
  id: requestId(message),
999
1100
  type: 'reply',
1000
1101
  ok: true,
1001
- data: registered,
1102
+ data: {
1103
+ ...registered.node,
1104
+ provider: registered.provider,
1105
+ accepted_capabilities: registered.acceptance,
1106
+ },
1002
1107
  });
1003
1108
  // Node is now marked online: flush any queued action.invoke frames so
1004
1109
  // spawns queued while it was offline can reserve capacity and dispatch.
@@ -1010,16 +1115,35 @@ export async function handleNodeControlMessage(args) {
1010
1115
  return;
1011
1116
  }
1012
1117
  case 'node.heartbeat':
1013
- await heartbeatNode(args.db, args.workspaceId, args.nodeId, message);
1118
+ await heartbeatNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message);
1014
1119
  // Heartbeat refreshes online/capacity state; re-drain as a backstop in
1015
1120
  // case a queued spawn could not reserve capacity at register time.
1016
1121
  await args.registry.drainNode(args.workspaceId, args.nodeId);
1017
1122
  return;
1018
1123
  case 'node.deregister':
1019
- await deregisterNode(args.db, args.registry, args.workspaceId, args.nodeId);
1124
+ await deregisterProvider(args.db, args.registry, args.workspaceId, args.nodeId, frameProviderName);
1020
1125
  return;
1126
+ case 'node.spawn': {
1127
+ // Handler-context `ctx.spawnAgent`: capacity-direct delegation to this
1128
+ // connection's own node capacity executor (the broker provider).
1129
+ // Bypasses action dispatch so a `spawn:<harness>` shadow handler cannot
1130
+ // re-enter itself. The target is always the connection's node — a node
1131
+ // credential cannot direct a spawn at another node.
1132
+ const result = await dispatchCapacitySpawn(args.db, args.workspaceId, {
1133
+ input: message.input,
1134
+ target_node_id: args.nodeId,
1135
+ }, { nodeConnections: args.registry });
1136
+ sendControl(args.socket, {
1137
+ v: 1,
1138
+ id: requestId(message),
1139
+ type: 'reply',
1140
+ ok: true,
1141
+ data: result,
1142
+ });
1143
+ return;
1144
+ }
1021
1145
  case 'agent.register': {
1022
- const registered = await registerAgentViaNode(args.db, args.workspaceId, args.nodeId, message);
1146
+ const registered = await registerAgentViaNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message);
1023
1147
  // The relay broker's node_control client awaits a `reply` frame keyed by
1024
1148
  // the request id (it matches `pending_agent_registrations` by `reply.id`
1025
1149
  // and parses `data` as {agent_id, token, name} with deny_unknown_fields).