@relaycast/engine 10.2.0 → 10.3.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 (41) hide show
  1. package/dist/adapters/node/realtime.d.ts.map +1 -1
  2. package/dist/adapters/node/realtime.js +3 -1
  3. package/dist/adapters/node/realtime.js.map +1 -1
  4. package/dist/db/migrations/0071_node_liveness_revision.sql +123 -0
  5. package/dist/db/schema.d.ts +68 -0
  6. package/dist/db/schema.d.ts.map +1 -1
  7. package/dist/db/schema.js +20 -0
  8. package/dist/db/schema.js.map +1 -1
  9. package/dist/engine/action.d.ts +4 -1
  10. package/dist/engine/action.d.ts.map +1 -1
  11. package/dist/engine/action.js +174 -33
  12. package/dist/engine/action.js.map +1 -1
  13. package/dist/engine/delivery.d.ts.map +1 -1
  14. package/dist/engine/delivery.js +33 -2
  15. package/dist/engine/delivery.js.map +1 -1
  16. package/dist/engine/invocationCompletion.d.ts +2 -0
  17. package/dist/engine/invocationCompletion.d.ts.map +1 -1
  18. package/dist/engine/invocationCompletion.js +21 -6
  19. package/dist/engine/invocationCompletion.js.map +1 -1
  20. package/dist/engine/node.d.ts +46 -1
  21. package/dist/engine/node.d.ts.map +1 -1
  22. package/dist/engine/node.js +126 -29
  23. package/dist/engine/node.js.map +1 -1
  24. package/dist/engine/nodeProvider.d.ts +20 -0
  25. package/dist/engine/nodeProvider.d.ts.map +1 -1
  26. package/dist/engine/nodeProvider.js +69 -10
  27. package/dist/engine/nodeProvider.js.map +1 -1
  28. package/dist/engine/taskInvocation.d.ts +1 -0
  29. package/dist/engine/taskInvocation.d.ts.map +1 -1
  30. package/dist/engine/workspaceEvents.d.ts +11 -0
  31. package/dist/engine/workspaceEvents.d.ts.map +1 -1
  32. package/dist/engine/workspaceEvents.js +44 -0
  33. package/dist/engine/workspaceEvents.js.map +1 -1
  34. package/dist/node-control.d.ts +1 -1
  35. package/dist/node-control.d.ts.map +1 -1
  36. package/dist/node-control.js +6 -1
  37. package/dist/node-control.js.map +1 -1
  38. package/dist/ports/realtime.d.ts +2 -0
  39. package/dist/ports/realtime.d.ts.map +1 -1
  40. package/dist/ports/realtime.js.map +1 -1
  41. package/package.json +3 -3
@@ -12,7 +12,7 @@ import { generateId } from './snowflake.js';
12
12
  import { assertRegistrableAgentName, agentLivenessLoss, agentLivenessRefresh, RELEASED_AGENT_STATUS } from './agent.js';
13
13
  import { rotateAgentIdentity } from './agentIdentity.js';
14
14
  import { isNodeLive, isReusableForMachineMatch, nodeHasCapacity, NODE_LIVENESS_TTL_MS } from './placement.js';
15
- import { DEFAULT_PROVIDER_NAME, assertActionTaskMode, capabilityKind, heartbeatProvider, isProviderLive, markProviderOffline, materializeProviderActions, providerActionsMaterialized, recomputeNodeAggregate, removeProvider, upsertProvider, } from './nodeProvider.js';
15
+ import { DEFAULT_PROVIDER_NAME, assertActionTaskMode, capabilityKind, heartbeatProvider, isProviderLive, markProviderOffline, materializeProviderActions, providerActionsMaterialized, recomputeNodeAggregate, refreshNodeAggregate, removeProvider, upsertProvider, } from './nodeProvider.js';
16
16
  import { serializeNodeOp } from './nodeLock.js';
17
17
  import { completeNodeInvocation, dispatchCapacitySpawn, rescheduleInvocationsForLostNode, rescheduleNodeInvocation, } from './action.js';
