@relaycast/engine 3.1.1 → 4.0.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 (166) hide show
  1. package/dist/adapters/node/event-queue.d.ts +10 -0
  2. package/dist/adapters/node/event-queue.d.ts.map +1 -1
  3. package/dist/adapters/node/event-queue.js +17 -0
  4. package/dist/adapters/node/event-queue.js.map +1 -1
  5. package/dist/adapters/node/index.d.ts.map +1 -1
  6. package/dist/adapters/node/index.js +10 -0
  7. package/dist/adapters/node/index.js.map +1 -1
  8. package/dist/adapters/node/realtime.d.ts +24 -2
  9. package/dist/adapters/node/realtime.d.ts.map +1 -1
  10. package/dist/adapters/node/realtime.js +178 -1
  11. package/dist/adapters/node/realtime.js.map +1 -1
  12. package/dist/bin/serve.js +18 -1
  13. package/dist/bin/serve.js.map +1 -1
  14. package/dist/db/migrations/0016_fleet_nodes.sql +189 -0
  15. package/dist/db/migrations/0017_spawn_reservation_and_retry_state.sql +5 -0
  16. package/dist/db/migrations/0018_spawn_reserved_at.sql +2 -0
  17. package/dist/db/migrations/0019_fleet_mailbox.sql +71 -0
  18. package/dist/db/migrations/0020_workspace_retention.sql +3 -0
  19. package/dist/db/schema.d.ts +1771 -911
  20. package/dist/db/schema.d.ts.map +1 -1
  21. package/dist/db/schema.js +77 -7
  22. package/dist/db/schema.js.map +1 -1
  23. package/dist/engine/action.d.ts +84 -10
  24. package/dist/engine/action.d.ts.map +1 -1
  25. package/dist/engine/action.js +523 -85
  26. package/dist/engine/action.js.map +1 -1
  27. package/dist/engine/agent.js +2 -2
  28. package/dist/engine/agent.js.map +1 -1
  29. package/dist/engine/delivery.d.ts +41 -6
  30. package/dist/engine/delivery.d.ts.map +1 -1
  31. package/dist/engine/delivery.js +316 -25
  32. package/dist/engine/delivery.js.map +1 -1
  33. package/dist/engine/deliveryWire.d.ts +34 -0
  34. package/dist/engine/deliveryWire.d.ts.map +1 -0
  35. package/dist/engine/deliveryWire.js +70 -0
  36. package/dist/engine/deliveryWire.js.map +1 -0
  37. package/dist/engine/deliveryWrites.d.ts +47 -0
  38. package/dist/engine/deliveryWrites.d.ts.map +1 -1
  39. package/dist/engine/deliveryWrites.js +155 -5
  40. package/dist/engine/deliveryWrites.js.map +1 -1
  41. package/dist/engine/dm.d.ts +4 -4
  42. package/dist/engine/dm.d.ts.map +1 -1
  43. package/dist/engine/dm.js +17 -7
  44. package/dist/engine/dm.js.map +1 -1
  45. package/dist/engine/eventQueue.d.ts.map +1 -1
  46. package/dist/engine/eventQueue.js +5 -0
  47. package/dist/engine/eventQueue.js.map +1 -1
  48. package/dist/engine/eventSubscription.d.ts +6 -0
  49. package/dist/engine/eventSubscription.d.ts.map +1 -1
  50. package/dist/engine/eventSubscription.js +13 -0
  51. package/dist/engine/eventSubscription.js.map +1 -1
  52. package/dist/engine/groupDm.d.ts +4 -0
  53. package/dist/engine/groupDm.d.ts.map +1 -1
  54. package/dist/engine/groupDm.js +16 -4
  55. package/dist/engine/groupDm.js.map +1 -1
  56. package/dist/engine/invocationCompletion.d.ts +13 -0
  57. package/dist/engine/invocationCompletion.d.ts.map +1 -0
  58. package/dist/engine/invocationCompletion.js +65 -0
  59. package/dist/engine/invocationCompletion.js.map +1 -0
  60. package/dist/engine/mailboxConfig.d.ts +9 -0
  61. package/dist/engine/mailboxConfig.d.ts.map +1 -0
  62. package/dist/engine/mailboxConfig.js +16 -0
  63. package/dist/engine/mailboxConfig.js.map +1 -0
  64. package/dist/engine/message.d.ts +4 -0
  65. package/dist/engine/message.d.ts.map +1 -1
  66. package/dist/engine/message.js +16 -4
  67. package/dist/engine/message.js.map +1 -1
  68. package/dist/engine/node.d.ts +201 -0
  69. package/dist/engine/node.d.ts.map +1 -0
  70. package/dist/engine/node.js +533 -0
  71. package/dist/engine/node.js.map +1 -0
  72. package/dist/engine/placement.d.ts +26 -0
  73. package/dist/engine/placement.d.ts.map +1 -0
  74. package/dist/engine/placement.js +242 -0
  75. package/dist/engine/placement.js.map +1 -0
  76. package/dist/engine/receipt.d.ts.map +1 -1
  77. package/dist/engine/receipt.js +5 -6
  78. package/dist/engine/receipt.js.map +1 -1
  79. package/dist/engine/retention.d.ts +65 -0
  80. package/dist/engine/retention.d.ts.map +1 -0
  81. package/dist/engine/retention.js +173 -0
  82. package/dist/engine/retention.js.map +1 -0
  83. package/dist/engine/snowflake.d.ts +11 -0
  84. package/dist/engine/snowflake.d.ts.map +1 -1
  85. package/dist/engine/snowflake.js +14 -0
  86. package/dist/engine/snowflake.js.map +1 -1
  87. package/dist/engine/thread.d.ts +4 -0
  88. package/dist/engine/thread.d.ts.map +1 -1
  89. package/dist/engine/thread.js +16 -4
  90. package/dist/engine/thread.js.map +1 -1
  91. package/dist/engine/trigger.d.ts +79 -0
  92. package/dist/engine/trigger.d.ts.map +1 -0
  93. package/dist/engine/trigger.js +151 -0
  94. package/dist/engine/trigger.js.map +1 -0
  95. package/dist/engine.d.ts.map +1 -1
  96. package/dist/engine.js +61 -2
  97. package/dist/engine.js.map +1 -1
  98. package/dist/entrypoints/node.d.ts.map +1 -1
  99. package/dist/entrypoints/node.js +36 -2
  100. package/dist/entrypoints/node.js.map +1 -1
  101. package/dist/env.d.ts +6 -0
  102. package/dist/env.d.ts.map +1 -1
  103. package/dist/index.d.ts +4 -1
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +6 -1
  106. package/dist/index.js.map +1 -1
  107. package/dist/lib/fleetNodes.d.ts +9 -0
  108. package/dist/lib/fleetNodes.d.ts.map +1 -0
  109. package/dist/lib/fleetNodes.js +66 -0
  110. package/dist/lib/fleetNodes.js.map +1 -0
  111. package/dist/lib/origin.d.ts.map +1 -1
  112. package/dist/lib/origin.js +0 -5
  113. package/dist/lib/origin.js.map +1 -1
  114. package/dist/lib/serverTelemetry.d.ts.map +1 -1
  115. package/dist/lib/serverTelemetry.js +0 -1
  116. package/dist/lib/serverTelemetry.js.map +1 -1
  117. package/dist/middleware/fleetNodes.d.ts +10 -0
  118. package/dist/middleware/fleetNodes.d.ts.map +1 -0
  119. package/dist/middleware/fleetNodes.js +18 -0
  120. package/dist/middleware/fleetNodes.js.map +1 -0
  121. package/dist/ports/index.d.ts +24 -2
  122. package/dist/ports/index.d.ts.map +1 -1
  123. package/dist/ports/realtime.d.ts +34 -2
  124. package/dist/ports/realtime.d.ts.map +1 -1
  125. package/dist/ports/realtime.js +1 -1
  126. package/dist/routes/action.d.ts.map +1 -1
  127. package/dist/routes/action.js +21 -21
  128. package/dist/routes/action.js.map +1 -1
  129. package/dist/routes/delivery.d.ts.map +1 -1
  130. package/dist/routes/delivery.js +7 -2
  131. package/dist/routes/delivery.js.map +1 -1
  132. package/dist/routes/deliveryRouting.d.ts +10 -0
  133. package/dist/routes/deliveryRouting.d.ts.map +1 -0
  134. package/dist/routes/deliveryRouting.js +91 -0
  135. package/dist/routes/deliveryRouting.js.map +1 -0
  136. package/dist/routes/dm.d.ts.map +1 -1
  137. package/dist/routes/dm.js +14 -13
  138. package/dist/routes/dm.js.map +1 -1
  139. package/dist/routes/groupDm.d.ts.map +1 -1
  140. package/dist/routes/groupDm.js +14 -15
  141. package/dist/routes/groupDm.js.map +1 -1
  142. package/dist/routes/inbox.d.ts.map +1 -1
  143. package/dist/routes/inbox.js +7 -0
  144. package/dist/routes/inbox.js.map +1 -1
  145. package/dist/routes/message.d.ts.map +1 -1
  146. package/dist/routes/message.js +45 -23
  147. package/dist/routes/message.js.map +1 -1
  148. package/dist/routes/node.d.ts +4 -0
  149. package/dist/routes/node.d.ts.map +1 -0
  150. package/dist/routes/node.js +57 -0
  151. package/dist/routes/node.js.map +1 -0
  152. package/dist/routes/thread.d.ts.map +1 -1
  153. package/dist/routes/thread.js +15 -16
  154. package/dist/routes/thread.js.map +1 -1
  155. package/dist/routes/trigger.d.ts +4 -0
  156. package/dist/routes/trigger.d.ts.map +1 -0
  157. package/dist/routes/trigger.js +83 -0
  158. package/dist/routes/trigger.js.map +1 -0
  159. package/dist/routes/webhookOutbox.d.ts +13 -6
  160. package/dist/routes/webhookOutbox.d.ts.map +1 -1
  161. package/dist/routes/webhookOutbox.js +29 -6
  162. package/dist/routes/webhookOutbox.js.map +1 -1
  163. package/dist/routes/workspace.d.ts.map +1 -1
  164. package/dist/routes/workspace.js +90 -0
  165. package/dist/routes/workspace.js.map +1 -1
  166. package/package.json +3 -3
