@relaycast/engine 12.1.0 → 12.2.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.
Files changed (71) hide show
  1. package/dist/db/migrations/0078_dm_participant_read_cursor.sql +15 -0
  2. package/dist/db/schema.d.ts +19 -0
  3. package/dist/db/schema.d.ts.map +1 -1
  4. package/dist/db/schema.js +3 -0
  5. package/dist/db/schema.js.map +1 -1
  6. package/dist/engine/action.d.ts.map +1 -1
  7. package/dist/engine/action.js +7 -6
  8. package/dist/engine/action.js.map +1 -1
  9. package/dist/engine/agent.d.ts.map +1 -1
  10. package/dist/engine/agent.js +37 -9
  11. package/dist/engine/agent.js.map +1 -1
  12. package/dist/engine/delivery.d.ts.map +1 -1
  13. package/dist/engine/delivery.js +75 -31
  14. package/dist/engine/delivery.js.map +1 -1
  15. package/dist/engine/deliveryWrites.d.ts +23 -0
  16. package/dist/engine/deliveryWrites.d.ts.map +1 -1
  17. package/dist/engine/deliveryWrites.js +50 -8
  18. package/dist/engine/deliveryWrites.js.map +1 -1
  19. package/dist/engine/groupDm.d.ts.map +1 -1
  20. package/dist/engine/groupDm.js +4 -0
  21. package/dist/engine/groupDm.js.map +1 -1
  22. package/dist/engine/inboundWebhook.d.ts.map +1 -1
  23. package/dist/engine/inboundWebhook.js +8 -1
  24. package/dist/engine/inboundWebhook.js.map +1 -1
  25. package/dist/engine/inbox.d.ts.map +1 -1
  26. package/dist/engine/inbox.js +10 -5
  27. package/dist/engine/inbox.js.map +1 -1
  28. package/dist/engine/message.d.ts.map +1 -1
  29. package/dist/engine/message.js +4 -0
  30. package/dist/engine/message.js.map +1 -1
  31. package/dist/engine/node.d.ts +47 -42
  32. package/dist/engine/node.d.ts.map +1 -1
  33. package/dist/engine/node.js +96 -22
  34. package/dist/engine/node.js.map +1 -1
  35. package/dist/engine/nodeLiveness.d.ts +34 -0
  36. package/dist/engine/nodeLiveness.d.ts.map +1 -0
  37. package/dist/engine/nodeLiveness.js +52 -0
  38. package/dist/engine/nodeLiveness.js.map +1 -0
  39. package/dist/engine/placement.d.ts.map +1 -1
  40. package/dist/engine/placement.js +15 -18
  41. package/dist/engine/placement.js.map +1 -1
  42. package/dist/engine/presence.d.ts.map +1 -1
  43. package/dist/engine/presence.js +33 -6
  44. package/dist/engine/presence.js.map +1 -1
  45. package/dist/engine/receipt.d.ts +100 -0
  46. package/dist/engine/receipt.d.ts.map +1 -1
  47. package/dist/engine/receipt.js +12 -0
  48. package/dist/engine/receipt.js.map +1 -1
  49. package/dist/engine/thread.d.ts.map +1 -1
  50. package/dist/engine/thread.js +8 -0
  51. package/dist/engine/thread.js.map +1 -1
  52. package/dist/engine.d.ts.map +1 -1
  53. package/dist/engine.js +3 -0
  54. package/dist/engine.js.map +1 -1
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +1 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/ports/database.d.ts.map +1 -1
  60. package/dist/ports/database.js +6 -1
  61. package/dist/ports/database.js.map +1 -1
  62. package/dist/ports/index.d.ts +4 -0
  63. package/dist/ports/index.d.ts.map +1 -1
  64. package/dist/ports/index.js.map +1 -1
  65. package/dist/ports/nodeLiveness.d.ts +23 -0
  66. package/dist/ports/nodeLiveness.d.ts.map +1 -0
  67. package/dist/ports/nodeLiveness.js +12 -0
  68. package/dist/ports/nodeLiveness.js.map +1 -0
  69. package/dist/ports/presence.d.ts +7 -0
  70. package/dist/ports/presence.d.ts.map +1 -1
  71. package/package.json +3 -3