18
18
  import { emitInvocationCompletionEffects, emitAgentExitedEffects, emitNodeStatusEffects, fleetInvocationId } from './invocationCompletion.js';
@@ -704,7 +704,11 @@ export async function registerNode(db, workspaceId, authenticatedNodeId, message
704
704
  }));
705
705
  return { node: publicNode(updated), acceptance, provider };
706
706
  }
707
- export async function heartbeatNode(db, workspaceId, nodeId, providerName, message) {
707
+ export async function heartbeatNode(db, workspaceId, nodeId, providerName, message,
708
+ // A caller that already read the node row may pass it to skip the role
709
+ // probe: `null` means "the row was missing", an object supplies its role.
710
+ // `undefined` keeps the historical own-read behavior.
711
+ priorNode) {
708
712
  // A heartbeat is provider-scoped by connection. It may carry the provider's
709
713
  // roster snapshot so the engine can refresh the descriptor between (or in the
710
714
  // absence of a fresh) node.register — e.g. after an engine restart where the
@@ -713,10 +717,12 @@ export async function heartbeatNode(db, workspaceId, nodeId, providerName, messa
713
717
  if (message.node_id !== undefined && message.node_id !== nodeId) {
714
718
  throw codedError('node_id does not match the authenticated node token', 'node_id_mismatch', 403);
715
719
  }
716
- const [nodeState] = await db
717
- .select({ role: nodes.role })
718
- .from(nodes)
719
- .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
720
+ const nodeState = priorNode === undefined
721
+ ? (await db
722
+ .select({ role: nodes.role })
723
+ .from(nodes)
724
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId))))[0]
725
+ : priorNode;
720
726
  if (!nodeState)
721
727
  return null;
722
728
  if (nodeState.role === 'direct') {
@@ -818,22 +824,55 @@ export async function heartbeatNode(db, workspaceId, nodeId, providerName, messa
818
824
  // ordering the separate stamp used to guarantee: the aggregate derives
819
825
  // lastHeartbeatAt from provider rows, which registration also writes,
820
826
  // whereas this column is only ever set by an arriving frame.
821
- updated = await recomputeNodeAggregate(tx, workspaceId, nodeId, {
822
- ...(message.version !== undefined ? { version: message.version } : {}),
823
- provenLiveAt: now,
824
- });
827
+ // A capability-free beat cannot change the manifest union, so it takes the
828
+ // single-statement aggregate path; only a beat carrying `capabilities`
829
+ // pays the provider-listing recompute.
830
+ updated = message.capabilities === undefined
831
+ ? await refreshNodeAggregate(tx, workspaceId, nodeId, {
832
+ ...(message.version !== undefined ? { version: message.version } : {}),
833
+ provenLiveAt: now,
834
+ })
835
+ : await recomputeNodeAggregate(tx, workspaceId, nodeId, {
836
+ ...(message.version !== undefined ? { version: message.version } : {}),
837
+ provenLiveAt: now,
838
+ });
825
839
  });
826
840
  return updated ? publicNode(updated) : null;
827
841
  }
