@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.
- package/dist/adapters/node/realtime.d.ts +28 -1
- package/dist/adapters/node/realtime.d.ts.map +1 -1
- package/dist/adapters/node/realtime.js +229 -50
- package/dist/adapters/node/realtime.js.map +1 -1
- package/dist/auth/tokenKind.d.ts +1 -1
- package/dist/auth/tokenKind.d.ts.map +1 -1
- package/dist/auth/tokenKind.js +4 -1
- package/dist/auth/tokenKind.js.map +1 -1
- package/dist/db/migrations/0028_node_providers.sql +55 -0
- package/dist/db/schema.d.ts +376 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +59 -1
- package/dist/db/schema.js.map +1 -1
- package/dist/engine/action.d.ts +44 -1
- package/dist/engine/action.d.ts.map +1 -1
- package/dist/engine/action.js +237 -18
- package/dist/engine/action.js.map +1 -1
- package/dist/engine/delivery.d.ts.map +1 -1
- package/dist/engine/delivery.js +2 -1
- package/dist/engine/delivery.js.map +1 -1
- package/dist/engine/node.d.ts +71 -28
- package/dist/engine/node.d.ts.map +1 -1
- package/dist/engine/node.js +230 -106
- package/dist/engine/node.js.map +1 -1
- package/dist/engine/nodeContext.d.ts.map +1 -1
- package/dist/engine/nodeContext.js +17 -6
- package/dist/engine/nodeContext.js.map +1 -1
- package/dist/engine/nodeDeliver.d.ts.map +1 -1
- package/dist/engine/nodeDeliver.js +4 -1
- package/dist/engine/nodeDeliver.js.map +1 -1
- package/dist/engine/nodeProvider.d.ts +62 -0
- package/dist/engine/nodeProvider.d.ts.map +1 -0
- package/dist/engine/nodeProvider.js +256 -0
- package/dist/engine/nodeProvider.js.map +1 -0
- package/dist/middleware/auth.d.ts +6 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +6 -0
- package/dist/middleware/auth.js.map +1 -1
- package/dist/node-control.d.ts +1 -1
- package/dist/node-control.d.ts.map +1 -1
- package/dist/node-control.js +11 -1
- package/dist/node-control.js.map +1 -1
- package/dist/ports/auth.d.ts +1 -1
- package/dist/ports/auth.d.ts.map +1 -1
- package/dist/ports/realtime.d.ts +24 -2
- package/dist/ports/realtime.d.ts.map +1 -1
- package/dist/routes/deliveryRouting.d.ts.map +1 -1
- package/dist/routes/deliveryRouting.js +6 -1
- package/dist/routes/deliveryRouting.js.map +1 -1
- package/dist/routes/message.d.ts.map +1 -1
- package/dist/routes/message.js +44 -14
- package/dist/routes/message.js.map +1 -1
- package/dist/routes/node.d.ts.map +1 -1
- package/dist/routes/node.js +89 -3
- package/dist/routes/node.js.map +1 -1
- package/package.json +3 -3
package/dist/engine/node.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
|
271
|
-
//
|
|
272
|
-
// e.g. after an engine restart where the
|
|
273
|
-
//
|
|
274
|
-
//
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
rosterUpdate
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
.
|
|
300
|
-
.
|
|
301
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
|
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:
|
|
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
|
|
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).
|