@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.
Files changed (139) hide show
  1. package/dist/adapters/node/index.d.ts.map +1 -1
  2. package/dist/adapters/node/index.js +1 -0
  3. package/dist/adapters/node/index.js.map +1 -1
  4. package/dist/adapters/node/presence.d.ts +18 -0
  5. package/dist/adapters/node/presence.d.ts.map +1 -1
  6. package/dist/adapters/node/presence.js +82 -0
  7. package/dist/adapters/node/presence.js.map +1 -1
  8. package/dist/adapters/node/realtime.d.ts +4 -0
  9. package/dist/adapters/node/realtime.d.ts.map +1 -1
  10. package/dist/adapters/node/realtime.js +32 -5
  11. package/dist/adapters/node/realtime.js.map +1 -1
  12. package/dist/db/schema.d.ts +2 -2
  13. package/dist/engine/a2a.d.ts +3 -0
  14. package/dist/engine/a2a.d.ts.map +1 -1
  15. package/dist/engine/a2a.js +1 -0
  16. package/dist/engine/a2a.js.map +1 -1
  17. package/dist/engine/action.d.ts +14 -9
  18. package/dist/engine/action.d.ts.map +1 -1
  19. package/dist/engine/action.js +342 -80
  20. package/dist/engine/action.js.map +1 -1
  21. package/dist/engine/agent.d.ts +9 -3
  22. package/dist/engine/agent.d.ts.map +1 -1
  23. package/dist/engine/agent.js +58 -12
  24. package/dist/engine/agent.js.map +1 -1
  25. package/dist/engine/delivery.d.ts.map +1 -1
  26. package/dist/engine/delivery.js +9 -0
  27. package/dist/engine/delivery.js.map +1 -1
  28. package/dist/engine/deliveryWrites.d.ts +23 -0
  29. package/dist/engine/deliveryWrites.d.ts.map +1 -1
  30. package/dist/engine/deliveryWrites.js +50 -8
  31. package/dist/engine/deliveryWrites.js.map +1 -1
  32. package/dist/engine/eventDelivery.d.ts +1 -1
  33. package/dist/engine/groupDm.d.ts.map +1 -1
  34. package/dist/engine/groupDm.js +4 -0
  35. package/dist/engine/groupDm.js.map +1 -1
  36. package/dist/engine/inboundWebhook.d.ts.map +1 -1
  37. package/dist/engine/inboundWebhook.js +20 -4
  38. package/dist/engine/inboundWebhook.js.map +1 -1
  39. package/dist/engine/inbox.d.ts.map +1 -1
  40. package/dist/engine/inbox.js +17 -0
  41. package/dist/engine/inbox.js.map +1 -1
  42. package/dist/engine/invocationClaim.d.ts +32 -0
  43. package/dist/engine/invocationClaim.d.ts.map +1 -0
  44. package/dist/engine/invocationClaim.js +42 -0
  45. package/dist/engine/invocationClaim.js.map +1 -0
  46. package/dist/engine/message.d.ts.map +1 -1
  47. package/dist/engine/message.js +4 -0
  48. package/dist/engine/message.js.map +1 -1
  49. package/dist/engine/messageMetadata.d.ts +2 -0
  50. package/dist/engine/messageMetadata.d.ts.map +1 -1
  51. package/dist/engine/messageMetadata.js +3 -2
  52. package/dist/engine/messageMetadata.js.map +1 -1
  53. package/dist/engine/node.d.ts +59 -44
  54. package/dist/engine/node.d.ts.map +1 -1
  55. package/dist/engine/node.js +179 -26
  56. package/dist/engine/node.js.map +1 -1
  57. package/dist/engine/nodeLiveness.d.ts +34 -0
  58. package/dist/engine/nodeLiveness.d.ts.map +1 -0
  59. package/dist/engine/nodeLiveness.js +52 -0
  60. package/dist/engine/nodeLiveness.js.map +1 -0
  61. package/dist/engine/placement.d.ts.map +1 -1
  62. package/dist/engine/placement.js +15 -18
  63. package/dist/engine/placement.js.map +1 -1
  64. package/dist/engine/presence.d.ts +173 -1
  65. package/dist/engine/presence.d.ts.map +1 -1
  66. package/dist/engine/presence.js +112 -8
  67. package/dist/engine/presence.js.map +1 -1
  68. package/dist/engine/registrationAdmission.d.ts +40 -0
  69. package/dist/engine/registrationAdmission.d.ts.map +1 -0
  70. package/dist/engine/registrationAdmission.js +78 -0
  71. package/dist/engine/registrationAdmission.js.map +1 -0
  72. package/dist/engine/taskInvocation.d.ts +1 -1
  73. package/dist/engine/thread.d.ts.map +1 -1
  74. package/dist/engine/thread.js +8 -0
  75. package/dist/engine/thread.js.map +1 -1
  76. package/dist/engine/workspace.d.ts +26 -1
  77. package/dist/engine/workspace.d.ts.map +1 -1
  78. package/dist/engine/workspace.js +108 -7
  79. package/dist/engine/workspace.js.map +1 -1
  80. package/dist/engine/workspaceDrain.d.ts +38 -0
  81. package/dist/engine/workspaceDrain.d.ts.map +1 -0
  82. package/dist/engine/workspaceDrain.js +202 -0
  83. package/dist/engine/workspaceDrain.js.map +1 -0
  84. package/dist/engine.d.ts.map +1 -1
  85. package/dist/engine.js +31 -0
  86. package/dist/engine.js.map +1 -1
  87. package/dist/index.d.ts +6 -2
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +6 -1
  90. package/dist/index.js.map +1 -1
  91. package/dist/lib/engineDiagnostics.d.ts +26 -0
  92. package/dist/lib/engineDiagnostics.d.ts.map +1 -0
  93. package/dist/lib/engineDiagnostics.js +100 -0
  94. package/dist/lib/engineDiagnostics.js.map +1 -0
  95. package/dist/lib/httpError.d.ts.map +1 -1
  96. package/dist/lib/httpError.js +5 -1
  97. package/dist/lib/httpError.js.map +1 -1
  98. package/dist/middleware/admission.d.ts +7 -0
  99. package/dist/middleware/admission.d.ts.map +1 -1
  100. package/dist/middleware/admission.js +19 -1
  101. package/dist/middleware/admission.js.map +1 -1
  102. package/dist/middleware/rateLimit.d.ts.map +1 -1
  103. package/dist/middleware/rateLimit.js +8 -1
  104. package/dist/middleware/rateLimit.js.map +1 -1
  105. package/dist/ports/admission.d.ts +24 -3
  106. package/dist/ports/admission.d.ts.map +1 -1
  107. package/dist/ports/database.d.ts.map +1 -1
  108. package/dist/ports/database.js +6 -1
  109. package/dist/ports/database.js.map +1 -1
  110. package/dist/ports/diagnostics.d.ts +42 -0
  111. package/dist/ports/diagnostics.d.ts.map +1 -0
  112. package/dist/ports/diagnostics.js +13 -0
  113. package/dist/ports/diagnostics.js.map +1 -0
  114. package/dist/ports/index.d.ts +13 -1
  115. package/dist/ports/index.d.ts.map +1 -1
  116. package/dist/ports/index.js.map +1 -1
  117. package/dist/ports/nodeLiveness.d.ts +23 -0
  118. package/dist/ports/nodeLiveness.d.ts.map +1 -0
  119. package/dist/ports/nodeLiveness.js +12 -0
  120. package/dist/ports/nodeLiveness.js.map +1 -0
  121. package/dist/ports/presence.d.ts +21 -0
  122. package/dist/ports/presence.d.ts.map +1 -1
  123. package/dist/ports/realtime.d.ts +18 -0
  124. package/dist/ports/realtime.d.ts.map +1 -1
  125. package/dist/ports/realtime.js.map +1 -1
  126. package/dist/routes/a2a.d.ts.map +1 -1
  127. package/dist/routes/a2a.js +2 -0
  128. package/dist/routes/a2a.js.map +1 -1
  129. package/dist/routes/action.js +3 -0
  130. package/dist/routes/action.js.map +1 -1
  131. package/dist/routes/agent.d.ts.map +1 -1
  132. package/dist/routes/agent.js +5 -1
  133. package/dist/routes/agent.js.map +1 -1
  134. package/dist/routes/node.d.ts.map +1 -1
  135. package/dist/routes/node.js +2 -1
  136. package/dist/routes/node.js.map +1 -1
  137. package/dist/routes/workspace.js +10 -2
  138. package/dist/routes/workspace.js.map +1 -1
  139. package/package.json +3 -3
@@ -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 `node_direct_${agentId}`;
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), ...(hasPresentedTokenProof && !authorizedDirectNode
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 && isReusableForMachineMatch(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 candidates.find((node) => isReusableForMachineMatch(node)) ?? null;
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.filter((node) => !isNodeLive(node));
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' || isNodeLive(current))
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 && isNodeLive(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 liveProviderNames = (await tx
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
- .filter((provider) => isProviderLive(provider))
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
- canAdoptLegacyDefault = !!brokerProvider && isProviderLive(brokerProvider)
2067
- && (!defaultProvider || !isProviderLive(defaultProvider));
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 && isNodeLive(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
- const noLiveDefaultProvider = notExists(db
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
- const liveBrokerProvider = exists(db
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 = rows.map((row) => publicNode(row, now.getTime()));
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 = page.map((row) => publicNode(row, now.getTime()));
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 ? publicNode(node) : null;
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, { deliveryCursorSupported: supportsProviderDeliveryReadiness(args.registry) });
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 code = error.code ?? 'node_control_failed';
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