@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.
- package/dist/adapters/node/event-queue.d.ts +10 -0
- package/dist/adapters/node/event-queue.d.ts.map +1 -1
- package/dist/adapters/node/event-queue.js +17 -0
- package/dist/adapters/node/event-queue.js.map +1 -1
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +10 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/realtime.d.ts +24 -2
- package/dist/adapters/node/realtime.d.ts.map +1 -1
- package/dist/adapters/node/realtime.js +178 -1
- package/dist/adapters/node/realtime.js.map +1 -1
- package/dist/bin/serve.js +18 -1
- package/dist/bin/serve.js.map +1 -1
- package/dist/db/migrations/0016_fleet_nodes.sql +189 -0
- package/dist/db/migrations/0017_spawn_reservation_and_retry_state.sql +5 -0
- package/dist/db/migrations/0018_spawn_reserved_at.sql +2 -0
- package/dist/db/migrations/0019_fleet_mailbox.sql +71 -0
- package/dist/db/migrations/0020_workspace_retention.sql +3 -0
- package/dist/db/schema.d.ts +1771 -911
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +77 -7
- package/dist/db/schema.js.map +1 -1
- package/dist/engine/action.d.ts +84 -10
- package/dist/engine/action.d.ts.map +1 -1
- package/dist/engine/action.js +523 -85
- package/dist/engine/action.js.map +1 -1
- package/dist/engine/agent.js +2 -2
- package/dist/engine/agent.js.map +1 -1
- package/dist/engine/delivery.d.ts +41 -6
- package/dist/engine/delivery.d.ts.map +1 -1
- package/dist/engine/delivery.js +316 -25
- package/dist/engine/delivery.js.map +1 -1
- package/dist/engine/deliveryWire.d.ts +34 -0
- package/dist/engine/deliveryWire.d.ts.map +1 -0
- package/dist/engine/deliveryWire.js +70 -0
- package/dist/engine/deliveryWire.js.map +1 -0
- package/dist/engine/deliveryWrites.d.ts +47 -0
- package/dist/engine/deliveryWrites.d.ts.map +1 -1
- package/dist/engine/deliveryWrites.js +155 -5
- package/dist/engine/deliveryWrites.js.map +1 -1
- package/dist/engine/dm.d.ts +4 -4
- package/dist/engine/dm.d.ts.map +1 -1
- package/dist/engine/dm.js +17 -7
- package/dist/engine/dm.js.map +1 -1
- package/dist/engine/eventQueue.d.ts.map +1 -1
- package/dist/engine/eventQueue.js +5 -0
- package/dist/engine/eventQueue.js.map +1 -1
- package/dist/engine/eventSubscription.d.ts +6 -0
- package/dist/engine/eventSubscription.d.ts.map +1 -1
- package/dist/engine/eventSubscription.js +13 -0
- package/dist/engine/eventSubscription.js.map +1 -1
- package/dist/engine/groupDm.d.ts +4 -0
- package/dist/engine/groupDm.d.ts.map +1 -1
- package/dist/engine/groupDm.js +16 -4
- package/dist/engine/groupDm.js.map +1 -1
- package/dist/engine/invocationCompletion.d.ts +13 -0
- package/dist/engine/invocationCompletion.d.ts.map +1 -0
- package/dist/engine/invocationCompletion.js +65 -0
- package/dist/engine/invocationCompletion.js.map +1 -0
- package/dist/engine/mailboxConfig.d.ts +9 -0
- package/dist/engine/mailboxConfig.d.ts.map +1 -0
- package/dist/engine/mailboxConfig.js +16 -0
- package/dist/engine/mailboxConfig.js.map +1 -0
- package/dist/engine/message.d.ts +4 -0
- package/dist/engine/message.d.ts.map +1 -1
- package/dist/engine/message.js +16 -4
- package/dist/engine/message.js.map +1 -1
- package/dist/engine/node.d.ts +201 -0
- package/dist/engine/node.d.ts.map +1 -0
- package/dist/engine/node.js +533 -0
- package/dist/engine/node.js.map +1 -0
- package/dist/engine/placement.d.ts +26 -0
- package/dist/engine/placement.d.ts.map +1 -0
- package/dist/engine/placement.js +242 -0
- package/dist/engine/placement.js.map +1 -0
- package/dist/engine/receipt.d.ts.map +1 -1
- package/dist/engine/receipt.js +5 -6
- package/dist/engine/receipt.js.map +1 -1
- package/dist/engine/retention.d.ts +65 -0
- package/dist/engine/retention.d.ts.map +1 -0
- package/dist/engine/retention.js +173 -0
- package/dist/engine/retention.js.map +1 -0
- package/dist/engine/snowflake.d.ts +11 -0
- package/dist/engine/snowflake.d.ts.map +1 -1
- package/dist/engine/snowflake.js +14 -0
- package/dist/engine/snowflake.js.map +1 -1
- package/dist/engine/thread.d.ts +4 -0
- package/dist/engine/thread.d.ts.map +1 -1
- package/dist/engine/thread.js +16 -4
- package/dist/engine/thread.js.map +1 -1
- package/dist/engine/trigger.d.ts +79 -0
- package/dist/engine/trigger.d.ts.map +1 -0
- package/dist/engine/trigger.js +151 -0
- package/dist/engine/trigger.js.map +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +61 -2
- package/dist/engine.js.map +1 -1
- package/dist/entrypoints/node.d.ts.map +1 -1
- package/dist/entrypoints/node.js +36 -2
- package/dist/entrypoints/node.js.map +1 -1
- package/dist/env.d.ts +6 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/fleetNodes.d.ts +9 -0
- package/dist/lib/fleetNodes.d.ts.map +1 -0
- package/dist/lib/fleetNodes.js +66 -0
- package/dist/lib/fleetNodes.js.map +1 -0
- package/dist/lib/origin.d.ts.map +1 -1
- package/dist/lib/origin.js +0 -5
- package/dist/lib/origin.js.map +1 -1
- package/dist/lib/serverTelemetry.d.ts.map +1 -1
- package/dist/lib/serverTelemetry.js +0 -1
- package/dist/lib/serverTelemetry.js.map +1 -1
- package/dist/middleware/fleetNodes.d.ts +10 -0
- package/dist/middleware/fleetNodes.d.ts.map +1 -0
- package/dist/middleware/fleetNodes.js +18 -0
- package/dist/middleware/fleetNodes.js.map +1 -0
- package/dist/ports/index.d.ts +24 -2
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/realtime.d.ts +34 -2
- package/dist/ports/realtime.d.ts.map +1 -1
- package/dist/ports/realtime.js +1 -1
- package/dist/routes/action.d.ts.map +1 -1
- package/dist/routes/action.js +21 -21
- package/dist/routes/action.js.map +1 -1
- package/dist/routes/delivery.d.ts.map +1 -1
- package/dist/routes/delivery.js +7 -2
- package/dist/routes/delivery.js.map +1 -1
- package/dist/routes/deliveryRouting.d.ts +10 -0
- package/dist/routes/deliveryRouting.d.ts.map +1 -0
- package/dist/routes/deliveryRouting.js +91 -0
- package/dist/routes/deliveryRouting.js.map +1 -0
- package/dist/routes/dm.d.ts.map +1 -1
- package/dist/routes/dm.js +14 -13
- package/dist/routes/dm.js.map +1 -1
- package/dist/routes/groupDm.d.ts.map +1 -1
- package/dist/routes/groupDm.js +14 -15
- package/dist/routes/groupDm.js.map +1 -1
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/inbox.js +7 -0
- package/dist/routes/inbox.js.map +1 -1
- package/dist/routes/message.d.ts.map +1 -1
- package/dist/routes/message.js +45 -23
- package/dist/routes/message.js.map +1 -1
- package/dist/routes/node.d.ts +4 -0
- package/dist/routes/node.d.ts.map +1 -0
- package/dist/routes/node.js +57 -0
- package/dist/routes/node.js.map +1 -0
- package/dist/routes/thread.d.ts.map +1 -1
- package/dist/routes/thread.js +15 -16
- package/dist/routes/thread.js.map +1 -1
- package/dist/routes/trigger.d.ts +4 -0
- package/dist/routes/trigger.d.ts.map +1 -0
- package/dist/routes/trigger.js +83 -0
- package/dist/routes/trigger.js.map +1 -0
- package/dist/routes/webhookOutbox.d.ts +13 -6
- package/dist/routes/webhookOutbox.d.ts.map +1 -1
- package/dist/routes/webhookOutbox.js +29 -6
- package/dist/routes/webhookOutbox.js.map +1 -1
- package/dist/routes/workspace.d.ts.map +1 -1
- package/dist/routes/workspace.js +90 -0
- package/dist/routes/workspace.js.map +1 -1
- package/package.json +3 -3
package/dist/engine/action.js
CHANGED
|
@@ -1,14 +1,132 @@
|
|
|
1
|
-
import { eq,
|
|
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
|
-
|
|
6
|
-
|
|
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(
|
|
9
|
-
.where(and(eq(
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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:
|
|
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
|
-
.
|
|
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(
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|