@@ -1,14 +1,132 @@
1
- import { eq, and } from 'drizzle-orm';
2
- import { actions, actionInvocations, agents } from '../db/schema.js';
1
+ import { and, eq, inArray, lte, sql } from 'drizzle-orm';
2
+ import { actions, actionInvocations, agents, nodes } from '../db/schema.js';
3
3
  import { generateId } from './snowflake.js';
4
4
  import { codedError } from '../lib/httpError.js';
5
- export async function registerAction(db, workspaceId, data) {
6
- const [agent] = await db
5
+ import { claimSpawnNode, chooseNodeForAction, releaseNodeCapacity, reserveNodeCapacity } from './placement.js';
6
+ const OPEN_INVOCATION_STATUSES = ['pending', 'dispatched', 'invoked'];
7
+ export const ACTION_DISPATCH_TIMEOUT_MS = 30_000;
8
+ const ACTION_RETRY_BACKOFF_MS = 5_000;
9
+ function capabilityName(capability) {
10
+ if (typeof capability === 'string')
11
+ return capability;
12
+ if (capability && typeof capability.name === 'string')
13
+ return capability.name;
14
+ return null;
15
+ }
16
+ function isSpawnInvocation(actionName) {
17
+ return actionName === 'spawn' || actionName.startsWith('spawn:');
18
+ }
19
+ function normalizeAttemptedNodeIds(value) {
20
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
21
+ }
22
+ function nextRetryAfter(attempts) {
23
+ const backoff = Math.min(ACTION_RETRY_BACKOFF_MS * 2 ** Math.max(0, attempts - 1), 60_000);
24
+ return new Date(Date.now() + backoff);
25
+ }
26
+ /**
27
+ * Field set that moves an invocation into the live `dispatched` state once its
28
+ * `action.invoke` frame has actually been delivered to the node. Shared by the
29
+ * live dispatch path (`dispatchNodeAttempt`) and the offline-queue drain path
30
+ * (`markDrainedInvocationDispatched`) so the dispatch-timeout sweep — which keys
31
+ * off `dispatchedAt` — and the reschedule path cover drained invocations too.
32
+ */
33
+ function dispatchedStateFields(opts = {}) {
34
+ return {
35
+ status: 'dispatched',
36
+ dispatchedAt: new Date(),
37
+ retryAfterAt: opts.retryAfterAt ?? null,
38
+ };
39
+ }
40
+ function isActionVisibleToCaller(availableTo, callerName) {
41
+ if (!availableTo || availableTo.length === 0)
42
+ return true;
43
+ return !!callerName && availableTo.includes(callerName);
44
+ }
45
+ function requireOneHandler(data) {
46
+ const hasAgent = !!data.handler_agent;
47
+ const hasNode = !!data.handler_node;
48
+ if (hasAgent === hasNode) {
49
+ throw codedError('Exactly one of handler_agent or handler_node is required', 'invalid_action_handler', 400);
50
+ }
51
+ }
52
+ function recordInput(value) {
53
+ return value && typeof value === 'object' && !Array.isArray(value)
54
+ ? value
55
+ : {};
56
+ }
57
+ function toFleetWireJson(value) {
58
+ if (value === null)
59
+ return null;
60
+ if (typeof value === 'string' || typeof value === 'boolean')
61
+ return value;
62
+ if (typeof value === 'number')
63
+ return Number.isFinite(value) ? value : null;
64
+ if (Array.isArray(value))
65
+ return value.map(toFleetWireJson);
66
+ if (value && typeof value === 'object') {
67
+ const out = {};
68
+ for (const [key, nested] of Object.entries(value)) {
69
+ out[key] = toFleetWireJson(nested);
70
+ }
71
+ return out;
72
+ }
73
+ return null;
74
+ }
75
+ function publicAction(row) {
76
+ return {
77
+ id: row.id,
78
+ name: row.name,
79
+ description: row.description,
80
+ handler_agent: row.handlerAgentName,
81
+ handler_node: row.handlerNodeName,
82
+ handler_node_id: row.handlerNodeId,
83
+ input_schema: row.inputSchema ?? {},
84
+ output_schema: row.outputSchema ?? {},
85
+ available_to: row.availableTo ?? null,
86
+ is_active: row.isActive,
87
+ created_at: row.createdAt.toISOString(),
88
+ };
89
+ }
90
+ async function fetchAction(db, workspaceId, actionName) {
91
+ const [action] = await db
7
92
  .select()
8
- .from(agents)
9
- .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, data.handler_agent)));
10
- if (!agent) {
11
- throw codedError(`Agent "${data.handler_agent}" not found`, 'agent_not_found', 404);
93
+ .from(actions)
94
+ .where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, actionName), eq(actions.isActive, true)));
95
+ return action ?? null;
96
+ }
97
+ export async function registerAction(db, workspaceId, data) {
98
+ requireOneHandler(data);
99
+ let handlerAgentId = null;
100
+ let handlerAgentName = null;
101
+ let handlerNodeId = null;
102
+ let handlerNodeName = null;
103
+ if (data.handler_agent) {
104
+ const [agent] = await db
105
+ .select()
106
+ .from(agents)
107
+ .where(and(eq(agents.workspaceId, workspaceId), eq(agents.name, data.handler_agent)));
108
+ if (!agent) {
109
+ throw codedError(`Agent "${data.handler_agent}" not found`, 'agent_not_found', 404);
110
+ }
111
+ handlerAgentId = agent.id;
112
+ handlerAgentName = agent.name;
113
+ }
114
+ if (data.handler_node) {
115
+ const [node] = await db
116
+ .select()
117
+ .from(nodes)
118
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.name, data.handler_node)));
119
+ if (!node) {
120
+ throw codedError(`Node "${data.handler_node}" not found`, 'node_not_found', 404);
121
+ }
122
+ const nodeCapabilities = Array.isArray(node.capabilities)
123
+ ? node.capabilities.map(capabilityName).filter((value) => !!value)
124
+ : [];
125
+ if (!nodeCapabilities.includes(data.name)) {
126
+ throw codedError(`Node "${data.handler_node}" does not provide ${data.name}`, 'capability_mismatch', 409);
127
+ }
128
+ handlerNodeId = node.id;
129
+ handlerNodeName = node.name;
12
130
  }