@@ -1,3 +1,4 @@
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';
@@ -219,8 +220,8 @@ async function connectionNegotiatedDeliveryCursor(db, registry, workspaceId, nod
219
220
  function requestId(message) {
220
221
  return message.id ?? generateId();
221
222
  }
222
- function publicNode(row, now) {
223
- const live = isNodeLive(row, now);
223
+ function publicNode(row, now, authoritativeLive, authorityStatus) {
224
+ const live = authoritativeLive ?? isNodeLive(row, now);
224
225
  return {
225
226
  id: row.id,
226
227
  name: row.name,
@@ -233,6 +234,7 @@ function publicNode(row, now) {
233
234
  tags: row.tags,
234
235
  version: row.version,
235
236
  status: live ? 'online' : 'offline',
237
+ ...(authorityStatus ? { liveness_status: authorityStatus } : {}),
236
238
  live,
237
239
  handlers_live: live && row.handlersLive,
238
240
  load: row.loadReported ? row.load : null,
@@ -423,7 +425,9 @@ export async function createNodeToken(db, workspaceId, data, authorization = {})
423
425
  // Token proof is a generation fence, not just a read-time check. If a
424
426
  // concurrent rotation changes the credential after `existing` was read,
425
427
  // this request must not overwrite the winner with stale authority.
426
- .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
427
431
  ? [eq(nodes.tokenHash, authorization.expectedTokenHash)]
428
432
  : [])))
429
433
  .returning();
@@ -540,7 +544,7 @@ export async function getBrokerNodeByMachineId(db, workspaceId, machineId, expec
540
544
  .from(nodes)
541
545
  .where(and(reusableWhere, eq(nodes.tokenHash, expectedTokenHash)))
542
546
  .limit(1);
543
- if (provenCandidate && isReusableForMachineMatch(provenCandidate)) {
547
+ if (provenCandidate && await reusableMachineNode(db, provenCandidate)) {
544
548
  return provenCandidate;
545
549
  }
546
550
  }
@@ -554,7 +558,7 @@ export async function getBrokerNodeByMachineId(db, workspaceId, machineId, expec
554
558
  // single definition of reusability and mirrors it exactly — including
555
559
  // treating an `online` row with a future heartbeat as live. Re-checking here
556
560
  // keeps that definition authoritative rather than trusting the query alone.
557
- return candidates.find((node) => isReusableForMachineMatch(node)) ?? null;
561
+ return (await filterAsync(candidates, node => reusableMachineNode(db, node)))[0] ?? null;
558
562
  }
559
563
  /**
560
564
  * The role an enroll is asking for, derived from the request alone.
@@ -1072,8 +1076,14 @@ export async function deregisterProvider(db, registry, workspaceId, nodeId, prov
1072
1076
  }
1073
1077
  }
1074
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;
1075
1082
  const rows = await db.select().from(nodes).where(eq(nodes.status, 'online'));
1076
- 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
+ });
1077
1087
  let swept = 0;
1078
1088
  for (const node of stale) {
1079
1089
  const offlined = await serializeNodeOp(node.workspaceId, node.id, async () => {
@@ -1083,7 +1093,14 @@ export async function sweepOfflineNodes(db, registry, deps) {
1083
1093
  .select()
1084
1094
  .from(nodes)
1085
1095
  .where(and(eq(nodes.workspaceId, node.workspaceId), eq(nodes.id, node.id)));
1086
- 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)
1087
1104
  return false;
1088
1105
  await markNodeOffline(db, registry, node.workspaceId, node.id, { deps, reason: 'liveness_timeout' });
1089
1106
  return true;
@@ -1464,6 +1481,7 @@ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts
1464
1481
  // stranded on its implicit `direct-*` pseudo-node and never woken — the
1465
1482
  // exact shape of the broker's create-only register falling back to this
1466
1483
  // endpoint. `unbindAgentFromNode` already assumes this move happened.
1484
+ let incumbentNode;
1467
1485
  if (agent.status === 'active'
1468
1486
  && agent.locationNodeId
1469
1487
  && agent.locationNodeId !== node.id
@@ -1474,16 +1492,17 @@ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts
1474
1492
  .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, agent.locationNodeId)));
1475
1493
  // A dead or missing prior location is not a conflict — it is exactly what
1476
1494
  // a bind is for. Only a live one still owns the identity.
1477
- if (locatedNode && isNodeLive(locatedNode)) {
1495
+ if (locatedNode && await nodeMayOwn(db, locatedNode)) {
1478
1496
  throw codedError(`Agent "${agentName}" is already active on another live location`, 'agent_location_conflict', 409);
1479
1497
  }
1498
+ incumbentNode = locatedNode;
1480
1499
  }
1481
1500
  // Provider rows persist after a disconnect so the node keeps its capability
1482
1501
  // manifest; they are history, not sockets. Adopt only among providers that
1483
1502
  // can actually receive a push right now — keeping or choosing an offline
1484
1503
  // row while a live sibling serves the node would point the agent at a
1485
1504
  // provider connection that does not exist.
1486
- const liveProviderNames = (await tx
1505
+ const providerCandidates = await tx
1487
1506
  .select({
1488
1507
  name: nodeProviders.name,
1489
1508
  status: nodeProviders.status,
@@ -1491,9 +1510,8 @@ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts
1491
1510
  lastHeartbeatAt: nodeProviders.lastHeartbeatAt,
1492
1511
  })
1493
1512
  .from(nodeProviders)
1494
- .where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, node.id))))
1495
- .filter((provider) => isProviderLive(provider))
1496
- .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);
1497
1515
  const adoptedProviderName = adoptNodeProviderName(agent.providerName, liveProviderNames);
1498
1516
  const activeNodeIds = await activeBindingNodeIdsForAgent(tx, workspaceId, agent.id);
1499
1517
  const targetWasActive = activeNodeIds.includes(node.id);
@@ -1511,6 +1529,9 @@ export async function bindAgentToNode(db, workspaceId, nodeName, agentName, opts
1511
1529
  // charges the old node forever, because the retry that would fix either
1512
1530
  // reads the half-applied state as already done.
1513
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)))] : []),
1514
1535
  ...agentNodeBindingWrites(writeDb, workspaceId, agent, node.id, {
1515
1536
  sessionRef: opts.session_ref ?? null,
1516
1537
  priority: opts.priority ?? 0,
@@ -2061,7 +2082,9 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
2061
2082
  const acceptedInventoryAgents = [];
2062
2083
  const rejectedInventoryErrors = [];
2063
2084
  const legacyDefaultAdoptions = new Set();
2085
+ const incumbentRevisions = new Map();
2064
2086
  let canAdoptLegacyDefault;
2087
+ let authorityAdoption;
2065
2088
  let acceptedExistingAgentCount = 0;
2066
2089
  for (const item of inventoryAgents) {
2067
2090
  const [existing] = await db
@@ -2092,8 +2115,23 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
2092
2115
  .where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), inArray(nodeProviders.name, [DEFAULT_PROVIDER_NAME, 'broker'])));
2093
2116
  const defaultProvider = providerRows.find((row) => row.name === DEFAULT_PROVIDER_NAME);
2094
2117
  const brokerProvider = providerRows.find((row) => row.name === 'broker');
2095
- canAdoptLegacyDefault = !!brokerProvider && isProviderLive(brokerProvider)
2096
- && (!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
+ }
2097
2135
  }
2098
2136
  if (legacyDefaultClaim && canAdoptLegacyDefault) {
2099
2137
  legacyDefaultAdoptions.add(item.name);
@@ -2107,7 +2145,9 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
2107
2145
  .select()
2108
2146
  .from(nodes)
2109
2147
  .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, existing.locationNodeId ?? '')));
2110
- 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 });
2111
2151
  const conflict = existing.locationType !== 'via_node'
2112
2152
  || !existing.locationNodeId
2113
2153
  || (existing.locationNodeId !== nodeId && boundNodeLive && !isImplicitDirectLocation(existing));
@@ -2208,14 +2248,24 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
2208
2248
  // compare-and-set fences broker liveness loss, a default provider
2209
2249
  // reconnecting, and concurrent ownership changes during the apply pass.
2210
2250
  const adoptionNow = Date.now();
2211
- const noLiveDefaultProvider = notExists(db
2251
+ let noLiveDefaultProvider = notExists(db
2212
2252
  .select({ id: nodeProviders.id })
2213
2253
  .from(nodeProviders)
2214
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)))));
2215
- const liveBrokerProvider = exists(db
2255
+ let liveBrokerProvider = exists(db
2216
2256
  .select({ id: nodeProviders.id })
2217
2257
  .from(nodeProviders)
2218
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
+ }
2219
2269
  const [updated] = await db
2220
2270
  .update(agents)
2221
2271
  .set({
@@ -2227,7 +2277,7 @@ export async function reconcileInventory(db, registry, workspaceId, nodeId, prov
2227
2277
  originNodeId: existing.originNodeId ?? nodeId,
2228
2278
  sessionRef: item.session_ref ?? existing.sessionRef,
2229
2279
  })
2230
- .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 ? [
2231
2281
  eq(agents.providerName, DEFAULT_PROVIDER_NAME),
2232
2282
  eq(agents.locationType, 'via_node'),
2233
2283
  eq(agents.locationNodeId, nodeId),
@@ -2434,7 +2484,7 @@ export async function listNodes(db, workspaceId, filters = {}) {
2434
2484
  conditions.push(eq(nodes.name, filters.name));
2435
2485
  if (filters.capability)
2436
2486
  conditions.push(capabilityMatchCondition(filters.capability));
2437
- if (filters.status)
2487
+ if (filters.status && !nodeLivenessAuthority(db))
2438
2488
  conditions.push(livenessCondition(filters.status, now));
2439
2489
  // Observer authorization is a WHERE condition, evaluated in the same query
2440
2490
  // as every other filter and therefore before pagination/cursor computation
@@ -2448,7 +2498,7 @@ export async function listNodes(db, workspaceId, filters = {}) {
2448
2498
  // that predates the history/status selector. `name`/`capability`/`status`
2449
2499
  // are still evaluated in SQL instead of after a full-table fetch.
2450
2500
  const rows = await db.select().from(nodes).where(and(...conditions));
2451
- const page = rows.map((row) => publicNode(row, now.getTime()));
2501
+ const page = await publicNodes(db, rows, now.getTime(), filters.status);
2452
2502
  if (!filters.observerAgentIds)
2453
2503
  return { nodes: page, nextCursor: null };
2454
2504
  const counts = await visibleActiveAgentCounts(db, workspaceId, rows.map((row) => row.id), filters.observerAgentIds);
@@ -2468,7 +2518,7 @@ export async function listNodes(db, workspaceId, filters = {}) {
2468
2518
  .limit(limit + 1);
2469
2519
  const page = rows.slice(0, limit);
2470
2520
  const nextCursor = rows.length > limit ? page[page.length - 1].id : null;
2471
- const publicPage = page.map((row) => publicNode(row, now.getTime()));
2521
+ const publicPage = await publicNodes(db, page, now.getTime(), filters.status);
2472
2522
  if (!filters.observerAgentIds)
2473
2523
  return { nodes: publicPage, nextCursor };
2474
2524
  const counts = await visibleActiveAgentCounts(db, workspaceId, page.map((row) => row.id), filters.observerAgentIds);
@@ -2479,7 +2529,7 @@ export async function listNodes(db, workspaceId, filters = {}) {
2479
2529
  }
2480
2530
  export async function getPublicNode(db, workspaceId, name) {
2481
2531
  const node = await getNodeByName(db, workspaceId, name);
2482
- return node ? publicNode(node) : null;
2532
+ return node ? (await publicNodes(db, [node], Date.now()))[0] : null;
2483
2533
  }
2484
2534
  /** The node's persisted `status` column, or undefined when the node is gone. */
2485
2535
  async function readNodeStatus(db, workspaceId, nodeId) {
@@ -2940,4 +2990,28 @@ export async function handleNodeControlMessage(args) {
2940
2990
  }
2941
2991
  });
2942
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
+ }
2943
3017
  //# sourceMappingURL=node.js.map