828
- export async function markNodeOffline(db, registry, workspaceId, nodeId, effects) {
829
- // Capture the node's name + prior status BEFORE flipping it so a durable
830
- // node.status.offline is emitted only on a real online -> offline transition
831
- // (never on a node that was already offline).
832
- const [before] = await db
833
- .select({ status: nodes.status, name: nodes.name })
834
- .from(nodes)
835
- .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
836
- await db
842
+ /**
843
+ * Flip a node's row offline in ONE conditional UPDATE — the only part of an
844
+ * offline transition a caller must serialize against reconnects. `expected`
845
+ * carries the heartbeat the caller's stale-read observed: any heartbeat or
846
+ * registration that landed since rewrote `last_heartbeat_at`, the CAS misses,
847
+ * and the caller must abandon the transition rather than clobber the live
848
+ * node. Returns the claimed row's name, timestamp, and monotonic revision; the
849
+ * revision is the bound the settle tail scopes its writes to. Returns undefined
850
+ * when the claim was lost.
851
+ */
852
+ export async function claimNodeOffline(db, workspaceId, nodeId, expected) {
853
+ const claimAt = new Date();
854
+ const nextRevision = sql `MAX(
855
+ ${nodes.livenessRevision},
856
+ COALESCE((
857
+ SELECT MAX(np.liveness_revision)
858
+ FROM node_providers np
859
+ WHERE np.workspace_id = ${workspaceId} AND np.node_id = ${nodeId}
860
+ ), 0),
861
+ COALESCE((
862
+ SELECT MAX(a.node_liveness_revision)
863
+ FROM agents a
864
+ WHERE a.workspace_id = ${workspaceId} AND a.location_node_id = ${nodeId}
865
+ AND a.location_type = 'via_node'
866
+ AND a.status NOT IN ('offline', 'released')
867
+ ), 0),
868
+ COALESCE((
869
+ SELECT MAX(ai.node_liveness_revision)
870
+ FROM action_invocations ai
871
+ WHERE ai.workspace_id = ${workspaceId} AND ai.dispatched_node_id = ${nodeId}
872
+ AND ai.status IN ('pending', 'dispatched', 'invoked')
873
+ ), 0)
874
+ ) + 1`;
875
+ const [claimed] = await db
837
876
  .update(nodes)
838
877
  .set({
839
878
  status: 'offline',
@@ -841,29 +880,86 @@ export async function markNodeOffline(db, registry, workspaceId, nodeId, effects
841
880
  load: 0,
842
881
  loadReported: false,
843
882
  activeAgents: 0,
844
- lastHeartbeatAt: new Date(),
883
+ // The written heartbeat IS the claim epoch: settle tails and later
884
+ // transitions detect a post-claim liveness write by observing a
885
+ // `last_heartbeat_at` newer than `claimAt`, so both values must be the
886
+ // same instant (second-granular column — two `new Date()` calls could
887
+ // straddle a boundary).
888
+ lastHeartbeatAt: claimAt,
889
+ // This is the durable claim boundary. Triggers stamp every later child
890
+ // liveness mutation above it, including writes in the same wall-clock
891
+ // second, so the settle tail never relies on timestamp ordering.
892
+ livenessRevision: nextRevision,
845
893
  })
846
- .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
894
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId), eq(nodes.status, 'online'), eq(nodes.livenessRevision, expected.livenessRevision), expected.lastHeartbeatAt === null
895
+ ? isNull(nodes.lastHeartbeatAt)
896
+ : eq(nodes.lastHeartbeatAt, expected.lastHeartbeatAt)))
897
+ .returning({ name: nodes.name, claimRevision: nodes.livenessRevision });
898
+ return claimed ? { name: claimed.name, claimAt, claimRevision: claimed.claimRevision } : undefined;
899
+ }
900
+ /**
901
+ * The resumable tail of an offline transition: provider/agent liveness loss,
902
+ * invocation rescheduling, and the durable event. Safe to run after the
903
+ * node's claim row is released and concurrently with a reconnect, because
904
+ * every write is claim-scoped — `claimRevision` bounds the transition to rows
905
+ * stamped before the claim, so a provider, hosted agent, or invocation changed
906
+ * after it is never clobbered even when both writes share a timestamp second.
907
+ * Callers that cannot interleave (in-process serialized paths) omit the claim
908
+ * fields for the legacy unconditional sweep.
909
+ */
910
+ export async function settleNodeOffline(db, registry, workspaceId, nodeId, opts = {}) {
911
+ const claimRevision = opts.claimRevision;
847
912
  // Provider capability sets persist (offline nodes still show their manifest);
848
913
  // only their liveness flips. Zero activeAgents too, so a later
849
914
  // recomputeNodeAggregate never resurrects a dropped provider's agent count.
850
915
  await db
851
916
  .update(nodeProviders)
852
917
  .set({ status: 'offline', handlersLive: false, load: 0, loadReported: false, activeAgents: 0, lastHeartbeatAt: new Date() })
853
- .where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId)));
918
+ .where(and(eq(nodeProviders.workspaceId, workspaceId), eq(nodeProviders.nodeId, nodeId), ...(claimRevision !== undefined
919
+ ? [lt(nodeProviders.livenessRevision, claimRevision)]
920
+ : [])));
854
921
  await db