13
131
  const id = `act_${generateId()}`;
14
132
  const [action] = await db
@@ -18,7 +136,8 @@ export async function registerAction(db, workspaceId, data) {
18
136
  workspaceId,
19
137
  name: data.name,
20
138
  description: data.description,
21
- handlerAgentId: agent.id,
139
+ handlerAgentId,
140
+ handlerNodeId,
22
141
  inputSchema: data.input_schema ?? {},
23
142
  outputSchema: data.output_schema ?? {},
24
143
  availableTo: data.available_to ?? null,
@@ -28,7 +147,9 @@ export async function registerAction(db, workspaceId, data) {
28
147
  id: action.id,
29
148
  name: action.name,
30
149
  description: action.description,
31
- handler_agent: data.handler_agent,
150
+ handler_agent: handlerAgentName,
151
+ handler_node: handlerNodeName,
152
+ handler_node_id: handlerNodeId,
32
153
  input_schema: action.inputSchema,
33
154
  output_schema: action.outputSchema,
34
155
  available_to: action.availableTo ?? null,
@@ -36,19 +157,15 @@ export async function registerAction(db, workspaceId, data) {
36
157
  created_at: action.createdAt.toISOString(),
37
158
  };
38
159
  }
39
- function isActionVisibleToCaller(availableTo, callerName) {
40
- if (!availableTo || availableTo.length === 0)
41
- return true;
42
- return !!callerName && availableTo.includes(callerName);
43
- }
44
160
  export async function listActions(db, workspaceId, callerName) {
45
161
  const rows = await db
46
162
  .select({
47
163
  id: actions.id,
48
164
  name: actions.name,
49
165
  description: actions.description,
50
- handlerAgentId: actions.handlerAgentId,
51
166
  handlerAgentName: agents.name,
167
+ handlerNodeName: nodes.name,
168
+ handlerNodeId: actions.handlerNodeId,
52
169
  inputSchema: actions.inputSchema,
53
170
  outputSchema: actions.outputSchema,
54
171
  availableTo: actions.availableTo,
@@ -56,21 +173,12 @@ export async function listActions(db, workspaceId, callerName) {
56
173
  createdAt: actions.createdAt,
57
174
  })
58
175
  .from(actions)
59
- .innerJoin(agents, eq(actions.handlerAgentId, agents.id))
176
+ .leftJoin(agents, eq(actions.handlerAgentId, agents.id))
177
+ .leftJoin(nodes, eq(actions.handlerNodeId, nodes.id))
60
178
  .where(eq(actions.workspaceId, workspaceId));
61
179
  return rows
62
180
  .filter((r) => isActionVisibleToCaller(r.availableTo ?? null, callerName))
63
- .map((r) => ({
64
- id: r.id,
65
- name: r.name,
66
- description: r.description,
67
- handler_agent: r.handlerAgentName,
68
- input_schema: r.inputSchema,
69
- output_schema: r.outputSchema,
70
- available_to: r.availableTo ?? null,
71
- is_active: r.isActive,
72
- created_at: r.createdAt.toISOString(),
73
- }));
181
+ .map(publicAction);
74
182
  }
75
183
  export async function getAction(db, workspaceId, name, callerName) {
76
184
  const [row] = await db
@@ -78,8 +186,9 @@ export async function getAction(db, workspaceId, name, callerName) {
78
186
  id: actions.id,
79
187
  name: actions.name,
80
188
  description: actions.description,
81
- handlerAgentId: actions.handlerAgentId,
82
189
  handlerAgentName: agents.name,
190
+ handlerNodeName: nodes.name,
191
+ handlerNodeId: actions.handlerNodeId,
83
192
  inputSchema: actions.inputSchema,
84
193
  outputSchema: actions.outputSchema,
85
194
  availableTo: actions.availableTo,
@@ -87,21 +196,12 @@ export async function getAction(db, workspaceId, name, callerName) {
87
196
  createdAt: actions.createdAt,
88
197
  })
89
198
  .from(actions)
90
- .innerJoin(agents, eq(actions.handlerAgentId, agents.id))
199
+ .leftJoin(agents, eq(actions.handlerAgentId, agents.id))
200
+ .leftJoin(nodes, eq(actions.handlerNodeId, nodes.id))
91
201
  .where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, name)));
