@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.
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +23 -1
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/presence.d.ts +7 -3
- package/dist/adapters/node/presence.d.ts.map +1 -1
- package/dist/adapters/node/presence.js +10 -8
- package/dist/adapters/node/presence.js.map +1 -1
- package/dist/adapters/node/realtime.d.ts +9 -1
- package/dist/adapters/node/realtime.d.ts.map +1 -1
- package/dist/adapters/node/realtime.js +72 -10
- package/dist/adapters/node/realtime.js.map +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +31 -9
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/tokenKind.d.ts +40 -0
- package/dist/auth/tokenKind.d.ts.map +1 -0
- package/dist/auth/tokenKind.js +89 -0
- package/dist/auth/tokenKind.js.map +1 -0
- package/dist/bin/serve.js +2 -4
- package/dist/bin/serve.js.map +1 -1
- package/dist/db/migrations/0021_node_delivery_contracts.sql +70 -0
- package/dist/db/migrations/0022_http_push_redrive.sql +13 -0
- package/dist/db/migrations/0023_direct_ws_nodes.sql +124 -0
- package/dist/db/migrations/0024_binding_workspace_constraints.sql +109 -0
- package/dist/db/migrations/0025_node_kind_role_adapter.sql +45 -0
- package/dist/db/migrations/0026_observer_tokens.sql +32 -0
- package/dist/db/schema.d.ts +727 -59
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +78 -2
- package/dist/db/schema.js.map +1 -1
- package/dist/engine/action.d.ts +2 -9
- package/dist/engine/action.d.ts.map +1 -1
- package/dist/engine/action.js +182 -14
- package/dist/engine/action.js.map +1 -1
- package/dist/engine/activity.d.ts +6 -0
- package/dist/engine/activity.d.ts.map +1 -1
- package/dist/engine/activity.js +9 -2
- package/dist/engine/activity.js.map +1 -1
- package/dist/engine/agent.d.ts +0 -33
- package/dist/engine/agent.d.ts.map +1 -1
- package/dist/engine/agent.js +4 -153
- package/dist/engine/agent.js.map +1 -1
- package/dist/engine/attachments.d.ts +6 -0
- package/dist/engine/attachments.d.ts.map +1 -1
- package/dist/engine/attachments.js +37 -0
- package/dist/engine/attachments.js.map +1 -1
- package/dist/engine/console.d.ts +78 -1
- package/dist/engine/console.d.ts.map +1 -1
- package/dist/engine/console.js +175 -19
- package/dist/engine/console.js.map +1 -1
- package/dist/engine/delivery.d.ts +26 -0
- package/dist/engine/delivery.d.ts.map +1 -1
- package/dist/engine/delivery.js +194 -74
- package/dist/engine/delivery.js.map +1 -1
- package/dist/engine/deliveryWire.d.ts +4 -0
- package/dist/engine/deliveryWire.d.ts.map +1 -1
- package/dist/engine/deliveryWire.js +2 -0
- package/dist/engine/deliveryWire.js.map +1 -1
- package/dist/engine/deliveryWrites.d.ts +6 -0
- package/dist/engine/deliveryWrites.d.ts.map +1 -1
- package/dist/engine/deliveryWrites.js +58 -13
- package/dist/engine/deliveryWrites.js.map +1 -1
- package/dist/engine/dm.d.ts.map +1 -1
- package/dist/engine/dm.js +11 -33
- package/dist/engine/dm.js.map +1 -1
- package/dist/engine/dmAll.d.ts.map +1 -1
- package/dist/engine/dmAll.js +2 -0
- package/dist/engine/dmAll.js.map +1 -1
- package/dist/engine/groupDm.d.ts +2 -3
- package/dist/engine/groupDm.d.ts.map +1 -1
- package/dist/engine/groupDm.js +8 -26
- package/dist/engine/groupDm.js.map +1 -1
- package/dist/engine/invocationCompletion.d.ts +1 -1
- package/dist/engine/invocationCompletion.d.ts.map +1 -1
- package/dist/engine/invocationCompletion.js +10 -18
- package/dist/engine/invocationCompletion.js.map +1 -1
- package/dist/engine/message.d.ts.map +1 -1
- package/dist/engine/message.js +16 -44
- package/dist/engine/message.js.map +1 -1
- package/dist/engine/node.d.ts +97 -0
- package/dist/engine/node.d.ts.map +1 -1
- package/dist/engine/node.js +555 -27
- package/dist/engine/node.js.map +1 -1
- package/dist/engine/nodeContext.d.ts +22 -0
- package/dist/engine/nodeContext.d.ts.map +1 -0
- package/dist/engine/nodeContext.js +80 -0
- package/dist/engine/nodeContext.js.map +1 -0
- package/dist/engine/nodeDeliver.d.ts +23 -0
- package/dist/engine/nodeDeliver.d.ts.map +1 -0
- package/dist/engine/nodeDeliver.js +81 -0
- package/dist/engine/nodeDeliver.js.map +1 -0
- package/dist/engine/observerToken.d.ts +110 -0
- package/dist/engine/observerToken.d.ts.map +1 -0
- package/dist/engine/observerToken.js +528 -0
- package/dist/engine/observerToken.js.map +1 -0
- package/dist/engine/search.d.ts +4 -0
- package/dist/engine/search.d.ts.map +1 -1
- package/dist/engine/search.js +6 -1
- package/dist/engine/search.js.map +1 -1
- package/dist/engine/thread.d.ts +4 -3
- package/dist/engine/thread.d.ts.map +1 -1
- package/dist/engine/thread.js +45 -17
- package/dist/engine/thread.js.map +1 -1
- package/dist/engine/wsAuth.d.ts +32 -0
- package/dist/engine/wsAuth.d.ts.map +1 -0
- package/dist/engine/wsAuth.js +65 -0
- package/dist/engine/wsAuth.js.map +1 -0
- package/dist/engine/wsTransform.d.ts.map +1 -1
- package/dist/engine/wsTransform.js +52 -36
- package/dist/engine/wsTransform.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +37 -86
- package/dist/engine.js.map +1 -1
- package/dist/entrypoints/node.d.ts.map +1 -1
- package/dist/entrypoints/node.js +20 -57
- package/dist/entrypoints/node.js.map +1 -1
- package/dist/env.d.ts +3 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/httpError.d.ts +5 -1
- package/dist/lib/httpError.d.ts.map +1 -1
- package/dist/lib/httpError.js +5 -2
- package/dist/lib/httpError.js.map +1 -1
- package/dist/lib/httpQuery.d.ts +28 -0
- package/dist/lib/httpQuery.d.ts.map +1 -0
- package/dist/lib/httpQuery.js +32 -0
- package/dist/lib/httpQuery.js.map +1 -0
- package/dist/lib/httpResponse.d.ts +15 -2
- package/dist/lib/httpResponse.d.ts.map +1 -1
- package/dist/lib/httpResponse.js +35 -7
- package/dist/lib/httpResponse.js.map +1 -1
- package/dist/middleware/auth.d.ts +8 -0
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +65 -2
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/idempotency.d.ts +30 -0
- package/dist/middleware/idempotency.d.ts.map +1 -1
- package/dist/middleware/idempotency.js +14 -0
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/middleware/logger.d.ts.map +1 -1
- package/dist/middleware/logger.js +4 -4
- package/dist/middleware/logger.js.map +1 -1
- package/dist/middleware/planLimits.d.ts.map +1 -1
- package/dist/middleware/planLimits.js +11 -9
- package/dist/middleware/planLimits.js.map +1 -1
- package/dist/middleware/rateLimit.d.ts.map +1 -1
- package/dist/middleware/rateLimit.js +15 -8
- package/dist/middleware/rateLimit.js.map +1 -1
- package/dist/middleware/usageTracker.d.ts.map +1 -1
- package/dist/middleware/usageTracker.js +2 -1
- package/dist/middleware/usageTracker.js.map +1 -1
- package/dist/ports/auth.d.ts +6 -2
- package/dist/ports/auth.d.ts.map +1 -1
- package/dist/ports/entitlements.d.ts +3 -1
- package/dist/ports/entitlements.d.ts.map +1 -1
- package/dist/ports/index.d.ts +0 -14
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/realtime.d.ts +4 -1
- package/dist/ports/realtime.d.ts.map +1 -1
- package/dist/providers/static-entitlements.js +4 -4
- package/dist/providers/static-entitlements.js.map +1 -1
- package/dist/routes/a2a.d.ts.map +1 -1
- package/dist/routes/a2a.js +41 -83
- package/dist/routes/a2a.js.map +1 -1
- package/dist/routes/action.d.ts.map +1 -1
- package/dist/routes/action.js +45 -42
- package/dist/routes/action.js.map +1 -1
- package/dist/routes/agent.d.ts.map +1 -1
- package/dist/routes/agent.js +172 -120
- package/dist/routes/agent.js.map +1 -1
- package/dist/routes/certify.d.ts.map +1 -1
- package/dist/routes/certify.js +21 -35
- package/dist/routes/certify.js.map +1 -1
- package/dist/routes/channel.d.ts.map +1 -1
- package/dist/routes/channel.js +69 -81
- package/dist/routes/channel.js.map +1 -1
- package/dist/routes/console.d.ts.map +1 -1
- package/dist/routes/console.js +60 -40
- package/dist/routes/console.js.map +1 -1
- package/dist/routes/delivery.d.ts.map +1 -1
- package/dist/routes/delivery.js +22 -54
- package/dist/routes/delivery.js.map +1 -1
- package/dist/routes/deliveryRouting.d.ts +6 -0
- package/dist/routes/deliveryRouting.d.ts.map +1 -1
- package/dist/routes/deliveryRouting.js +297 -34
- package/dist/routes/deliveryRouting.js.map +1 -1
- package/dist/routes/directory.d.ts.map +1 -1
- package/dist/routes/directory.js +47 -63
- package/dist/routes/directory.js.map +1 -1
- package/dist/routes/dm.d.ts.map +1 -1
- package/dist/routes/dm.js +21 -42
- package/dist/routes/dm.js.map +1 -1
- package/dist/routes/fanout.d.ts +2 -1
- package/dist/routes/fanout.d.ts.map +1 -1
- package/dist/routes/fanout.js +38 -20
- package/dist/routes/fanout.js.map +1 -1
- package/dist/routes/file.d.ts.map +1 -1
- package/dist/routes/file.js +48 -44
- package/dist/routes/file.js.map +1 -1
- package/dist/routes/groupDm.d.ts.map +1 -1
- package/dist/routes/groupDm.js +23 -48
- package/dist/routes/groupDm.js.map +1 -1
- package/dist/routes/inboundWebhook.d.ts.map +1 -1
- package/dist/routes/inboundWebhook.js +17 -35
- package/dist/routes/inboundWebhook.js.map +1 -1
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/inbox.js +2 -1
- package/dist/routes/inbox.js.map +1 -1
- package/dist/routes/message.d.ts.map +1 -1
- package/dist/routes/message.js +60 -53
- package/dist/routes/message.js.map +1 -1
- package/dist/routes/node.d.ts.map +1 -1
- package/dist/routes/node.js +173 -10
- package/dist/routes/node.js.map +1 -1
- package/dist/routes/observerToken.d.ts +4 -0
- package/dist/routes/observerToken.d.ts.map +1 -0
- package/dist/routes/observerToken.js +105 -0
- package/dist/routes/observerToken.js.map +1 -0
- package/dist/routes/presence.d.ts.map +1 -1
- package/dist/routes/presence.js +10 -6
- package/dist/routes/presence.js.map +1 -1
- package/dist/routes/reaction.d.ts.map +1 -1
- package/dist/routes/reaction.js +69 -28
- package/dist/routes/reaction.js.map +1 -1
- package/dist/routes/receipt.d.ts.map +1 -1
- package/dist/routes/receipt.js +20 -3
- package/dist/routes/receipt.js.map +1 -1
- package/dist/routes/routing.d.ts.map +1 -1
- package/dist/routes/routing.js +38 -51
- package/dist/routes/routing.js.map +1 -1
- package/dist/routes/search.d.ts.map +1 -1
- package/dist/routes/search.js +21 -15
- package/dist/routes/search.js.map +1 -1
- package/dist/routes/thread.d.ts.map +1 -1
- package/dist/routes/thread.js +36 -37
- package/dist/routes/thread.js.map +1 -1
- package/dist/routes/workspace.d.ts.map +1 -1
- package/dist/routes/workspace.js +57 -225
- package/dist/routes/workspace.js.map +1 -1
- package/package.json +3 -3
- package/dist/lib/fleetNodes.d.ts +0 -9
- package/dist/lib/fleetNodes.d.ts.map +0 -1
- package/dist/lib/fleetNodes.js +0 -66
- package/dist/lib/fleetNodes.js.map +0 -1
- package/dist/lib/workspaceStream.d.ts +0 -9
- package/dist/lib/workspaceStream.d.ts.map +0 -1
- package/dist/lib/workspaceStream.js +0 -53
- package/dist/lib/workspaceStream.js.map +0 -1
- package/dist/middleware/fleetNodes.d.ts +0 -10
- package/dist/middleware/fleetNodes.d.ts.map +0 -1
- package/dist/middleware/fleetNodes.js +0 -18
- package/dist/middleware/fleetNodes.js.map +0 -1
package/dist/engine/node.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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:
|
|
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 (
|
|
151
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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':
|