855
922
  .update(agents)
856
923
  .set({ ...agentLivenessLoss(), lastSeen: new Date() })
857
- .where(and(eq(agents.workspaceId, workspaceId), ne(agents.status, 'offline'), ne(agents.status, RELEASED_AGENT_STATUS), eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId)));
858
- await rescheduleInvocationsForLostNode(db, registry, workspaceId, nodeId);
859
- if (effects?.deps && before && before.status === 'online') {
860
- await emitNodeStatusEffects(effects.deps, workspaceId, {
924
+ .where(and(eq(agents.workspaceId, workspaceId), ne(agents.status, 'offline'), ne(agents.status, RELEASED_AGENT_STATUS), eq(agents.locationType, 'via_node'), eq(agents.locationNodeId, nodeId), ...(claimRevision !== undefined
925
+ ? [lt(agents.nodeLivenessRevision, claimRevision)]
926
+ : [])));
927
+ await rescheduleInvocationsForLostNode(db, registry, workspaceId, nodeId, { claimRevision });
928
+ if (opts.deps && opts.before && opts.before.status === 'online') {
929
+ await emitNodeStatusEffects(opts.deps, workspaceId, {
861
930
  status: 'offline',
862
931
  nodeId,
863
- nodeName: before.name,
864
- reason: effects.reason,
932
+ nodeName: opts.before.name,
933
+ reason: opts.reason,
934
+ ...(claimRevision !== undefined ? { claimRevision } : {}),
865
935
  });
866
936
  }
937
+ return true;
938
+ }
939
+ export async function markNodeOffline(db, registry, workspaceId, nodeId, effects) {
940
+ // Capture the node's name + prior status BEFORE flipping it so a durable
941
+ // node.status.offline is emitted only on a real online -> offline transition
942
+ // (never on a node that was already offline).
943
+ const [before] = await db
944
+ .select({ status: nodes.status, name: nodes.name })
945
+ .from(nodes)
946
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
947
+ await db
948
+ .update(nodes)
949
+ .set({
950
+ status: 'offline',
951
+ handlersLive: false,
952
+ load: 0,
953
+ loadReported: false,
954
+ activeAgents: 0,
955
+ lastHeartbeatAt: new Date(),
956
+ })
957
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
958
+ await settleNodeOffline(db, registry, workspaceId, nodeId, {
959
+ before,
960
+ deps: effects?.deps,
961
+ reason: effects?.reason,
962
+ });
867
963
  }
868
964
  /**
869
965
  * A provider's control connection dropped. Its capability set persists (marked
@@ -2295,6 +2391,7 @@ async function readHeartbeatDrainState(db, workspaceId, nodeId, providerName) {
2295
2391
  db
2296
2392
  .select({
2297
2393
  status: nodes.status,
2394
+ role: nodes.role,
2298
2395
  handlersLive: nodes.handlersLive,
2299
2396
  maxAgents: nodes.maxAgents,
2300
2397
  activeAgents: nodes.activeAgents,
@@ -2474,7 +2571,7 @@ export async function handleNodeControlMessage(args) {
2474
2571
  }
2475
2572
  case 'node.heartbeat': {
2476
2573
  const prior = await readHeartbeatDrainState(args.db, args.workspaceId, args.nodeId, frameProviderName);
2477
- const beat = await heartbeatNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message);
2574
+ const beat = await heartbeatNode(args.db, args.workspaceId, args.nodeId, frameProviderName, message, prior.node ?? null);
2478
2575
  // The node row is already persisted, so emit the durable online
2479
2576
  // transition before draining: a drainNode rejection must not skip the
2480
2577
  // node.status.online event. Isolated so its own failure can't either.