@relaycast/engine 12.0.0 → 12.2.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/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/presence.d.ts +18 -0
- package/dist/adapters/node/presence.d.ts.map +1 -1
- package/dist/adapters/node/presence.js +82 -0
- package/dist/adapters/node/presence.js.map +1 -1
- package/dist/adapters/node/realtime.d.ts +4 -0
- package/dist/adapters/node/realtime.d.ts.map +1 -1
- package/dist/adapters/node/realtime.js +32 -5
- package/dist/adapters/node/realtime.js.map +1 -1
- package/dist/db/schema.d.ts +2 -2
- package/dist/engine/a2a.d.ts +3 -0
- package/dist/engine/a2a.d.ts.map +1 -1
- package/dist/engine/a2a.js +1 -0
- package/dist/engine/a2a.js.map +1 -1
- package/dist/engine/action.d.ts +14 -9
- package/dist/engine/action.d.ts.map +1 -1
- package/dist/engine/action.js +342 -80
- package/dist/engine/action.js.map +1 -1
- package/dist/engine/agent.d.ts +9 -3
- package/dist/engine/agent.d.ts.map +1 -1
- package/dist/engine/agent.js +58 -12
- package/dist/engine/agent.js.map +1 -1
- package/dist/engine/delivery.d.ts.map +1 -1
- package/dist/engine/delivery.js +9 -0
- package/dist/engine/delivery.js.map +1 -1
- package/dist/engine/deliveryWrites.d.ts +23 -0
- package/dist/engine/deliveryWrites.d.ts.map +1 -1
- package/dist/engine/deliveryWrites.js +50 -8
- package/dist/engine/deliveryWrites.js.map +1 -1
- package/dist/engine/eventDelivery.d.ts +1 -1
- package/dist/engine/groupDm.d.ts.map +1 -1
- package/dist/engine/groupDm.js +4 -0
- package/dist/engine/groupDm.js.map +1 -1
- package/dist/engine/inboundWebhook.d.ts.map +1 -1
- package/dist/engine/inboundWebhook.js +20 -4
- package/dist/engine/inboundWebhook.js.map +1 -1
- package/dist/engine/inbox.d.ts.map +1 -1
- package/dist/engine/inbox.js +17 -0
- package/dist/engine/inbox.js.map +1 -1
- package/dist/engine/invocationClaim.d.ts +32 -0
- package/dist/engine/invocationClaim.d.ts.map +1 -0
- package/dist/engine/invocationClaim.js +42 -0
- package/dist/engine/invocationClaim.js.map +1 -0
- package/dist/engine/message.d.ts.map +1 -1
- package/dist/engine/message.js +4 -0
- package/dist/engine/message.js.map +1 -1
- package/dist/engine/messageMetadata.d.ts +2 -0
- package/dist/engine/messageMetadata.d.ts.map +1 -1
- package/dist/engine/messageMetadata.js +3 -2
- package/dist/engine/messageMetadata.js.map +1 -1
- package/dist/engine/node.d.ts +59 -44
- package/dist/engine/node.d.ts.map +1 -1
- package/dist/engine/node.js +179 -26
- package/dist/engine/node.js.map +1 -1
- package/dist/engine/nodeLiveness.d.ts +34 -0
- package/dist/engine/nodeLiveness.d.ts.map +1 -0
- package/dist/engine/nodeLiveness.js +52 -0
- package/dist/engine/nodeLiveness.js.map +1 -0
- package/dist/engine/placement.d.ts.map +1 -1
- package/dist/engine/placement.js +15 -18
- package/dist/engine/placement.js.map +1 -1
- package/dist/engine/presence.d.ts +173 -1
- package/dist/engine/presence.d.ts.map +1 -1
- package/dist/engine/presence.js +112 -8
- package/dist/engine/presence.js.map +1 -1
- package/dist/engine/registrationAdmission.d.ts +40 -0
- package/dist/engine/registrationAdmission.d.ts.map +1 -0
- package/dist/engine/registrationAdmission.js +78 -0
- package/dist/engine/registrationAdmission.js.map +1 -0
- package/dist/engine/taskInvocation.d.ts +1 -1
- package/dist/engine/thread.d.ts.map +1 -1
- package/dist/engine/thread.js +8 -0
- package/dist/engine/thread.js.map +1 -1
- package/dist/engine/workspace.d.ts +26 -1
- package/dist/engine/workspace.d.ts.map +1 -1
- package/dist/engine/workspace.js +108 -7
- package/dist/engine/workspace.js.map +1 -1
- package/dist/engine/workspaceDrain.d.ts +38 -0
- package/dist/engine/workspaceDrain.d.ts.map +1 -0
- package/dist/engine/workspaceDrain.js +202 -0
- package/dist/engine/workspaceDrain.js.map +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +31 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/engineDiagnostics.d.ts +26 -0
- package/dist/lib/engineDiagnostics.d.ts.map +1 -0
- package/dist/lib/engineDiagnostics.js +100 -0
- package/dist/lib/engineDiagnostics.js.map +1 -0
- package/dist/lib/httpError.d.ts.map +1 -1
- package/dist/lib/httpError.js +5 -1
- package/dist/lib/httpError.js.map +1 -1
- package/dist/middleware/admission.d.ts +7 -0
- package/dist/middleware/admission.d.ts.map +1 -1
- package/dist/middleware/admission.js +19 -1
- package/dist/middleware/admission.js.map +1 -1
- package/dist/middleware/rateLimit.d.ts.map +1 -1
- package/dist/middleware/rateLimit.js +8 -1
- package/dist/middleware/rateLimit.js.map +1 -1
- package/dist/ports/admission.d.ts +24 -3
- package/dist/ports/admission.d.ts.map +1 -1
- package/dist/ports/database.d.ts.map +1 -1
- package/dist/ports/database.js +6 -1
- package/dist/ports/database.js.map +1 -1
- package/dist/ports/diagnostics.d.ts +42 -0
- package/dist/ports/diagnostics.d.ts.map +1 -0
- package/dist/ports/diagnostics.js +13 -0
- package/dist/ports/diagnostics.js.map +1 -0
- package/dist/ports/index.d.ts +13 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/ports/nodeLiveness.d.ts +23 -0
- package/dist/ports/nodeLiveness.d.ts.map +1 -0
- package/dist/ports/nodeLiveness.js +12 -0
- package/dist/ports/nodeLiveness.js.map +1 -0
- package/dist/ports/presence.d.ts +21 -0
- package/dist/ports/presence.d.ts.map +1 -1
- package/dist/ports/realtime.d.ts +18 -0
- package/dist/ports/realtime.d.ts.map +1 -1
- package/dist/ports/realtime.js.map +1 -1
- package/dist/routes/a2a.d.ts.map +1 -1
- package/dist/routes/a2a.js +2 -0
- package/dist/routes/a2a.js.map +1 -1
- package/dist/routes/action.js +3 -0
- package/dist/routes/action.js.map +1 -1
- package/dist/routes/agent.d.ts.map +1 -1
- package/dist/routes/agent.js +5 -1
- package/dist/routes/agent.js.map +1 -1
- package/dist/routes/node.d.ts.map +1 -1
- package/dist/routes/node.js +2 -1
- package/dist/routes/node.js.map +1 -1
- package/dist/routes/workspace.js +10 -2
- package/dist/routes/workspace.js.map +1 -1
- package/package.json +3 -3
package/dist/engine/node.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { nodeMayOwn, providerLive, filterAsync, nodeLivenessAuthority, inspectNodeLiveness, snapshotOnline } from './nodeLiveness.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { invalidateChannelCache } from './cache.js';
|
|
3
4
|
import { and, asc, eq, exists, gt, gte, inArray, isNotNull, isNull, lt, lte, ne, notExists, or, sql } from 'drizzle-orm';
|
|
4
5
|
import { FLEET_ACTION_CALLER_METADATA_KEY, FLEET_ACTION_CALLER_METADATA_VERSION, FLEET_DELIVERY_CURSOR_CAPABILITY, NODE_REGISTRATION_CONTRACT_V1, parseFleetBrokerToRelaycastMessage, } from '@relaycast/types';
|
|
5
|
-
import { actionInvocations, agents, agentNodeBindings, channelMembers, channels, nodeProviders, nodes } from '../db/schema.js';
|
|
6
|
+
import { actionInvocations, agents, agentNodeBindings, channelMembers, channels, nodeProviders, nodes, workspaces } from '../db/schema.js';
|
|
7
|
+
import { isWorkspaceExpired, WORKSPACE_EXPIRED_CODE, WORKSPACE_EXPIRED_MESSAGE } from '../auth/workspaceExpiry.js';
|
|
6
8
|
import { randomHex, sha256Hex } from '../lib/crypto.js';
|
|
7
9
|
import { codedError } from '../lib/httpError.js';
|
|
10
|
+
import { AdmissionDeniedError } from '../lib/admissionError.js';
|
|
8
11
|
import { acceptTaskInvocation, completeTaskInvocation } from './taskInvocation.js';
|
|
9
12
|
import { databaseConstraintKind, runAtomic, runAtomicWrites } from '../ports/database.js';
|
|
10
13
|
import { retryD1Write } from '../lib/d1Retry.js';
|
|
@@ -23,8 +26,10 @@ function resolveProviderIdentity(message, connectionId) {
|
|
|
23
26
|
return message.provider;
|
|
24
27
|
return { name: DEFAULT_PROVIDER_NAME, instance_id: connectionId ?? generateId() };
|
|
25
28
|
}
|
|
29
|
+
/** Id prefix of the implicit direct node minted for every agent; reserved. */
|
|
30
|
+
export const IMPLICIT_DIRECT_NODE_ID_PREFIX = 'node_direct_';
|
|
26
31
|
export function directNodeIdForAgent(agentId) {
|
|
27
|
-
return
|
|
32
|
+
return `${IMPLICIT_DIRECT_NODE_ID_PREFIX}${agentId}`;
|
|
28
33
|
}
|
|
29
34
|
function directNodeNameForAgent(agentId) {
|
|
30
35
|
return `direct-${agentId}`;
|
|
@@ -215,8 +220,8 @@ async function connectionNegotiatedDeliveryCursor(db, registry, workspaceId, nod
|
|
|
215
220
|
function requestId(message) {
|
|
216
221
|
return message.id ?? generateId();
|
|
217
222
|
}
|
|
218
|
-
function publicNode(row, now) {
|
|
219
|
-
const live = isNodeLive(row, now);
|
|
223
|
+
function publicNode(row, now, authoritativeLive, authorityStatus) {
|
|
224
|
+
const live = authoritativeLive ?? isNodeLive(row, now);
|
|
220
225
|
return {
|
|
221
226
|
id: row.id,
|
|
222
227
|
name: row.name,
|
|
@@ -229,6 +234,7 @@ function publicNode(row, now) {
|
|
|
229
234
|
tags: row.tags,
|
|
230
235
|
version: row.version,
|
|
231
236
|
status: live ? 'online' : 'offline',
|
|
237
|
+
...(authorityStatus ? { liveness_status: authorityStatus } : {}),
|
|
232
238
|
live,
|
|
233
239
|
handlers_live: live && row.handlersLive,
|
|
234
240
|
load: row.loadReported ? row.load : null,
|
|
@@ -314,6 +320,14 @@ export async function createNodeToken(db, workspaceId, data, authorization = {})
|
|
|
314
320
|
if (!name || name.startsWith('#')) {
|
|
315
321
|
throw codedError('Node name must be non-empty and cannot begin with "#"', 'invalid_node_name', 400);
|
|
316
322
|
}
|
|
323
|
+
// `node_direct_<agent id>` is the engine-minted implicit direct node, which
|
|
324
|
+
// the device count excludes. The namespace is reserved so a host cannot
|
|
325
|
+
// enroll a node that is miscounted as implicit.
|
|
326
|
+
if (data.node_id !== undefined
|
|
327
|
+
&& authorization.directAgentId === undefined
|
|
328
|
+
&& data.node_id.startsWith(IMPLICIT_DIRECT_NODE_ID_PREFIX)) {
|
|
329
|
+
throw codedError(`Node ids beginning with "${IMPLICIT_DIRECT_NODE_ID_PREFIX}" are reserved for implicit direct nodes`, 'reserved_node_id', 400);
|
|
330
|
+
}
|
|
317
331
|
// Enrollment keys on node_id (the stable identity) when supplied; the name
|
|
318
332
|
// lookup is only a fallback for callers without a stable id. A name held by
|
|
319
333
|
// a *different* node id is a conflict — mirroring node.register — never a
|
|
@@ -345,6 +359,13 @@ export async function createNodeToken(db, workspaceId, data, authorization = {})
|
|
|
345
359
|
if (role === 'direct' && maxAgents !== 1) {
|
|
346
360
|
throw codedError('Direct nodes can bind at most one agent', 'direct_node_capacity_exceeded', 400);
|
|
347
361
|
}
|
|
362
|
+
// Only a NEW device is admitted. Rotating or re-enrolling an established
|
|
363
|
+
// row never is (its role may change, but it was admitted when it was
|
|
364
|
+
// enrolled), and an agent's implicit direct node — minted on its behalf,
|
|
365
|
+
// never enrolled by a host — is its own WebSocket, not a device.
|
|
366
|
+
if (!existing && authorization.directAgentId === undefined && authorization.admitRegistration) {
|
|
367
|
+
await authorization.admitRegistration('register_device');
|
|
368
|
+
}
|
|
348
369
|
if (existing) {
|
|
349
370
|
const hasPresentedTokenProof = authorization.expectedTokenHash !== undefined;
|
|
350
371
|
const authorizedDirectNode = authorization.directAgentId !== undefined
|
|
@@ -404,7 +425,9 @@ export async function createNodeToken(db, workspaceId, data, authorization = {})
|
|
|
404
425
|
// Token proof is a generation fence, not just a read-time check. If a
|
|
405
426
|
// concurrent rotation changes the credential after `existing` was read,
|
|
406
427
|
// this request must not overwrite the winner with stale authority.
|
|
407
|
-
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, existing.id),
|
|
428
|
+
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, existing.id),
|
|
429
|
+
// Accepted reconnect/register advances this revision before its lease.
|
|
430
|
+
...(nodeLivenessAuthority(db) ? [eq(nodes.livenessRevision, existing.livenessRevision)] : []), ...(hasPresentedTokenProof && !authorizedDirectNode
|
|
408
431
|
? [eq(nodes.tokenHash, authorization.expectedTokenHash)]
|
|
409
432
|
: [])))
|
|
410
433
|
.returning();
|
|
@@ -521,7 +544,7 @@ export async function getBrokerNodeByMachineId(db, workspaceId, machineId, expec
|
|
|
521
544
|
.from(nodes)
|
|
522
545
|
.where(and(reusableWhere, eq(nodes.tokenHash, expectedTokenHash)))
|
|
523
546
|
.limit(1);
|
|
524
|
-
if (provenCandidate &&
|
|
547
|
+
if (provenCandidate && await reusableMachineNode(db, provenCandidate)) {
|
|
525
548
|
return provenCandidate;
|
|
526
549
|
}
|
|
527
550
|
}
|
|
@@ -535,7 +558,7 @@ export async function getBrokerNodeByMachineId(db, workspaceId, machineId, expec
|
|
|
535
558
|
// single definition of reusability and mirrors it exactly — including
|
|
536
559
|
// treating an `online` row with a future heartbeat as live. Re-checking here
|
|
537
560
|
// keeps that definition authoritative rather than trusting the query alone.
|
|
538
|
-
return
|
|
561
|
+
return (await filterAsync(candidates, node => reusableMachineNode(db, node)))[0] ?? null;
|
|
539
562
|
}
|
|
540
563
|
/**
|
|
541
564
|
* The role an enroll is asking for, derived from the request alone.
|
|
@@ -1053,8 +1076,14 @@ export async function deregisterProvider(db, registry, workspaceId, nodeId, prov
|
|
|
1053
1076
|
}
|
|
1054
1077
|
}
|
|
1055
1078
|
export async function sweepOfflineNodes(db, registry, deps) {
|
|
1079
|
+
// Only the external socket owner can fence this transition with reconnect.
|
|
1080
|
+
if (nodeLivenessAuthority(db))
|
|
1081
|
+
return 0;
|
|
1056
1082
|
const rows = await db.select().from(nodes).where(eq(nodes.status, 'online'));
|
|
1057
|
-
const stale = rows
|
|
1083
|
+
const stale = await filterAsync(rows, async (node) => {
|
|
1084
|
+
const authority = await inspectNodeLiveness(db, node.workspaceId, node.id);
|
|
1085
|
+
return authority ? authority.status === 'offline' : !isNodeLive(node);
|
|
1086
|
+
});
|
|
1058
1087
|
let swept = 0;
|
|
1059
1088
|
for (const node of stale) {
|
|
1060
1089
|
const offlined = await serializeNodeOp(node.workspaceId, node.id, async () => {
|
|
@@ -1064,7 +1093,14 @@ export async function sweepOfflineNodes(db, registry, deps) {
|
|
|
1064
1093
|
.select()
|
|
1065
1094
|
.from(nodes)
|
|
1066
1095
|
.where(and(eq(nodes.workspaceId, node.workspaceId), eq(nodes.id, node.id)));
|
|
1067
|
-
if (!current || current.status !== 'online'
|
|
1096
|
+
if (!current || current.status !== 'online')
|
|
1097
|
+
return false;
|
|
1098
|
+
const authority = await inspectNodeLiveness(db, current.workspaceId, current.id);
|
|
1099
|
+
if (authority ? authority.status !== 'offline' : isNodeLive(current))
|
|
1100
|
+
return false;
|
|
1101
|
+
// External owners serialize offline transitions with reconnect; this
|
|
1102
|
+
// generic sweep cannot hold that fence across markNodeOffline.
|
|
1103
|
+
if (authority)
|
|
1068
1104
|
return false;
|
|
1069
1105
|
await markNodeOffline(db, registry, node.workspaceId, node.id, { deps, reason: 'liveness_timeout' });
|
|
1070
1106
|
return true;
|
|
@@ -1445,6 +1481,7 @@ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts
|
|
|
1445
1481
|
// stranded on its implicit `direct-*` pseudo-node and never woken — the
|
|
1446
1482
|
// exact shape of the broker's create-only register falling back to this
|
|
1447
1483
|
// endpoint. `unbindAgentFromNode` already assumes this move happened.
|
|
1484
|
+
let incumbentNode;
|
|
1448
1485
|
if (agent.status === 'active'
|
|
1449
1486
|
&& agent.locationNodeId
|
|
1450
1487
|
&& agent.locationNodeId !== node.id
|
|
@@ -1455,16 +1492,17 @@ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts
|
|
|
1455
1492
|
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, agent.locationNodeId)));
|
|
1456
1493
|
// A dead or missing prior location is not a conflict — it is exactly what
|
|
1457
1494
|
// a bind is for. Only a live one still owns the identity.
|
|
1458
|
-
if (locatedNode &&
|
|
1495
|
+
if (locatedNode && await nodeMayOwn(db, locatedNode)) {
|
|
1459
1496
|
throw codedError(`Agent "${agentName}" is already active on another live location`, 'agent_location_conflict', 409);
|
|
1460
1497
|
}
|
|
1498
|
+
incumbentNode = locatedNode;
|
|
1461
1499
|
}
|
|
1462
1500
|
// Provider rows persist after a disconnect so the node keeps its capability
|
|
1463
1501
|
// manifest; they are history, not sockets. Adopt only among providers that
|
|
1464
1502
|
// can actually receive a push right now — keeping or choosing an offline
|
|
1465
1503
|
// row while a live sibling serves the node would point the agent at a
|
|
1466
1504
|
// provider connection that does not exist.
|
|
1467
|
-
const
|
|
1505
|
+
const providerCandidates = await tx
|
|
1468
1506
|
.select({
|
|
1469
1507
|
name: nodeProviders.name,
|
|
1470
1508
|
status: nodeProviders.status,
|
|
@@ -1472,9 +1510,8 @@ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts
|
|
|
1472
1510
|
lastHeartbeatAt: nodeProviders.lastHeartbeatAt,
|
|
1473
1511
|
})
|
|
1474
1512
|
.from(nodeProviders)
|
|
1475
|
-
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, node.id)))
|
|
1476
|
-
|
|
1477
|
-
.map((row) => row.name);
|
|
1513
|
+
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, node.id)));
|
|
1514
|
+
const liveProviderNames = (await filterAsync(providerCandidates, provider => providerLive(db, workspaceId, node.id, provider))).map(row => row.name);
|
|
1478
1515
|
const adoptedProviderName = adoptNodeProviderName(agent.providerName, liveProviderNames);
|
|
1479
1516
|
const activeNodeIds = await activeBindingNodeIdsForAgent(tx, workspaceId, agent.id);
|
|
1480
1517
|
const targetWasActive = activeNodeIds.includes(node.id);
|
|
@@ -1492,6 +1529,9 @@ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts
|
|
|
1492
1529
|
// charges the old node forever, because the retry that would fix either
|
|
1493
1530
|
// reads the half-applied state as already done.
|
|
1494
1531
|
const buildMoveWrites = (writeDb) => [
|
|
1532
|
+
...(incumbentNode && nodeLivenessAuthority(db) ? [writeDb.update(nodes).set({
|
|
1533
|
+
activeAgents: sql `CASE WHEN ${nodes.livenessRevision} = ${incumbentNode.livenessRevision} THEN ${nodes.activeAgents} ELSE NULL END`,
|
|
1534
|
+
}).where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, incumbentNode.id)))] : []),
|
|
1495
1535
|
...agentNodeBindingWrites(writeDb, workspaceId, agent, node.id, {
|
|
1496
1536
|
sessionRef: opts.session_ref ?? null,
|
|
1497
1537
|
priority: opts.priority ?? 0,
|
|
@@ -1610,6 +1650,16 @@ export async function listNodeAgents(db, workspaceId, nodeName) {
|
|
|
1610
1650
|
export async function registerAgentViaNode(db, workspaceId, nodeId, providerName, message, options = {}) {
|
|
1611
1651
|
assertRegistrableAgentName(message.name);
|
|
1612
1652
|
const wantsAutoJoin = message.auto_join_general !== false;
|
|
1653
|
+
if (options.admitRegistration) {
|
|
1654
|
+
// Only a NEW identity is admitted: an established name is answered by the
|
|
1655
|
+
// conflict below, never by a denial.
|
|
1656
|
+
const [established] = await db
|
|
1657
|
+
.select({ id: agents.id })
|
|
1658
|
+
.from(agents)
|
|
1659
|
+
.where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, message.name)));
|
|
1660
|
+
if (!established)
|
|
1661
|
+
await options.admitRegistration('register_agent');
|
|
1662
|
+
}
|
|
1613
1663
|
const registered = await runAtomic(db, async (tx) => {
|
|
1614
1664
|
// Every awaited statement is a separate D1 round-trip — and this path runs
|
|
1615
1665
|
// under the node-control liveness deadline whose misses surface as
|
|
@@ -2032,7 +2082,9 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
|
|
|
2032
2082
|
const acceptedInventoryAgents = [];
|
|
2033
2083
|
const rejectedInventoryErrors = [];
|
|
2034
2084
|
const legacyDefaultAdoptions = new Set();
|
|
2085
|
+
const incumbentRevisions = new Map();
|
|
2035
2086
|
let canAdoptLegacyDefault;
|
|
2087
|
+
let authorityAdoption;
|
|
2036
2088
|
let acceptedExistingAgentCount = 0;
|
|
2037
2089
|
for (const item of inventoryAgents) {
|
|
2038
2090
|
const [existing] = await db
|
|
@@ -2063,8 +2115,23 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
|
|
|
2063
2115
|
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), inArray(nodeProviders.name, [DEFAULT_PROVIDER_NAME, 'broker'])));
|
|
2064
2116
|
const defaultProvider = providerRows.find((row) => row.name === DEFAULT_PROVIDER_NAME);
|
|
2065
2117
|
const brokerProvider = providerRows.find((row) => row.name === 'broker');
|
|
2066
|
-
|
|
2067
|
-
|
|
2118
|
+
const authority = await inspectNodeLiveness(db, workspaceId, nodeId);
|
|
2119
|
+
if (authority) {
|
|
2120
|
+
const now = Date.now();
|
|
2121
|
+
const brokerLease = authority.providers.find(p => p.name === 'broker' && p.live && p.handlers_live && p.expires_at > now && p.last_received_at <= now);
|
|
2122
|
+
const defaultLive = authority.providers.some(p => p.name === DEFAULT_PROVIDER_NAME && p.live && p.handlers_live && p.expires_at > now && p.last_received_at <= now);
|
|
2123
|
+
canAdoptLegacyDefault = authority.status === 'online' && !!brokerProvider && !!brokerLease && !defaultLive;
|
|
2124
|
+
if (canAdoptLegacyDefault && brokerProvider && brokerLease)
|
|
2125
|
+
authorityAdoption = {
|
|
2126
|
+
brokerId: brokerProvider.id, brokerRevision: brokerProvider.livenessRevision,
|
|
2127
|
+
defaultId: defaultProvider?.id, defaultRevision: defaultProvider?.livenessRevision,
|
|
2128
|
+
expiresAt: brokerLease.expires_at,
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
else {
|
|
2132
|
+
canAdoptLegacyDefault = !!brokerProvider && isProviderLive(brokerProvider)
|
|
2133
|
+
&& (!defaultProvider || !isProviderLive(defaultProvider));
|
|
2134
|
+
}
|
|
2068
2135
|
}
|
|
2069
2136
|
if (legacyDefaultClaim && canAdoptLegacyDefault) {
|
|
2070
2137
|
legacyDefaultAdoptions.add(item.name);
|
|
@@ -2078,7 +2145,9 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
|
|
|
2078
2145
|
.select()
|
|
2079
2146
|
.from(nodes)
|
|
2080
2147
|
.where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, existing.locationNodeId ?? '')));
|
|
2081
|
-
const boundNodeLive = !!boundNode &&
|
|
2148
|
+
const boundNodeLive = !!boundNode && await nodeMayOwn(db, boundNode);
|
|
2149
|
+
if (boundNode && boundNode.id !== nodeId && !boundNodeLive && nodeLivenessAuthority(db))
|
|
2150
|
+
incumbentRevisions.set(item.name, { id: boundNode.id, revision: boundNode.livenessRevision });
|
|
2082
2151
|
const conflict = existing.locationType !== 'via_node'
|
|
2083
2152
|
|| !existing.locationNodeId
|
|
2084
2153
|
|| (existing.locationNodeId !== nodeId && boundNodeLive && !isImplicitDirectLocation(existing));
|
|
@@ -2146,6 +2215,7 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
|
|
|
2146
2215
|
spawnReservedAt: actionInvocations.spawnReservedAt,
|
|
2147
2216
|
attemptedNodeIds: actionInvocations.attemptedNodeIds,
|
|
2148
2217
|
dispatchAttempts: actionInvocations.dispatchAttempts,
|
|
2218
|
+
retryAfterAt: actionInvocations.retryAfterAt,
|
|
2149
2219
|
})
|
|
2150
2220
|
.from(actionInvocations)
|
|
2151
2221
|
.where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.dispatchedNodeId, nodeId), eq(actionInvocations.dispatchedProvider, providerName), inArray(actionInvocations.status, ['pending', 'dispatched'])));
|
|
@@ -2178,14 +2248,24 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
|
|
|
2178
2248
|
// compare-and-set fences broker liveness loss, a default provider
|
|
2179
2249
|
// reconnecting, and concurrent ownership changes during the apply pass.
|
|
2180
2250
|
const adoptionNow = Date.now();
|
|
2181
|
-
|
|
2251
|
+
let noLiveDefaultProvider = notExists(db
|
|
2182
2252
|
.select({ id: nodeProviders.id })
|
|
2183
2253
|
.from(nodeProviders)
|
|
2184
2254
|
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), eq(nodeProviders.name, DEFAULT_PROVIDER_NAME), eq(nodeProviders.status, 'online'), eq(nodeProviders.handlersLive, true), gte(nodeProviders.lastHeartbeatAt, new Date(adoptionNow - NODE_LIVENESS_TTL_MS)), lte(nodeProviders.lastHeartbeatAt, new Date(adoptionNow)))));
|
|
2185
|
-
|
|
2255
|
+
let liveBrokerProvider = exists(db
|
|
2186
2256
|
.select({ id: nodeProviders.id })
|
|
2187
2257
|
.from(nodeProviders)
|
|
2188
2258
|
.where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), eq(nodeProviders.name, 'broker'), eq(nodeProviders.status, 'online'), eq(nodeProviders.handlersLive, true), gte(nodeProviders.lastHeartbeatAt, new Date(adoptionNow - NODE_LIVENESS_TTL_MS)), lte(nodeProviders.lastHeartbeatAt, new Date(adoptionNow)))));
|
|
2259
|
+
if (authorityAdoption) {
|
|
2260
|
+
// Every accepted register/reconnect advances the provider revision
|
|
2261
|
+
// before renewing its owner lease. Fence those generations in the
|
|
2262
|
+
// same SQL update, and reject if the observed broker lease expires.
|
|
2263
|
+
const proof = authorityAdoption;
|
|
2264
|
+
noLiveDefaultProvider = proof.defaultId
|
|
2265
|
+
? exists(db.select({ id: nodeProviders.id }).from(nodeProviders).where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), eq(nodeProviders.name, DEFAULT_PROVIDER_NAME), eq(nodeProviders.id, proof.defaultId), eq(nodeProviders.livenessRevision, proof.defaultRevision))))
|
|
2266
|
+
: notExists(db.select({ id: nodeProviders.id }).from(nodeProviders).where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), eq(nodeProviders.name, DEFAULT_PROVIDER_NAME))));
|
|
2267
|
+
liveBrokerProvider = exists(db.select({ id: nodeProviders.id }).from(nodeProviders).where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), eq(nodeProviders.name, 'broker'), eq(nodeProviders.id, proof.brokerId), eq(nodeProviders.livenessRevision, proof.brokerRevision), sql `unixepoch() * 1000 < ${proof.expiresAt}`)));
|
|
2268
|
+
}
|
|
2189
2269
|
const [updated] = await db
|
|
2190
2270
|
.update(agents)
|
|
2191
2271
|
.set({
|
|
@@ -2197,7 +2277,7 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
|
|
|
2197
2277
|
originNodeId: existing.originNodeId ?? nodeId,
|
|
2198
2278
|
sessionRef: item.session_ref ?? existing.sessionRef,
|
|
2199
2279
|
})
|
|
2200
|
-
.where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, existing.id), ...(adoptingLegacyDefault ? [
|
|
2280
|
+
.where(and(eq(agents.workspaceId, workspaceId), eq(agents.id, existing.id), ...(incumbentRevisions.has(item.name) ? [notExists(db.select({ id: nodes.id }).from(nodes).where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, incumbentRevisions.get(item.name).id), ne(nodes.livenessRevision, incumbentRevisions.get(item.name).revision))))] : []), ...(adoptingLegacyDefault ? [
|
|
2201
2281
|
eq(agents.providerName, DEFAULT_PROVIDER_NAME),
|
|
2202
2282
|
eq(agents.locationType, 'via_node'),
|
|
2203
2283
|
eq(agents.locationNodeId, nodeId),
|
|
@@ -2404,7 +2484,7 @@ export async function listNodes(db, workspaceId, filters = {}) {
|
|
|
2404
2484
|
conditions.push(eq(nodes.name, filters.name));
|
|
2405
2485
|
if (filters.capability)
|
|
2406
2486
|
conditions.push(capabilityMatchCondition(filters.capability));
|
|
2407
|
-
if (filters.status)
|
|
2487
|
+
if (filters.status && !nodeLivenessAuthority(db))
|
|
2408
2488
|
conditions.push(livenessCondition(filters.status, now));
|
|
2409
2489
|
// Observer authorization is a WHERE condition, evaluated in the same query
|
|
2410
2490
|
// as every other filter and therefore before pagination/cursor computation
|
|
@@ -2418,7 +2498,7 @@ export async function listNodes(db, workspaceId, filters = {}) {
|
|
|
2418
2498
|
// that predates the history/status selector. `name`/`capability`/`status`
|
|
2419
2499
|
// are still evaluated in SQL instead of after a full-table fetch.
|
|
2420
2500
|
const rows = await db.select().from(nodes).where(and(...conditions));
|
|
2421
|
-
const page =
|
|
2501
|
+
const page = await publicNodes(db, rows, now.getTime(), filters.status);
|
|
2422
2502
|
if (!filters.observerAgentIds)
|
|
2423
2503
|
return { nodes: page, nextCursor: null };
|
|
2424
2504
|
const counts = await visibleActiveAgentCounts(db, workspaceId, rows.map((row) => row.id), filters.observerAgentIds);
|
|
@@ -2438,7 +2518,7 @@ export async function listNodes(db, workspaceId, filters = {}) {
|
|
|
2438
2518
|
.limit(limit + 1);
|
|
2439
2519
|
const page = rows.slice(0, limit);
|
|
2440
2520
|
const nextCursor = rows.length > limit ? page[page.length - 1].id : null;
|
|
2441
|
-
const publicPage =
|
|
2521
|
+
const publicPage = await publicNodes(db, page, now.getTime(), filters.status);
|
|
2442
2522
|
if (!filters.observerAgentIds)
|
|
2443
2523
|
return { nodes: publicPage, nextCursor };
|
|
2444
2524
|
const counts = await visibleActiveAgentCounts(db, workspaceId, page.map((row) => row.id), filters.observerAgentIds);
|
|
@@ -2449,7 +2529,7 @@ export async function listNodes(db, workspaceId, filters = {}) {
|
|
|
2449
2529
|
}
|
|
2450
2530
|
export async function getPublicNode(db, workspaceId, name) {
|
|
2451
2531
|
const node = await getNodeByName(db, workspaceId, name);
|
|
2452
|
-
return node ?
|
|
2532
|
+
return node ? (await publicNodes(db, [node], Date.now()))[0] : null;
|
|
2453
2533
|
}
|
|
2454
2534
|
/** The node's persisted `status` column, or undefined when the node is gone. */
|
|
2455
2535
|
async function readNodeStatus(db, workspaceId, nodeId) {
|
|
@@ -2534,6 +2614,30 @@ function sendControl(socket, payload) {
|
|
|
2534
2614
|
}
|
|
2535
2615
|
}
|
|
2536
2616
|
/** Validate and serialize an authenticated node frame, replying only after its effects. */
|
|
2617
|
+
/**
|
|
2618
|
+
* A node socket authenticates once, at upgrade. A workspace deleted (or past
|
|
2619
|
+
* its deadline) afterwards keeps that socket, and a large workspace is drained
|
|
2620
|
+
* in steps with its nodes last, so frames that create identity must re-check:
|
|
2621
|
+
* otherwise a connected node registers agents into a workspace being deleted.
|
|
2622
|
+
*/
|
|
2623
|
+
/** Control frames still admitted after the workspace expires (see the gate below). */
|
|
2624
|
+
const SETTLING_CONTROL_FRAMES = new Set([
|
|
2625
|
+
'action.accept',
|
|
2626
|
+
'action.result',
|
|
2627
|
+
'delivery.ack',
|
|
2628
|
+
'node.deregister',
|
|
2629
|
+
'agent.deregister',
|
|
2630
|
+
]);
|
|
2631
|
+
async function assertWorkspaceUnexpired(db, workspaceId) {
|
|
2632
|
+
const [row] = await db
|
|
2633
|
+
.select({ expiresAt: workspaces.expiresAt })
|
|
2634
|
+
.from(workspaces)
|
|
2635
|
+
.where(eq(workspaces.id, workspaceId))
|
|
2636
|
+
.limit(1);
|
|
2637
|
+
if (!row || isWorkspaceExpired(row)) {
|
|
2638
|
+
throw codedError(WORKSPACE_EXPIRED_MESSAGE, WORKSPACE_EXPIRED_CODE, 401);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2537
2641
|
export async function handleNodeControlMessage(args) {
|
|
2538
2642
|
let message;
|
|
2539
2643
|
try {
|
|
@@ -2573,6 +2677,12 @@ export async function handleNodeControlMessage(args) {
|
|
|
2573
2677
|
// check, the provider-row upsert, or the aggregate recompute.
|
|
2574
2678
|
return serializeNodeOp(args.workspaceId, args.nodeId, async () => {
|
|
2575
2679
|
try {
|
|
2680
|
+
// A socket outlives its workspace's deletion, and every other frame writes
|
|
2681
|
+
// durable state (a heartbeat can insert provider and action rows). Only
|
|
2682
|
+
// frames that settle work already in flight or tear identity down pass.
|
|
2683
|
+
if (!SETTLING_CONTROL_FRAMES.has(message.type)) {
|
|
2684
|
+
await assertWorkspaceUnexpired(args.db, args.workspaceId);
|
|
2685
|
+
}
|
|
2576
2686
|
switch (message.type) {
|
|
2577
2687
|
case 'node.register': {
|
|
2578
2688
|
const provider = resolveProviderIdentity(message, args.connectionId);
|
|
@@ -2705,7 +2815,10 @@ export async function handleNodeControlMessage(args) {
|
|
|
2705
2815
|
return;
|
|
2706
2816
|
}
|
|
2707
2817
|
case 'agent.register': {
|
|
2708
|
-
const registered = await registerAgentViaNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message, {
|
|
2818
|
+
const registered = await registerAgentViaNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message, {
|
|
2819
|
+
deliveryCursorSupported: supportsProviderDeliveryReadiness(args.registry),
|
|
2820
|
+
admitRegistration: args.admitRegistration,
|
|
2821
|
+
});
|
|
2709
2822
|
// The relay broker's node_control client awaits a `reply` frame keyed by
|
|
2710
2823
|
// the request id (it matches `pending_agent_registrations` by `reply.id`
|
|
2711
2824
|
// and parses `data` as {agent_id, token, name} with deny_unknown_fields).
|
|
@@ -2732,6 +2845,11 @@ export async function handleNodeControlMessage(args) {
|
|
|
2732
2845
|
}
|
|
2733
2846
|
case 'agent.recover': {
|
|
2734
2847
|
const recovered = await recoverAgentViaNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message, { deliveryCursorSupported: supportsProviderDeliveryReadiness(args.registry) });
|
|
2848
|
+
// Inventory only clears a hold for an agent it newly routes. A
|
|
2849
|
+
// presence-only disconnect leaves the binding active, so the next
|
|
2850
|
+
// sync would keep the hold. Recovery is the node putting that agent
|
|
2851
|
+
// back, and node liveness should count them without an HTTP heartbeat.
|
|
2852
|
+
await args.presence?.clearExplicitDisconnect?.(args.workspaceId, recovered.agent_id);
|
|
2735
2853
|
const replySent = sendControl(args.socket, {
|
|
2736
2854
|
v: 1,
|
|
2737
2855
|
id: requestId(message),
|
|
@@ -2755,6 +2873,13 @@ export async function handleNodeControlMessage(args) {
|
|
|
2755
2873
|
return;
|
|
2756
2874
|
case 'inventory.sync': {
|
|
2757
2875
|
const result = await reconcileInventory(args.db, args.registry, args.workspaceId, args.nodeId, frameProviderName, message.agents, args.completionDeps);
|
|
2876
|
+
// An agent the node had dropped and is routing again is a new hosting
|
|
2877
|
+
// episode. Clear a presence-only disconnect so node liveness can count
|
|
2878
|
+
// them without an HTTP heartbeat. An agent the snapshot already
|
|
2879
|
+
// routed keeps the hold: still being listed is not recovery.
|
|
2880
|
+
if (args.presence?.clearExplicitDisconnect && result.newlyRoutedAgentIds.length > 0) {
|
|
2881
|
+
await Promise.all(result.newlyRoutedAgentIds.map((agentId) => args.presence.clearExplicitDisconnect(args.workspaceId, agentId)));
|
|
2882
|
+
}
|
|
2758
2883
|
const replySent = sendControl(args.socket, {
|
|
2759
2884
|
v: 1,
|
|
2760
2885
|
id: requestId(message),
|
|
@@ -2836,7 +2961,8 @@ export async function handleNodeControlMessage(args) {
|
|
|
2836
2961
|
}
|
|
2837
2962
|
catch (err) {
|
|
2838
2963
|
const error = err;
|
|
2839
|
-
const
|
|
2964
|
+
const denial = err instanceof AdmissionDeniedError ? err.denial : null;
|
|
2965
|
+
const code = denial?.code ?? error.code ?? 'node_control_failed';
|
|
2840
2966
|
// The rejection reaches the client only as the error frame below. Log it so
|
|
2841
2967
|
// it is not invisible server-side: a rejected node.register/node.heartbeat
|
|
2842
2968
|
// (e.g. node_name_conflict, a UNIQUE violation) otherwise leaves a node
|
|
@@ -2850,7 +2976,10 @@ export async function handleNodeControlMessage(args) {
|
|
|
2850
2976
|
provider: frameProviderName,
|
|
2851
2977
|
message: error.message,
|
|
2852
2978
|
});
|
|
2979
|
+
// An admission denial carries the host's details, as on the HTTP error;
|
|
2980
|
+
// the frame's own fields always win over them.
|
|
2853
2981
|
sendControl(args.socket, {
|
|
2982
|
+
...(denial?.details ?? {}),
|
|
2854
2983
|
v: 1,
|
|
2855
2984
|
id: requestId(message),
|
|
2856
2985
|
type: 'error',
|
|
@@ -2861,4 +2990,28 @@ export async function handleNodeControlMessage(args) {
|
|
|
2861
2990
|
}
|
|
2862
2991
|
});
|
|
2863
2992
|
}
|
|
2993
|
+
async function publicNodes(db, rows, now, status) {
|
|
2994
|
+
if (!nodeLivenessAuthority(db))
|
|
2995
|
+
return rows.map(row => publicNode(row, now));
|
|
2996
|
+
const result = [];
|
|
2997
|
+
await filterAsync(rows, async (row) => {
|
|
2998
|
+
const snapshot = await inspectNodeLiveness(db, row.workspaceId, row.id);
|
|
2999
|
+
const live = !!snapshot && snapshotOnline(snapshot);
|
|
3000
|
+
const item = publicNode(row, now, live, snapshot?.status ?? 'unknown');
|
|
3001
|
+
// Readiness is owned by the fresh lease, independently of checkpointed
|
|
3002
|
+
// catalog metadata in nodes.handlers_live.
|
|
3003
|
+
const receivedAt = Date.now();
|
|
3004
|
+
item.handlers_live = live && !!snapshot?.providers.some(p => p.live && p.handlers_live && p.expires_at > receivedAt && p.last_received_at <= receivedAt);
|
|
3005
|
+
if (!status || item.status === status)
|
|
3006
|
+
result.push(item);
|
|
3007
|
+
return true;
|
|
3008
|
+
});
|
|
3009
|
+
// Concurrent probes must not scramble stable list ordering.
|
|
3010
|
+
const positions = new Map(rows.map((row, index) => [row.id, index]));
|
|
3011
|
+
return result.sort((a, b) => positions.get(a.id) - positions.get(b.id));
|
|
3012
|
+
}
|
|
3013
|
+
async function reusableMachineNode(db, node) {
|
|
3014
|
+
const authority = await inspectNodeLiveness(db, node.workspaceId, node.id);
|
|
3015
|
+
return authority ? authority.status === 'offline' : isReusableForMachineMatch(node);
|
|
3016
|
+
}
|
|
2864
3017
|
//# sourceMappingURL=node.js.map
|