92
202
  if (!row || !isActionVisibleToCaller(row.availableTo ?? null, callerName))
93
203
  return null;
94
- return {
95
- id: row.id,
96
- name: row.name,
97
- description: row.description,
98
- handler_agent: row.handlerAgentName,
99
- input_schema: row.inputSchema,
100
- output_schema: row.outputSchema,
101
- available_to: row.availableTo ?? null,
102
- is_active: row.isActive,
103
- created_at: row.createdAt.toISOString(),
104
- };
204
+ return publicAction(row);
105
205
  }
106
206
  export async function deleteAction(db, workspaceId, name) {
107
207
  const result = await db
@@ -110,44 +210,310 @@ export async function deleteAction(db, workspaceId, name) {
110
210
  .returning();
111
211
  return result.length > 0;
112
212
  }
113
- export async function invokeAction(db, workspaceId, actionName, data) {
114
- const [action] = await db
115
- .select()
116
- .from(actions)
117
- .where(and(eq(actions.workspaceId, workspaceId), eq(actions.name, actionName), eq(actions.isActive, true)));
118
- if (!action) {
119
- throw codedError(`Action "${actionName}" not found`, 'action_not_found', 404);
120
- }
121
- // Check availableTo access control — deny if caller is absent OR not in the list
122
- if (action.availableTo && action.availableTo.length > 0) {
123
- if (!data.caller_name || !action.availableTo.includes(data.caller_name)) {
124
- const who = data.caller_name ? `Agent "${data.caller_name}"` : 'Caller';
125
- throw codedError(`${who} is not authorized to invoke action "${actionName}"`, 'action_denied', 403);
126
- }
127
- }
213
+ async function createInvocation(db, workspaceId, action, data) {
128
214
  const invocationId = `inv_${generateId()}`;
129
215
  const [invocation] = await db
130
216
  .insert(actionInvocations)
131
217
  .values({
132
218
  id: invocationId,
133
219
  workspaceId,
134
- actionId: action.id,
135
- actionName,
220
+ actionId: action?.id ?? null,
221
+ actionName: action?.name ?? 'spawn',
136
222
  callerId: data.caller_id ?? null,
137
223
  callerName: data.caller_name ?? null,
138
224
  input: data.input ?? {},
139
- status: 'invoked',
225
+ status: data.status ?? 'pending',
140
226
  })
141
227
  .returning();
228
+ return invocation;
229
+ }
230
+ async function dispatchSpawn(args) {
231
+ if (!args.registry) {
232
+ throw codedError('Node dispatch is not available', 'node_dispatch_unavailable', 503);
233
+ }
234
+ const invocation = await createInvocation(args.db, args.workspaceId, null, {
235
+ input: args.data.input,
236
+ caller_id: args.data.caller_id,
237
+ caller_name: args.data.caller_name,
238
+ });
239
+ const placement = await claimSpawnNode(args.db, args.workspaceId, {
240
+ actionName: 'spawn',
241
+ input: args.data.input,
242
+ callerId: args.data.caller_id,
243
+ });
244
+ const dispatched = await dispatchNodeInvocation({
245
+ db: args.db,
246
+ registry: args.registry,
247
+ workspaceId: args.workspaceId,
248
+ invocationId: invocation.id,
249
+ nodeId: placement.node.id,
250
+ action: placement.capability,
251
+ input: recordInput(invocation.input),
252
+ pending: placement.queued,
253
+ reservationHeld: !placement.queued,
254
+ });
255
+ return {
256
+ invocation_id: invocation.id,
257
+ action_name: 'spawn',
258
+ handler_agent_id: null,
259
+ handler_node_id: placement.node.id,
260
+ dispatched_node_id: dispatched.accepted ? placement.node.id : null,
261
+ input: recordInput(invocation.input),
262
+ status: dispatched.accepted ? (dispatched.pending ? 'pending' : 'dispatched') : 'pending',
263
+ created_at: invocation.createdAt.toISOString(),
264
+ };
265
+ }
266
+ export async function invokeAction(db, workspaceId, actionName, data, options = {}) {
267
+ const fleetNodesEnabled = options.fleetNodesEnabled ?? true;
268
+ const action = await fetchAction(db, workspaceId, actionName);
269
+ if (!action && actionName === 'spawn') {
270
+ if (!fleetNodesEnabled) {
271
+ throw codedError('Fleet nodes are disabled for this workspace', 'fleet_nodes_disabled', 404);
272
+ }
273
+ return dispatchSpawn({
274
+ db,
275
+ registry: options.nodeConnections,
276
+ workspaceId,
277
+ data,
278
+ });
279
+ }
280
+ if (!action) {
281
+ throw codedError(`Action "${actionName}" not found`, 'action_not_found', 404);
282
+ }
283
+ // Check availableTo access control — deny if caller is absent OR not in the list
284
+ if (action.availableTo && action.availableTo.length > 0) {
285
+ if (!data.caller_name || !action.availableTo.includes(data.caller_name)) {
286
+ const who = data.caller_name ? `Agent "${data.caller_name}"` : 'Caller';
287
+ throw codedError(`${who} is not authorized to invoke action "${actionName}"`, 'action_denied', 403);
288
+ }
289
+ }
290
+ if (action.handlerNodeId) {
291
+ if (!fleetNodesEnabled) {
292
+ throw codedError('Fleet nodes are disabled for this workspace', 'fleet_nodes_disabled', 404);
293
+ }
294
+ if (!options.nodeConnections) {
295
+ throw codedError('Node dispatch is not available', 'node_dispatch_unavailable', 503);
296
+ }
297
+ const invocation = await createInvocation(db, workspaceId, action, {
298
+ input: data.input,
299
+ caller_id: data.caller_id,
300
+ caller_name: data.caller_name,
301
+ });
302
+ // Only mark the reservation held when we actually incremented the node's
303
+ // reserved-capacity counter, so completion/reschedule release stays balanced.
304
+ // If the reservation can't be taken (node offline / at capacity) the queued
305
+ // frame reserves later on drain via the spawnReservedAt check.
306
+ const reservedNode = isSpawnInvocation(action.name)
307
+ ? await reserveNodeCapacity(db, workspaceId, action.handlerNodeId)
308
+ : null;
309
+ const dispatched = await dispatchNodeInvocation({
310
+ db,
311
+ registry: options.nodeConnections,
312
+ workspaceId,
313
+ invocationId: invocation.id,
314
+ nodeId: action.handlerNodeId,
315
+ action: action.name,
316
+ input: recordInput(invocation.input),
317
+ reservationHeld: !!reservedNode,
318
+ });
319
+ return {
320
+ invocation_id: invocation.id,
321
+ action_name: actionName,
322
+ handler_agent_id: null,
323
+ handler_node_id: action.handlerNodeId,
324
+ dispatched_node_id: dispatched.accepted ? action.handlerNodeId : null,
325
+ input: recordInput(invocation.input),
326
+ status: dispatched.accepted ? (dispatched.pending ? 'pending' : 'dispatched') : 'pending',
327
+ created_at: invocation.createdAt.toISOString(),
328
+ };
329
+ }
330
+ if (!action.handlerAgentId) {
331
+ throw codedError(`Action "${actionName}" has no handler`, 'handler_unavailable', 503);
332
+ }
333
+ const invocation = await createInvocation(db, workspaceId, action, {
334
+ input: data.input,
335
+ caller_id: data.caller_id,
336
+ caller_name: data.caller_name,
337
+ status: 'dispatched',
338
+ });
142
339
  return {
143
340
  invocation_id: invocation.id,
144
341
  action_name: actionName,
145
342
  handler_agent_id: action.handlerAgentId,
146
- input: invocation.input,
343
+ handler_node_id: null,
344
+ dispatched_node_id: null,
345
+ input: recordInput(invocation.input),
147
346
  status: invocation.status,
148
347
  created_at: invocation.createdAt.toISOString(),
149
348
  };
150
349
  }
350
+ function publicInvocation(row) {
351
+ return {
352
+ invocation_id: row.id,
353
+ action_name: row.actionName,
354
+ caller_id: row.callerId,
355
+ caller_name: row.callerName,
356
+ input: row.input,
357
+ output: row.output,
358
+ status: row.status,
359
+ error: row.error,
360
+ duration_ms: row.durationMs,
361
+ dispatched_node_id: row.dispatchedNodeId,
362
+ dispatched_at: row.dispatchedAt?.toISOString() ?? null,
363
+ created_at: row.createdAt.toISOString(),
364
+ completed_at: row.completedAt?.toISOString() ?? null,
365
+ };
366
+ }
367
+ async function dispatchNodeAttempt(db, workspaceId, invocationId, nodeId, opts = {}) {
368
+ const stateFields = opts.pending
369
+ ? { status: 'pending', dispatchedAt: null, retryAfterAt: opts.retryAfterAt ?? null }
370
+ : dispatchedStateFields({ retryAfterAt: opts.retryAfterAt });
371
+ const [updated] = await db
372
+ .update(actionInvocations)
373
+ .set({
374
+ ...stateFields,
375
+ dispatchedNodeId: nodeId,
376
+ spawnReservedAt: opts.reservationHeld ? new Date() : null,
377
+ attemptedNodeIds: sql `json_insert(COALESCE(${actionInvocations.attemptedNodeIds}, '[]'), '$[#]', ${nodeId})`,
378
+ dispatchAttempts: sql `COALESCE(${actionInvocations.dispatchAttempts}, 0) + 1`,
379
+ })
380
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
381
+ .returning();
382
+ return !!updated;
383
+ }
384
+ /**
385
+ * Transition a drained offline-queue invocation into the live `dispatched` state
386
+ * once its queued `action.invoke` frame is actually delivered on node
387
+ * reconnect/drain. Reuses the same dispatched-state fields as the live dispatch
388
+ * path (stamping `dispatchedAt` and `retryAfterAt`) so the dispatch-timeout sweep
389
+ * and reschedule cover drained invocations. This is the SAME attempt that was
390
+ * queued, so `dispatchAttempts`/`attemptedNodeIds` are intentionally left intact.
391
+ * Guarded on the invocation still being `pending` on this node so a completion or
392
+ * reschedule that raced the drain is never clobbered.
393
+ */
394
+ export async function markDrainedInvocationDispatched(db, workspaceId, invocationId, nodeId) {
395
+ const [updated] = await db
396
+ .update(actionInvocations)
397
+ .set(dispatchedStateFields({ retryAfterAt: new Date(Date.now() + ACTION_DISPATCH_TIMEOUT_MS) }))
398
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), eq(actionInvocations.dispatchedNodeId, nodeId), eq(actionInvocations.status, 'pending')))
399
+ .returning({ id: actionInvocations.id });
400
+ return !!updated;
401
+ }
402
+ async function dispatchNodeInvocation(args) {
403
+ const connectedBefore = args.registry.isNodeConnected(args.workspaceId, args.nodeId);
404
+ const sent = await args.registry.sendToNode(args.workspaceId, args.nodeId, {
405
+ v: 1,
406
+ type: 'action.invoke',
407
+ invocation_id: args.invocationId,
408
+ action: args.action,
409
+ input: toFleetWireJson(args.input),
410
+ });
411
+ if (!sent)
412
+ return { accepted: false, pending: false };
413
+ const pending = !!args.pending || !connectedBefore;
414
+ const accepted = await dispatchNodeAttempt(args.db, args.workspaceId, args.invocationId, args.nodeId, { pending, retryAfterAt: args.retryAfterAt, reservationHeld: args.reservationHeld });
415
+ return { accepted, pending };
416
+ }
417
+ function attemptedNodeSet(invocation) {
418
+ return Array.from(new Set([
419
+ ...normalizeAttemptedNodeIds(invocation.attemptedNodeIds),
420
+ invocation.dispatchedNodeId,
421
+ ].filter((nodeId) => !!nodeId)));
422
+ }
423
+ async function selectRetryPlacement(db, invocation, excludeNodeIds) {
424
+ const input = recordInput(invocation.input);
425
+ if (isSpawnInvocation(invocation.actionName)) {
426
+ return claimSpawnNode(db, invocation.workspaceId, {
427
+ actionName: 'spawn',
428
+ input,
429
+ callerId: invocation.callerId,
430
+ excludeNodeIds,
431
+ });
432
+ }
433
+ return chooseNodeForAction(db, invocation.workspaceId, {
434
+ actionName: invocation.actionName,
435
+ input,
436
+ callerId: invocation.callerId,
437
+ excludeNodeIds,
438
+ });
439
+ }
440
+ export async function rescheduleNodeInvocation(db, registry, invocation, opts = {}) {
441
+ if (invocation.dispatchedNodeId && isSpawnInvocation(invocation.actionName)) {
442
+ await releaseNodeCapacity(db, invocation.workspaceId, invocation.dispatchedNodeId);
443
+ }
444
+ const attempted = attemptedNodeSet(invocation);
445
+ const current = invocation.dispatchedNodeId ? [invocation.dispatchedNodeId] : [];
446
+ const input = recordInput(invocation.input);
447
+ const actionToSend = isSpawnInvocation(invocation.actionName) ? 'spawn' : invocation.actionName;
448
+ const baseExclude = Array.from(new Set([...attempted, ...current]));
449
+ const candidates = opts.allowAttemptedFallback ? [baseExclude, []] : [baseExclude];
450
+ for (const excludeNodeIds of candidates) {
451
+ try {
452
+ const placement = await selectRetryPlacement(db, invocation, excludeNodeIds);
453
+ if (placement.queued) {
454
+ await dispatchNodeAttempt(db, invocation.workspaceId, invocation.id, placement.node.id, {
455
+ pending: true,
456
+ retryAfterAt: opts.retryAfterAt ?? null,
457
+ reservationHeld: false,
458
+ });
459
+ return true;
460
+ }
461
+ const dispatched = await dispatchNodeInvocation({
462
+ db,
463
+ registry,
464
+ workspaceId: invocation.workspaceId,
465
+ invocationId: invocation.id,
466
+ nodeId: placement.node.id,
467
+ action: actionToSend,
468
+ input,
469
+ reservationHeld: isSpawnInvocation(invocation.actionName) && !placement.queued,
470
+ });
471
+ return dispatched.accepted;
472
+ }
473
+ catch {
474
+ // Try the next candidate set.
475
+ }
476
+ }
477
+ await db
478
+ .update(actionInvocations)
479
+ .set({
480
+ status: 'pending',
481
+ dispatchedNodeId: null,
482
+ dispatchedAt: null,
483
+ spawnReservedAt: null,
484
+ retryAfterAt: opts.retryAfterAt ?? nextRetryAfter(invocation.dispatchAttempts + 1),
485
+ })
486
+ .where(and(eq(actionInvocations.workspaceId, invocation.workspaceId), eq(actionInvocations.id, invocation.id), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
487
+ return false;
488
+ }
489
+ export async function rescheduleInvocationsForLostNode(db, registry, workspaceId, nodeId) {
490
+ const rows = await db
491
+ .select()
492
+ .from(actionInvocations)
493
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.dispatchedNodeId, nodeId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)));
494
+ let rescheduled = 0;
495
+ for (const invocation of rows) {
496
+ try {
497
+ if (await rescheduleNodeInvocation(db, registry, invocation, { retryAfterAt: nextRetryAfter(invocation.dispatchAttempts + 1) })) {
498
+ rescheduled++;
499
+ }
500
+ }
501
+ catch {
502
+ // Keep the invocation pending; another heartbeat/sweep can retry.
503
+ await db
504
+ .update(actionInvocations)
505
+ .set({
506
+ status: 'pending',
507
+ dispatchedNodeId: null,
508
+ dispatchedAt: null,
509
+ spawnReservedAt: null,
510
+ retryAfterAt: nextRetryAfter(invocation.dispatchAttempts + 1),
511
+ })
512
+ .where(eq(actionInvocations.id, invocation.id));
513
+ }
514
+ }
515
+ return rescheduled;
516
+ }
151
517
  export async function completeInvocation(db, workspaceId, actionName, invocationId, data) {
152
518
  // Fetch the invocation joined with its action — verify action name matches URL param
153
519
  const [existing] = await db
@@ -155,13 +521,20 @@ export async function completeInvocation(db, workspaceId, actionName, invocation
155
521
  id: actionInvocations.id,
156
522
  status: actionInvocations.status,
157
523
  actionName: actionInvocations.actionName,
524
+ attemptedNodeIds: actionInvocations.attemptedNodeIds,
525
+ dispatchAttempts: actionInvocations.dispatchAttempts,
526
+ dispatchedNodeId: actionInvocations.dispatchedNodeId,
158
527
  handlerAgentId: actions.handlerAgentId,
528
+ handlerNodeId: actions.handlerNodeId,
159
529
  })
