@nordsym/apiclaw 1.3.13 → 1.4.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/PRD-API-CHAINING.md +483 -0
- package/PRD-HARDEN-SHELL.md +18 -12
- package/convex/_generated/api.d.ts +2 -0
- package/convex/chains.ts +1095 -0
- package/convex/schema.ts +101 -0
- package/dist/chain-types.d.ts +187 -0
- package/dist/chain-types.d.ts.map +1 -0
- package/dist/chain-types.js +33 -0
- package/dist/chain-types.js.map +1 -0
- package/dist/chainExecutor.d.ts +122 -0
- package/dist/chainExecutor.d.ts.map +1 -0
- package/dist/chainExecutor.js +454 -0
- package/dist/chainExecutor.js.map +1 -0
- package/dist/chainResolver.d.ts +100 -0
- package/dist/chainResolver.d.ts.map +1 -0
- package/dist/chainResolver.js +519 -0
- package/dist/chainResolver.js.map +1 -0
- package/dist/chainResolver.test.d.ts +5 -0
- package/dist/chainResolver.test.d.ts.map +1 -0
- package/dist/chainResolver.test.js +201 -0
- package/dist/chainResolver.test.js.map +1 -0
- package/dist/execute.d.ts +4 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +3 -0
- package/dist/execute.js.map +1 -1
- package/dist/index.js +382 -2
- package/dist/index.js.map +1 -1
- package/landing/public/logos/chattgpt.svg +1 -0
- package/landing/public/logos/claude.svg +1 -0
- package/landing/public/logos/gemini.svg +1 -0
- package/landing/public/logos/grok.svg +1 -0
- package/landing/src/app/page.tsx +12 -21
- package/landing/src/app/workspace/chains/page.tsx +520 -0
- package/landing/src/components/AITestimonials.tsx +15 -9
- package/landing/src/components/ChainStepDetail.tsx +310 -0
- package/landing/src/components/ChainTrace.tsx +261 -0
- package/landing/src/lib/stats.json +1 -1
- package/package.json +1 -1
- package/src/chain-types.ts +270 -0
- package/src/chainExecutor.ts +730 -0
- package/src/chainResolver.test.ts +246 -0
- package/src/chainResolver.ts +658 -0
- package/src/execute.ts +23 -0
- package/src/index.ts +423 -2
package/convex/chains.ts
ADDED
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query, action, internalMutation, internalAction } from "./_generated/server";
|
|
3
|
+
import { internal, api } from "./_generated/api";
|
|
4
|
+
import { Id, Doc } from "./_generated/dataModel";
|
|
5
|
+
|
|
6
|
+
// ============================================
|
|
7
|
+
// TYPES
|
|
8
|
+
// ============================================
|
|
9
|
+
|
|
10
|
+
type ChainStatus = "pending" | "running" | "completed" | "failed" | "paused";
|
|
11
|
+
type StepStatus = "pending" | "running" | "completed" | "failed" | "skipped";
|
|
12
|
+
|
|
13
|
+
interface ChainStep {
|
|
14
|
+
id: string;
|
|
15
|
+
provider: string;
|
|
16
|
+
action: string;
|
|
17
|
+
params: Record<string, unknown>;
|
|
18
|
+
// Execution modifiers
|
|
19
|
+
onError?: {
|
|
20
|
+
retry?: { attempts: number; backoff?: number[] };
|
|
21
|
+
fallback?: ChainStep;
|
|
22
|
+
abort?: boolean;
|
|
23
|
+
};
|
|
24
|
+
// Parallel execution
|
|
25
|
+
parallel?: ChainStep[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ChainError {
|
|
29
|
+
stepId: string;
|
|
30
|
+
code: string;
|
|
31
|
+
message: string;
|
|
32
|
+
retryAfter?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ============================================
|
|
36
|
+
// HELPER: Generate resume token
|
|
37
|
+
// ============================================
|
|
38
|
+
|
|
39
|
+
function generateResumeToken(chainId: string, stepIndex: number): string {
|
|
40
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
41
|
+
return `chain_${chainId.slice(-8)}_step_${stepIndex}_${random}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================
|
|
45
|
+
// MUTATIONS
|
|
46
|
+
// ============================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a new chain execution record (internal)
|
|
50
|
+
* Validates chain structure and initializes execution state
|
|
51
|
+
*/
|
|
52
|
+
export const createChainInternal = internalMutation({
|
|
53
|
+
args: {
|
|
54
|
+
workspaceId: v.id("workspaces"),
|
|
55
|
+
steps: v.array(v.any()),
|
|
56
|
+
continueOnError: v.optional(v.boolean()),
|
|
57
|
+
timeout: v.optional(v.number()),
|
|
58
|
+
},
|
|
59
|
+
handler: async (ctx, args) => {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
|
|
62
|
+
// Validate steps have required fields
|
|
63
|
+
for (let i = 0; i < args.steps.length; i++) {
|
|
64
|
+
const step = args.steps[i] as ChainStep;
|
|
65
|
+
|
|
66
|
+
// Check for parallel steps
|
|
67
|
+
if (step.parallel) {
|
|
68
|
+
for (const pStep of step.parallel) {
|
|
69
|
+
if (!pStep.id || !pStep.provider || !pStep.action) {
|
|
70
|
+
throw new Error(`Parallel step at index ${i} missing required fields (id, provider, action)`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} else if (!step.id || !step.provider || !step.action) {
|
|
74
|
+
throw new Error(`Step at index ${i} missing required fields (id, provider, action)`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Create chain record
|
|
79
|
+
const chainId = await ctx.db.insert("chains", {
|
|
80
|
+
workspaceId: args.workspaceId,
|
|
81
|
+
steps: args.steps,
|
|
82
|
+
status: "pending",
|
|
83
|
+
currentStep: 0,
|
|
84
|
+
results: {},
|
|
85
|
+
continueOnError: args.continueOnError ?? false,
|
|
86
|
+
timeout: args.timeout,
|
|
87
|
+
totalCostCents: 0,
|
|
88
|
+
totalLatencyMs: 0,
|
|
89
|
+
createdAt: now,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Create execution records for each step
|
|
93
|
+
for (let i = 0; i < args.steps.length; i++) {
|
|
94
|
+
const step = args.steps[i] as ChainStep;
|
|
95
|
+
|
|
96
|
+
if (step.parallel) {
|
|
97
|
+
// Create execution records for parallel steps
|
|
98
|
+
const parallelGroup = `parallel_${i}_${Date.now()}`;
|
|
99
|
+
for (const pStep of step.parallel) {
|
|
100
|
+
await ctx.db.insert("chainExecutions", {
|
|
101
|
+
chainId,
|
|
102
|
+
stepId: pStep.id,
|
|
103
|
+
stepIndex: i,
|
|
104
|
+
status: "pending",
|
|
105
|
+
parallelGroup,
|
|
106
|
+
createdAt: now,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
await ctx.db.insert("chainExecutions", {
|
|
111
|
+
chainId,
|
|
112
|
+
stepId: step.id,
|
|
113
|
+
stepIndex: i,
|
|
114
|
+
status: "pending",
|
|
115
|
+
createdAt: now,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return chainId;
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create a new chain execution record (public API)
|
|
126
|
+
*/
|
|
127
|
+
export const createChain = mutation({
|
|
128
|
+
args: {
|
|
129
|
+
workspaceId: v.id("workspaces"),
|
|
130
|
+
steps: v.array(v.any()),
|
|
131
|
+
continueOnError: v.optional(v.boolean()),
|
|
132
|
+
timeout: v.optional(v.number()),
|
|
133
|
+
},
|
|
134
|
+
handler: async (ctx, args) => {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
|
|
137
|
+
// Validate steps have required fields
|
|
138
|
+
for (let i = 0; i < args.steps.length; i++) {
|
|
139
|
+
const step = args.steps[i] as ChainStep;
|
|
140
|
+
|
|
141
|
+
// Check for parallel steps
|
|
142
|
+
if (step.parallel) {
|
|
143
|
+
for (const pStep of step.parallel) {
|
|
144
|
+
if (!pStep.id || !pStep.provider || !pStep.action) {
|
|
145
|
+
throw new Error(`Parallel step at index ${i} missing required fields (id, provider, action)`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else if (!step.id || !step.provider || !step.action) {
|
|
149
|
+
throw new Error(`Step at index ${i} missing required fields (id, provider, action)`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Create chain record
|
|
154
|
+
const chainId = await ctx.db.insert("chains", {
|
|
155
|
+
workspaceId: args.workspaceId,
|
|
156
|
+
steps: args.steps,
|
|
157
|
+
status: "pending",
|
|
158
|
+
currentStep: 0,
|
|
159
|
+
results: {},
|
|
160
|
+
continueOnError: args.continueOnError ?? false,
|
|
161
|
+
timeout: args.timeout,
|
|
162
|
+
totalCostCents: 0,
|
|
163
|
+
totalLatencyMs: 0,
|
|
164
|
+
createdAt: now,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Create execution records for each step
|
|
168
|
+
for (let i = 0; i < args.steps.length; i++) {
|
|
169
|
+
const step = args.steps[i] as ChainStep;
|
|
170
|
+
|
|
171
|
+
if (step.parallel) {
|
|
172
|
+
const parallelGroup = `parallel_${i}_${Date.now()}`;
|
|
173
|
+
for (const pStep of step.parallel) {
|
|
174
|
+
await ctx.db.insert("chainExecutions", {
|
|
175
|
+
chainId,
|
|
176
|
+
stepId: pStep.id,
|
|
177
|
+
stepIndex: i,
|
|
178
|
+
status: "pending",
|
|
179
|
+
parallelGroup,
|
|
180
|
+
createdAt: now,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
await ctx.db.insert("chainExecutions", {
|
|
185
|
+
chainId,
|
|
186
|
+
stepId: step.id,
|
|
187
|
+
stepIndex: i,
|
|
188
|
+
status: "pending",
|
|
189
|
+
createdAt: now,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return chainId;
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create chain from template
|
|
200
|
+
*/
|
|
201
|
+
export const createChainFromTemplate = mutation({
|
|
202
|
+
args: {
|
|
203
|
+
workspaceId: v.id("workspaces"),
|
|
204
|
+
templateName: v.string(),
|
|
205
|
+
inputs: v.optional(v.any()),
|
|
206
|
+
},
|
|
207
|
+
handler: async (ctx, args) => {
|
|
208
|
+
// Get template
|
|
209
|
+
const template = await ctx.db
|
|
210
|
+
.query("chainTemplates")
|
|
211
|
+
.withIndex("by_name", (q) =>
|
|
212
|
+
q.eq("workspaceId", args.workspaceId).eq("name", args.templateName)
|
|
213
|
+
)
|
|
214
|
+
.first();
|
|
215
|
+
|
|
216
|
+
if (!template) {
|
|
217
|
+
throw new Error(`Template '${args.templateName}' not found`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
|
|
222
|
+
// Create chain from template
|
|
223
|
+
const chainId = await ctx.db.insert("chains", {
|
|
224
|
+
workspaceId: args.workspaceId,
|
|
225
|
+
steps: template.chain,
|
|
226
|
+
status: "pending",
|
|
227
|
+
currentStep: 0,
|
|
228
|
+
results: {},
|
|
229
|
+
totalCostCents: 0,
|
|
230
|
+
totalLatencyMs: 0,
|
|
231
|
+
createdAt: now,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Create execution records
|
|
235
|
+
for (let i = 0; i < template.chain.length; i++) {
|
|
236
|
+
const step = template.chain[i] as ChainStep;
|
|
237
|
+
|
|
238
|
+
if (step.parallel) {
|
|
239
|
+
const parallelGroup = `parallel_${i}_${Date.now()}`;
|
|
240
|
+
for (const pStep of step.parallel) {
|
|
241
|
+
await ctx.db.insert("chainExecutions", {
|
|
242
|
+
chainId,
|
|
243
|
+
stepId: pStep.id,
|
|
244
|
+
stepIndex: i,
|
|
245
|
+
status: "pending",
|
|
246
|
+
parallelGroup,
|
|
247
|
+
createdAt: now,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
await ctx.db.insert("chainExecutions", {
|
|
252
|
+
chainId,
|
|
253
|
+
stepId: step.id,
|
|
254
|
+
stepIndex: i,
|
|
255
|
+
status: "pending",
|
|
256
|
+
createdAt: now,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Increment template use count
|
|
262
|
+
await ctx.db.patch(template._id, {
|
|
263
|
+
useCount: (template.useCount || 0) + 1,
|
|
264
|
+
lastUsedAt: now,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return chainId;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Execute a single step and store the result
|
|
273
|
+
* Called by the orchestrator action
|
|
274
|
+
*/
|
|
275
|
+
export const executeStep = internalMutation({
|
|
276
|
+
args: {
|
|
277
|
+
chainId: v.id("chains"),
|
|
278
|
+
stepId: v.string(),
|
|
279
|
+
stepIndex: v.number(),
|
|
280
|
+
input: v.any(),
|
|
281
|
+
},
|
|
282
|
+
handler: async (ctx, args) => {
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
|
|
285
|
+
// Get execution record
|
|
286
|
+
const execution = await ctx.db
|
|
287
|
+
.query("chainExecutions")
|
|
288
|
+
.withIndex("by_chainId_stepId", (q) =>
|
|
289
|
+
q.eq("chainId", args.chainId).eq("stepId", args.stepId)
|
|
290
|
+
)
|
|
291
|
+
.first();
|
|
292
|
+
|
|
293
|
+
if (!execution) {
|
|
294
|
+
throw new Error(`No execution record found for step ${args.stepId}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Mark as running
|
|
298
|
+
await ctx.db.patch(execution._id, {
|
|
299
|
+
status: "running",
|
|
300
|
+
input: args.input,
|
|
301
|
+
startedAt: now,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Update chain status
|
|
305
|
+
const chain = await ctx.db.get(args.chainId);
|
|
306
|
+
if (chain && chain.status === "pending") {
|
|
307
|
+
await ctx.db.patch(args.chainId, {
|
|
308
|
+
status: "running",
|
|
309
|
+
startedAt: now,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return execution._id;
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Record step completion with result
|
|
319
|
+
*/
|
|
320
|
+
export const completeStep = internalMutation({
|
|
321
|
+
args: {
|
|
322
|
+
chainId: v.id("chains"),
|
|
323
|
+
stepId: v.string(),
|
|
324
|
+
output: v.any(),
|
|
325
|
+
latencyMs: v.number(),
|
|
326
|
+
costCents: v.number(),
|
|
327
|
+
},
|
|
328
|
+
handler: async (ctx, args) => {
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
|
|
331
|
+
// Get execution record
|
|
332
|
+
const execution = await ctx.db
|
|
333
|
+
.query("chainExecutions")
|
|
334
|
+
.withIndex("by_chainId_stepId", (q) =>
|
|
335
|
+
q.eq("chainId", args.chainId).eq("stepId", args.stepId)
|
|
336
|
+
)
|
|
337
|
+
.first();
|
|
338
|
+
|
|
339
|
+
if (!execution) {
|
|
340
|
+
throw new Error(`No execution record found for step ${args.stepId}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Mark as completed
|
|
344
|
+
await ctx.db.patch(execution._id, {
|
|
345
|
+
status: "completed",
|
|
346
|
+
output: args.output,
|
|
347
|
+
latencyMs: args.latencyMs,
|
|
348
|
+
costCents: args.costCents,
|
|
349
|
+
completedAt: now,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Update chain results
|
|
353
|
+
const chain = await ctx.db.get(args.chainId);
|
|
354
|
+
if (chain) {
|
|
355
|
+
const results = { ...(chain.results || {}), [args.stepId]: args.output };
|
|
356
|
+
const totalCost = (chain.totalCostCents || 0) + args.costCents;
|
|
357
|
+
const totalLatency = (chain.totalLatencyMs || 0) + args.latencyMs;
|
|
358
|
+
|
|
359
|
+
await ctx.db.patch(args.chainId, {
|
|
360
|
+
results,
|
|
361
|
+
totalCostCents: totalCost,
|
|
362
|
+
totalLatencyMs: totalLatency,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { success: true };
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Advance chain to next step or parallel batch
|
|
372
|
+
*/
|
|
373
|
+
export const advanceChain = internalMutation({
|
|
374
|
+
args: {
|
|
375
|
+
chainId: v.id("chains"),
|
|
376
|
+
},
|
|
377
|
+
handler: async (ctx, args) => {
|
|
378
|
+
const chain = await ctx.db.get(args.chainId);
|
|
379
|
+
if (!chain) {
|
|
380
|
+
throw new Error("Chain not found");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const nextStep = chain.currentStep + 1;
|
|
384
|
+
|
|
385
|
+
// Check if chain is complete
|
|
386
|
+
if (nextStep >= chain.steps.length) {
|
|
387
|
+
return { complete: true, nextStep: null };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Update current step
|
|
391
|
+
await ctx.db.patch(args.chainId, {
|
|
392
|
+
currentStep: nextStep,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Generate resume token for this position
|
|
396
|
+
const resumeToken = generateResumeToken(args.chainId, nextStep);
|
|
397
|
+
await ctx.db.patch(args.chainId, {
|
|
398
|
+
resumeToken,
|
|
399
|
+
canResume: true,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
complete: false,
|
|
404
|
+
nextStep,
|
|
405
|
+
nextStepDef: chain.steps[nextStep],
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Handle chain failure
|
|
412
|
+
* Stores partial results and generates resume token
|
|
413
|
+
*/
|
|
414
|
+
export const failChain = internalMutation({
|
|
415
|
+
args: {
|
|
416
|
+
chainId: v.id("chains"),
|
|
417
|
+
stepId: v.string(),
|
|
418
|
+
error: v.object({
|
|
419
|
+
code: v.string(),
|
|
420
|
+
message: v.string(),
|
|
421
|
+
retryAfter: v.optional(v.number()),
|
|
422
|
+
}),
|
|
423
|
+
},
|
|
424
|
+
handler: async (ctx, args) => {
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
|
|
427
|
+
// Update execution record
|
|
428
|
+
const execution = await ctx.db
|
|
429
|
+
.query("chainExecutions")
|
|
430
|
+
.withIndex("by_chainId_stepId", (q) =>
|
|
431
|
+
q.eq("chainId", args.chainId).eq("stepId", args.stepId)
|
|
432
|
+
)
|
|
433
|
+
.first();
|
|
434
|
+
|
|
435
|
+
if (execution) {
|
|
436
|
+
await ctx.db.patch(execution._id, {
|
|
437
|
+
status: "failed",
|
|
438
|
+
error: {
|
|
439
|
+
code: args.error.code,
|
|
440
|
+
message: args.error.message,
|
|
441
|
+
},
|
|
442
|
+
completedAt: now,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Get chain to determine resume position
|
|
447
|
+
const chain = await ctx.db.get(args.chainId);
|
|
448
|
+
if (!chain) {
|
|
449
|
+
throw new Error("Chain not found");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Generate resume token
|
|
453
|
+
const resumeToken = generateResumeToken(args.chainId, chain.currentStep);
|
|
454
|
+
|
|
455
|
+
// Update chain status
|
|
456
|
+
await ctx.db.patch(args.chainId, {
|
|
457
|
+
status: "failed",
|
|
458
|
+
error: {
|
|
459
|
+
stepId: args.stepId,
|
|
460
|
+
...args.error,
|
|
461
|
+
},
|
|
462
|
+
resumeToken,
|
|
463
|
+
canResume: true,
|
|
464
|
+
completedAt: now,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
resumeToken,
|
|
469
|
+
partialResults: chain.results,
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Resume chain from failed step (public mutation)
|
|
476
|
+
*/
|
|
477
|
+
export const resumeChain = mutation({
|
|
478
|
+
args: {
|
|
479
|
+
resumeToken: v.string(),
|
|
480
|
+
overrides: v.optional(v.any()), // Optional param overrides per step
|
|
481
|
+
},
|
|
482
|
+
handler: async (ctx, args) => {
|
|
483
|
+
// Find chain by resume token
|
|
484
|
+
const chain = await ctx.db
|
|
485
|
+
.query("chains")
|
|
486
|
+
.withIndex("by_resumeToken", (q) => q.eq("resumeToken", args.resumeToken))
|
|
487
|
+
.first();
|
|
488
|
+
|
|
489
|
+
if (!chain) {
|
|
490
|
+
throw new Error("Invalid or expired resume token");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (!chain.canResume) {
|
|
494
|
+
throw new Error("Chain cannot be resumed");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Reset failed step to pending
|
|
498
|
+
const executions = await ctx.db
|
|
499
|
+
.query("chainExecutions")
|
|
500
|
+
.withIndex("by_chainId_stepIndex", (q) =>
|
|
501
|
+
q.eq("chainId", chain._id).eq("stepIndex", chain.currentStep)
|
|
502
|
+
)
|
|
503
|
+
.collect();
|
|
504
|
+
|
|
505
|
+
for (const exec of executions) {
|
|
506
|
+
if (exec.status === "failed") {
|
|
507
|
+
await ctx.db.patch(exec._id, {
|
|
508
|
+
status: "pending",
|
|
509
|
+
error: undefined,
|
|
510
|
+
startedAt: undefined,
|
|
511
|
+
completedAt: undefined,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Update chain status
|
|
517
|
+
await ctx.db.patch(chain._id, {
|
|
518
|
+
status: "pending",
|
|
519
|
+
error: undefined,
|
|
520
|
+
resumeToken: undefined,
|
|
521
|
+
canResume: false,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
chainId: chain._id,
|
|
526
|
+
resumeFromStep: chain.currentStep,
|
|
527
|
+
steps: chain.steps,
|
|
528
|
+
results: chain.results,
|
|
529
|
+
overrides: args.overrides,
|
|
530
|
+
};
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Mark chain as completed
|
|
536
|
+
*/
|
|
537
|
+
export const completeChain = internalMutation({
|
|
538
|
+
args: {
|
|
539
|
+
chainId: v.id("chains"),
|
|
540
|
+
},
|
|
541
|
+
handler: async (ctx, args) => {
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
const chain = await ctx.db.get(args.chainId);
|
|
544
|
+
|
|
545
|
+
if (!chain) {
|
|
546
|
+
throw new Error("Chain not found");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
await ctx.db.patch(args.chainId, {
|
|
550
|
+
status: "completed",
|
|
551
|
+
canResume: false,
|
|
552
|
+
resumeToken: undefined,
|
|
553
|
+
completedAt: now,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
success: true,
|
|
558
|
+
results: chain.results,
|
|
559
|
+
totalCostCents: chain.totalCostCents,
|
|
560
|
+
totalLatencyMs: chain.totalLatencyMs,
|
|
561
|
+
};
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Pause chain execution (for long-running chains)
|
|
567
|
+
*/
|
|
568
|
+
export const pauseChain = mutation({
|
|
569
|
+
args: {
|
|
570
|
+
chainId: v.id("chains"),
|
|
571
|
+
},
|
|
572
|
+
handler: async (ctx, args) => {
|
|
573
|
+
const chain = await ctx.db.get(args.chainId);
|
|
574
|
+
if (!chain) {
|
|
575
|
+
throw new Error("Chain not found");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (chain.status !== "running") {
|
|
579
|
+
throw new Error("Can only pause running chains");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const resumeToken = generateResumeToken(args.chainId, chain.currentStep);
|
|
583
|
+
|
|
584
|
+
await ctx.db.patch(args.chainId, {
|
|
585
|
+
status: "paused",
|
|
586
|
+
resumeToken,
|
|
587
|
+
canResume: true,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
return { resumeToken };
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ============================================
|
|
595
|
+
// CHAIN TEMPLATE MUTATIONS
|
|
596
|
+
// ============================================
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Create or update a chain template
|
|
600
|
+
*/
|
|
601
|
+
export const saveChainTemplate = mutation({
|
|
602
|
+
args: {
|
|
603
|
+
id: v.optional(v.id("chainTemplates")),
|
|
604
|
+
workspaceId: v.id("workspaces"),
|
|
605
|
+
name: v.string(),
|
|
606
|
+
description: v.optional(v.string()),
|
|
607
|
+
inputs: v.optional(v.any()),
|
|
608
|
+
chain: v.array(v.any()),
|
|
609
|
+
},
|
|
610
|
+
handler: async (ctx, args) => {
|
|
611
|
+
const now = Date.now();
|
|
612
|
+
|
|
613
|
+
if (args.id) {
|
|
614
|
+
// Update existing
|
|
615
|
+
await ctx.db.patch(args.id, {
|
|
616
|
+
name: args.name,
|
|
617
|
+
description: args.description,
|
|
618
|
+
inputs: args.inputs,
|
|
619
|
+
chain: args.chain,
|
|
620
|
+
updatedAt: now,
|
|
621
|
+
});
|
|
622
|
+
return args.id;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Create new
|
|
626
|
+
return await ctx.db.insert("chainTemplates", {
|
|
627
|
+
workspaceId: args.workspaceId,
|
|
628
|
+
name: args.name,
|
|
629
|
+
description: args.description,
|
|
630
|
+
inputs: args.inputs,
|
|
631
|
+
chain: args.chain,
|
|
632
|
+
useCount: 0,
|
|
633
|
+
createdAt: now,
|
|
634
|
+
updatedAt: now,
|
|
635
|
+
});
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Delete a chain template
|
|
641
|
+
*/
|
|
642
|
+
export const deleteChainTemplate = mutation({
|
|
643
|
+
args: {
|
|
644
|
+
id: v.id("chainTemplates"),
|
|
645
|
+
},
|
|
646
|
+
handler: async (ctx, args) => {
|
|
647
|
+
await ctx.db.delete(args.id);
|
|
648
|
+
return { success: true };
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// ============================================
|
|
653
|
+
// QUERIES
|
|
654
|
+
// ============================================
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Get chain status and results
|
|
658
|
+
*/
|
|
659
|
+
export const getChain = query({
|
|
660
|
+
args: {
|
|
661
|
+
chainId: v.id("chains"),
|
|
662
|
+
},
|
|
663
|
+
handler: async (ctx, args) => {
|
|
664
|
+
const chain = await ctx.db.get(args.chainId);
|
|
665
|
+
if (!chain) {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
id: chain._id,
|
|
671
|
+
status: chain.status,
|
|
672
|
+
currentStep: chain.currentStep,
|
|
673
|
+
totalSteps: chain.steps.length,
|
|
674
|
+
results: chain.results,
|
|
675
|
+
error: chain.error,
|
|
676
|
+
canResume: chain.canResume,
|
|
677
|
+
resumeToken: chain.resumeToken,
|
|
678
|
+
totalCostCents: chain.totalCostCents,
|
|
679
|
+
totalLatencyMs: chain.totalLatencyMs,
|
|
680
|
+
createdAt: chain.createdAt,
|
|
681
|
+
startedAt: chain.startedAt,
|
|
682
|
+
completedAt: chain.completedAt,
|
|
683
|
+
};
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Get full execution trace for dashboard
|
|
689
|
+
*/
|
|
690
|
+
export const getChainTrace = query({
|
|
691
|
+
args: {
|
|
692
|
+
chainId: v.id("chains"),
|
|
693
|
+
},
|
|
694
|
+
handler: async (ctx, args) => {
|
|
695
|
+
const chain = await ctx.db.get(args.chainId);
|
|
696
|
+
if (!chain) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Get all step executions
|
|
701
|
+
const executions = await ctx.db
|
|
702
|
+
.query("chainExecutions")
|
|
703
|
+
.withIndex("by_chainId", (q) => q.eq("chainId", args.chainId))
|
|
704
|
+
.collect();
|
|
705
|
+
|
|
706
|
+
// Sort by stepIndex
|
|
707
|
+
executions.sort((a, b) => a.stepIndex - b.stepIndex);
|
|
708
|
+
|
|
709
|
+
// Build trace
|
|
710
|
+
const trace = executions.map((exec) => ({
|
|
711
|
+
stepId: exec.stepId,
|
|
712
|
+
stepIndex: exec.stepIndex,
|
|
713
|
+
status: exec.status,
|
|
714
|
+
parallelGroup: exec.parallelGroup,
|
|
715
|
+
input: exec.input,
|
|
716
|
+
output: exec.output,
|
|
717
|
+
latencyMs: exec.latencyMs,
|
|
718
|
+
costCents: exec.costCents,
|
|
719
|
+
error: exec.error,
|
|
720
|
+
startedAt: exec.startedAt,
|
|
721
|
+
completedAt: exec.completedAt,
|
|
722
|
+
}));
|
|
723
|
+
|
|
724
|
+
// Calculate tokens saved (estimate: ~400 tokens per avoided round-trip)
|
|
725
|
+
const completedSteps = executions.filter((e) => e.status === "completed");
|
|
726
|
+
const tokensSaved = completedSteps.length > 1 ? (completedSteps.length - 1) * 400 : 0;
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
chainId: chain._id,
|
|
730
|
+
workspaceId: chain.workspaceId,
|
|
731
|
+
status: chain.status,
|
|
732
|
+
steps: chain.steps,
|
|
733
|
+
currentStep: chain.currentStep,
|
|
734
|
+
results: chain.results,
|
|
735
|
+
error: chain.error,
|
|
736
|
+
trace,
|
|
737
|
+
totalCostCents: chain.totalCostCents,
|
|
738
|
+
totalLatencyMs: chain.totalLatencyMs,
|
|
739
|
+
tokensSaved,
|
|
740
|
+
canResume: chain.canResume,
|
|
741
|
+
resumeToken: chain.resumeToken,
|
|
742
|
+
createdAt: chain.createdAt,
|
|
743
|
+
startedAt: chain.startedAt,
|
|
744
|
+
completedAt: chain.completedAt,
|
|
745
|
+
};
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* List chains for a workspace
|
|
751
|
+
*/
|
|
752
|
+
export const listChains = query({
|
|
753
|
+
args: {
|
|
754
|
+
workspaceId: v.id("workspaces"),
|
|
755
|
+
status: v.optional(v.string()),
|
|
756
|
+
limit: v.optional(v.number()),
|
|
757
|
+
},
|
|
758
|
+
handler: async (ctx, args) => {
|
|
759
|
+
const limit = args.limit ?? 50;
|
|
760
|
+
|
|
761
|
+
let chainsQuery = ctx.db
|
|
762
|
+
.query("chains")
|
|
763
|
+
.withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
|
|
764
|
+
.order("desc");
|
|
765
|
+
|
|
766
|
+
const chains = await chainsQuery.take(limit);
|
|
767
|
+
|
|
768
|
+
// Filter by status if provided
|
|
769
|
+
let filtered = chains;
|
|
770
|
+
if (args.status && args.status !== "all") {
|
|
771
|
+
filtered = chains.filter((c) => c.status === args.status);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return filtered.map((chain) => ({
|
|
775
|
+
id: chain._id,
|
|
776
|
+
status: chain.status,
|
|
777
|
+
stepsCount: chain.steps.length,
|
|
778
|
+
currentStep: chain.currentStep,
|
|
779
|
+
totalCostCents: chain.totalCostCents,
|
|
780
|
+
totalLatencyMs: chain.totalLatencyMs,
|
|
781
|
+
error: chain.error,
|
|
782
|
+
canResume: chain.canResume,
|
|
783
|
+
createdAt: chain.createdAt,
|
|
784
|
+
completedAt: chain.completedAt,
|
|
785
|
+
}));
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* List chain templates for a workspace
|
|
791
|
+
*/
|
|
792
|
+
export const listChainTemplates = query({
|
|
793
|
+
args: {
|
|
794
|
+
workspaceId: v.id("workspaces"),
|
|
795
|
+
},
|
|
796
|
+
handler: async (ctx, args) => {
|
|
797
|
+
return await ctx.db
|
|
798
|
+
.query("chainTemplates")
|
|
799
|
+
.withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
|
|
800
|
+
.collect();
|
|
801
|
+
},
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Get chain template by name
|
|
806
|
+
*/
|
|
807
|
+
export const getChainTemplate = query({
|
|
808
|
+
args: {
|
|
809
|
+
workspaceId: v.id("workspaces"),
|
|
810
|
+
name: v.string(),
|
|
811
|
+
},
|
|
812
|
+
handler: async (ctx, args) => {
|
|
813
|
+
return await ctx.db
|
|
814
|
+
.query("chainTemplates")
|
|
815
|
+
.withIndex("by_name", (q) =>
|
|
816
|
+
q.eq("workspaceId", args.workspaceId).eq("name", args.name)
|
|
817
|
+
)
|
|
818
|
+
.first();
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Get chain statistics for workspace
|
|
824
|
+
*/
|
|
825
|
+
export const getChainStats = query({
|
|
826
|
+
args: {
|
|
827
|
+
workspaceId: v.id("workspaces"),
|
|
828
|
+
},
|
|
829
|
+
handler: async (ctx, args) => {
|
|
830
|
+
const chains = await ctx.db
|
|
831
|
+
.query("chains")
|
|
832
|
+
.withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
|
|
833
|
+
.collect();
|
|
834
|
+
|
|
835
|
+
const total = chains.length;
|
|
836
|
+
const completed = chains.filter((c) => c.status === "completed").length;
|
|
837
|
+
const failed = chains.filter((c) => c.status === "failed").length;
|
|
838
|
+
const running = chains.filter((c) => c.status === "running").length;
|
|
839
|
+
const paused = chains.filter((c) => c.status === "paused").length;
|
|
840
|
+
|
|
841
|
+
const totalCostCents = chains.reduce((acc, c) => acc + (c.totalCostCents || 0), 0);
|
|
842
|
+
const totalLatencyMs = chains.reduce((acc, c) => acc + (c.totalLatencyMs || 0), 0);
|
|
843
|
+
|
|
844
|
+
// Count total steps across all chains
|
|
845
|
+
const allExecutions = await Promise.all(
|
|
846
|
+
chains.map((c) =>
|
|
847
|
+
ctx.db
|
|
848
|
+
.query("chainExecutions")
|
|
849
|
+
.withIndex("by_chainId", (q) => q.eq("chainId", c._id))
|
|
850
|
+
.collect()
|
|
851
|
+
)
|
|
852
|
+
);
|
|
853
|
+
const totalSteps = allExecutions.flat().length;
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
total,
|
|
857
|
+
completed,
|
|
858
|
+
failed,
|
|
859
|
+
running,
|
|
860
|
+
paused,
|
|
861
|
+
totalCostCents,
|
|
862
|
+
totalLatencyMs,
|
|
863
|
+
totalSteps,
|
|
864
|
+
successRate: total > 0 ? Math.round((completed / total) * 100) : 0,
|
|
865
|
+
};
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// ============================================
|
|
870
|
+
// ACTIONS (Orchestration Logic)
|
|
871
|
+
// ============================================
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Main chain orchestrator
|
|
875
|
+
* Validates chain → executes steps sequentially/parallel → handles errors
|
|
876
|
+
*/
|
|
877
|
+
export const runChain = action({
|
|
878
|
+
args: {
|
|
879
|
+
workspaceId: v.id("workspaces"),
|
|
880
|
+
steps: v.array(v.any()),
|
|
881
|
+
continueOnError: v.optional(v.boolean()),
|
|
882
|
+
timeout: v.optional(v.number()),
|
|
883
|
+
},
|
|
884
|
+
handler: async (ctx, args): Promise<{
|
|
885
|
+
success: boolean;
|
|
886
|
+
chainId: Id<"chains">;
|
|
887
|
+
results?: Record<string, unknown>;
|
|
888
|
+
completedSteps?: string[];
|
|
889
|
+
failedStep?: { id: string; error: string; code: string };
|
|
890
|
+
partialResults?: Record<string, unknown>;
|
|
891
|
+
canResume?: boolean;
|
|
892
|
+
resumeToken?: string;
|
|
893
|
+
totalCostCents?: number;
|
|
894
|
+
totalLatencyMs?: number;
|
|
895
|
+
}> => {
|
|
896
|
+
const startTime = Date.now();
|
|
897
|
+
const timeout = args.timeout || 30000; // Default 30s
|
|
898
|
+
|
|
899
|
+
// 1. Create chain record
|
|
900
|
+
const chainId = await ctx.runMutation(internal.chains.createChainInternal, {
|
|
901
|
+
workspaceId: args.workspaceId,
|
|
902
|
+
steps: args.steps,
|
|
903
|
+
continueOnError: args.continueOnError,
|
|
904
|
+
timeout: args.timeout,
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
const completedSteps: string[] = [];
|
|
908
|
+
let currentResults: Record<string, unknown> = {};
|
|
909
|
+
|
|
910
|
+
try {
|
|
911
|
+
// 2. Execute steps
|
|
912
|
+
for (let i = 0; i < args.steps.length; i++) {
|
|
913
|
+
// Check timeout
|
|
914
|
+
if (Date.now() - startTime > timeout) {
|
|
915
|
+
throw new Error("TIMEOUT: Chain execution exceeded timeout");
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const step = args.steps[i] as ChainStep;
|
|
919
|
+
|
|
920
|
+
// Handle parallel steps
|
|
921
|
+
if (step.parallel && step.parallel.length > 0) {
|
|
922
|
+
const parallelResults = await ctx.runAction(internal.chains.runParallelSteps, {
|
|
923
|
+
chainId,
|
|
924
|
+
steps: step.parallel,
|
|
925
|
+
stepIndex: i,
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
// Merge parallel results
|
|
929
|
+
for (const [stepId, result] of Object.entries(parallelResults)) {
|
|
930
|
+
currentResults[stepId] = result;
|
|
931
|
+
completedSteps.push(stepId);
|
|
932
|
+
}
|
|
933
|
+
} else {
|
|
934
|
+
// Sequential step
|
|
935
|
+
await ctx.runMutation(internal.chains.executeStep, {
|
|
936
|
+
chainId,
|
|
937
|
+
stepId: step.id,
|
|
938
|
+
stepIndex: i,
|
|
939
|
+
input: step.params,
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
// Execute the actual API call (placeholder - in production calls Direct Call)
|
|
943
|
+
const stepStartTime = Date.now();
|
|
944
|
+
const result = await executeProviderCall(ctx, step);
|
|
945
|
+
const latencyMs = Date.now() - stepStartTime;
|
|
946
|
+
|
|
947
|
+
await ctx.runMutation(internal.chains.completeStep, {
|
|
948
|
+
chainId,
|
|
949
|
+
stepId: step.id,
|
|
950
|
+
output: result,
|
|
951
|
+
latencyMs,
|
|
952
|
+
costCents: result.costCents || 0,
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
currentResults[step.id] = result;
|
|
956
|
+
completedSteps.push(step.id);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Advance to next step
|
|
960
|
+
await ctx.runMutation(internal.chains.advanceChain, { chainId });
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// 3. Complete chain
|
|
964
|
+
const finalResult = await ctx.runMutation(internal.chains.completeChain, { chainId });
|
|
965
|
+
|
|
966
|
+
return {
|
|
967
|
+
success: true,
|
|
968
|
+
chainId,
|
|
969
|
+
results: currentResults,
|
|
970
|
+
completedSteps,
|
|
971
|
+
totalCostCents: finalResult.totalCostCents,
|
|
972
|
+
totalLatencyMs: finalResult.totalLatencyMs,
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
} catch (error) {
|
|
976
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
977
|
+
const errorCode = errorMessage.startsWith("TIMEOUT") ? "TIMEOUT" : "EXECUTION_ERROR";
|
|
978
|
+
|
|
979
|
+
// Find current step
|
|
980
|
+
const currentStep = args.steps[completedSteps.length] as ChainStep | undefined;
|
|
981
|
+
const failedStepId = currentStep?.id || "unknown";
|
|
982
|
+
|
|
983
|
+
// Handle failure
|
|
984
|
+
const failureResult = await ctx.runMutation(internal.chains.failChain, {
|
|
985
|
+
chainId,
|
|
986
|
+
stepId: failedStepId,
|
|
987
|
+
error: {
|
|
988
|
+
code: errorCode,
|
|
989
|
+
message: errorMessage,
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
success: false,
|
|
995
|
+
chainId,
|
|
996
|
+
completedSteps,
|
|
997
|
+
failedStep: {
|
|
998
|
+
id: failedStepId,
|
|
999
|
+
error: errorMessage,
|
|
1000
|
+
code: errorCode,
|
|
1001
|
+
},
|
|
1002
|
+
partialResults: failureResult.partialResults,
|
|
1003
|
+
canResume: true,
|
|
1004
|
+
resumeToken: failureResult.resumeToken,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Execute parallel steps concurrently
|
|
1012
|
+
*/
|
|
1013
|
+
export const runParallelSteps = internalAction({
|
|
1014
|
+
args: {
|
|
1015
|
+
chainId: v.id("chains"),
|
|
1016
|
+
steps: v.array(v.any()),
|
|
1017
|
+
stepIndex: v.number(),
|
|
1018
|
+
},
|
|
1019
|
+
handler: async (ctx, args): Promise<Record<string, unknown>> => {
|
|
1020
|
+
const results: Record<string, unknown> = {};
|
|
1021
|
+
|
|
1022
|
+
// Mark all steps as running
|
|
1023
|
+
for (const step of args.steps as ChainStep[]) {
|
|
1024
|
+
await ctx.runMutation(internal.chains.executeStep, {
|
|
1025
|
+
chainId: args.chainId,
|
|
1026
|
+
stepId: step.id,
|
|
1027
|
+
stepIndex: args.stepIndex,
|
|
1028
|
+
input: step.params,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Execute all in parallel
|
|
1033
|
+
const promises = (args.steps as ChainStep[]).map(async (step) => {
|
|
1034
|
+
const startTime = Date.now();
|
|
1035
|
+
|
|
1036
|
+
try {
|
|
1037
|
+
const result = await executeProviderCall(ctx, step);
|
|
1038
|
+
const latencyMs = Date.now() - startTime;
|
|
1039
|
+
|
|
1040
|
+
await ctx.runMutation(internal.chains.completeStep, {
|
|
1041
|
+
chainId: args.chainId,
|
|
1042
|
+
stepId: step.id,
|
|
1043
|
+
output: result,
|
|
1044
|
+
latencyMs,
|
|
1045
|
+
costCents: result.costCents || 0,
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
return { stepId: step.id, result };
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
throw new Error(`Step ${step.id} failed: ${error instanceof Error ? error.message : "Unknown"}`);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
const settledResults = await Promise.all(promises);
|
|
1055
|
+
|
|
1056
|
+
for (const { stepId, result } of settledResults) {
|
|
1057
|
+
results[stepId] = result;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return results;
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
// ============================================
|
|
1065
|
+
// HELPER: Execute provider call
|
|
1066
|
+
// Placeholder - in production, this calls the Direct Call API
|
|
1067
|
+
// ============================================
|
|
1068
|
+
|
|
1069
|
+
async function executeProviderCall(
|
|
1070
|
+
ctx: any,
|
|
1071
|
+
step: ChainStep
|
|
1072
|
+
): Promise<{ success: boolean; data?: unknown; costCents?: number }> {
|
|
1073
|
+
// For MVP/testing, return mock success
|
|
1074
|
+
// In production, this would:
|
|
1075
|
+
// 1. Look up the provider's Direct Call config
|
|
1076
|
+
// 2. Find the action definition
|
|
1077
|
+
// 3. Execute the HTTP request with params
|
|
1078
|
+
// 4. Return the mapped result
|
|
1079
|
+
|
|
1080
|
+
// Simulate some latency
|
|
1081
|
+
await new Promise((resolve) => setTimeout(resolve, 50 + Math.random() * 100));
|
|
1082
|
+
|
|
1083
|
+
return {
|
|
1084
|
+
success: true,
|
|
1085
|
+
data: {
|
|
1086
|
+
stepId: step.id,
|
|
1087
|
+
provider: step.provider,
|
|
1088
|
+
action: step.action,
|
|
1089
|
+
params: step.params,
|
|
1090
|
+
mockResult: `Executed ${step.action} on ${step.provider}`,
|
|
1091
|
+
timestamp: Date.now(),
|
|
1092
|
+
},
|
|
1093
|
+
costCents: Math.ceil(Math.random() * 5), // Mock 1-5 cents cost
|
|
1094
|
+
};
|
|
1095
|
+
}
|