160
530
  .from(actionInvocations)
161
531
  .leftJoin(actions, eq(actionInvocations.actionId, actions.id))
162
532
  .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), eq(actionInvocations.actionName, actionName)));
163
533
  if (!existing)
164
534
  return null;
535
+ if (existing.handlerNodeId || existing.dispatchedNodeId) {
536
+ throw codedError('Node-owned invocations must be completed over the node control channel', 'node_owned_invocation', 403);
537
+ }
165
538
  // Agent tokens must belong to the handler agent
166
539
  if (data.caller_agent_id && existing.handlerAgentId && data.caller_agent_id !== existing.handlerAgentId) {
167
540
  throw codedError('Only the handler agent can complete this invocation', 'forbidden', 403);
@@ -176,21 +549,98 @@ export async function completeInvocation(db, workspaceId, actionName, invocation
176
549
  durationMs: data.duration_ms ?? null,
177
550
  completedAt: new Date(),
178
551
  })
179
- .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), eq(actionInvocations.actionName, actionName), eq(actionInvocations.status, 'invoked')))
552
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), eq(actionInvocations.actionName, actionName), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
180
553
  .returning();
181
- // No rows updated means either not found or already completed by a concurrent request
182
554
  if (!updated)
183
555
  return null;
184
- return {
185
- invocation_id: updated.id,
186
- action_name: updated.actionName,
187
- caller_id: updated.callerId,
188
- status: updated.status,
189
- output: updated.output,
190
- error: updated.error,
191
- duration_ms: updated.durationMs,
192
- completed_at: updated.completedAt?.toISOString() ?? null,
193
- };
556
+ return publicInvocation(updated);
557
+ }
558
+ export async function completeNodeInvocation(db, registry, workspaceId, nodeId, invocationId, data) {
559
+ const [existing] = await db
560
+ .select({
561
+ id: actionInvocations.id,
562
+ workspaceId: actionInvocations.workspaceId,
563
+ actionName: actionInvocations.actionName,
564
+ callerId: actionInvocations.callerId,
565
+ input: actionInvocations.input,
566
+ output: actionInvocations.output,
567
+ status: actionInvocations.status,
568
+ error: actionInvocations.error,
569
+ durationMs: actionInvocations.durationMs,
570
+ dispatchedNodeId: actionInvocations.dispatchedNodeId,
571
+ dispatchedAt: actionInvocations.dispatchedAt,
572
+ attemptedNodeIds: actionInvocations.attemptedNodeIds,
573
+ dispatchAttempts: actionInvocations.dispatchAttempts,
574
+ createdAt: actionInvocations.createdAt,
575
+ completedAt: actionInvocations.completedAt,
576
+ })
577
+ .from(actionInvocations)
578
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId)));
579
+ if (!existing)
580
+ return null;
581
+ if (existing.dispatchedNodeId && existing.dispatchedNodeId !== nodeId) {
582
+ return null;
583
+ }
584
+ if (existing.status === 'completed' || existing.status === 'failed') {
585
+ return null;
586
+ }
587
+ if (data.error === 'handler_unavailable') {
588
+ await db
589
+ .update(nodes)
590
+ .set({ handlersLive: false })
591
+ .where(and(eq(nodes.workspaceId, workspaceId), eq(nodes.id, nodeId)));
592
+ try {
593
+ if (await rescheduleNodeInvocation(db, registry, existing, { retryAfterAt: nextRetryAfter(existing.dispatchAttempts + 1) })) {
594
+ return null;
595
+ }
596
+ }
597
+ catch {
598
+ // Fall through and fail the invocation if no eligible node exists.
599
+ }
600
+ }
601
+ const [updated] = await db
602
+ .update(actionInvocations)
603
+ .set({
604
+ output: data.output ?? null,
605
+ error: data.error ?? null,
606
+ status: data.error ? 'failed' : 'completed',
607
+ completedAt: new Date(),
608
+ spawnReservedAt: null,
609
+ })
610
+ .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), eq(actionInvocations.dispatchedNodeId, nodeId), inArray(actionInvocations.status, OPEN_INVOCATION_STATUSES)))
611
+ .returning();
612
+ if (updated && isSpawnInvocation(updated.actionName) && updated.dispatchedNodeId) {
613
+ await releaseNodeCapacity(db, workspaceId, updated.dispatchedNodeId);
614
+ }
615
+ return updated ? publicInvocation(updated) : null;
616
+ }
617
+ export async function sweepTimedOutInvocations(db, registry, timeoutMs = ACTION_DISPATCH_TIMEOUT_MS) {
618
+ const now = new Date();
619
+ const cutoff = new Date(Date.now() - timeoutMs);
620
+ const rows = await db
621
+ .select()
622
+ .from(actionInvocations)
623
+ .where(and(inArray(actionInvocations.status, ['dispatched']), lte(actionInvocations.dispatchedAt, cutoff)));
624
+ const pendingRows = await db
625
+ .select()
626
+ .from(actionInvocations)
627
+ .where(and(eq(actionInvocations.status, 'pending'), lte(actionInvocations.retryAfterAt, now)));
628
+ let rescheduled = 0;
629
+ for (const invocation of [...rows, ...pendingRows]) {
630
+ try {
631
+ const allowFallback = invocation.status === 'pending';
632
+ if (await rescheduleNodeInvocation(db, registry, invocation, {
633
+ allowAttemptedFallback: allowFallback,
634
+ retryAfterAt: allowFallback ? null : nextRetryAfter(invocation.dispatchAttempts + 1),
635
+ })) {
636
+ rescheduled++;
637
+ }
638
+ }
639
+ catch {
640
+ // Leave the invocation for the next sweep.
641
+ }
642
+ }
643
+ return rescheduled;
194
644
  }
195
645
  export async function getInvocation(db, workspaceId, actionName, invocationId) {
196
646
  const [row] = await db
@@ -199,18 +649,6 @@ export async function getInvocation(db, workspaceId, actionName, invocationId) {
199
649
  .where(and(eq(actionInvocations.workspaceId, workspaceId), eq(actionInvocations.id, invocationId), eq(actionInvocations.actionName, actionName)));
200
650
  if (!row)
201
651
  return null;
202
- return {
203
- invocation_id: row.id,
204
- action_name: row.actionName,
205
- caller_id: row.callerId,
206
- caller_name: row.callerName,
207
- input: row.input,
208
- output: row.output,
209
- status: row.status,
210
- error: row.error,
211
- duration_ms: row.durationMs,
212
- created_at: row.createdAt.toISOString(),
213
- completed_at: row.completedAt?.toISOString() ?? null,
214
- };
652
+ return publicInvocation(row);
215
653
  }
216
654
  //# sourceMappingURL=action.js.map