@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.
Files changed (44) hide show
  1. package/PRD-API-CHAINING.md +483 -0
  2. package/PRD-HARDEN-SHELL.md +18 -12
  3. package/convex/_generated/api.d.ts +2 -0
  4. package/convex/chains.ts +1095 -0
  5. package/convex/schema.ts +101 -0
  6. package/dist/chain-types.d.ts +187 -0
  7. package/dist/chain-types.d.ts.map +1 -0
  8. package/dist/chain-types.js +33 -0
  9. package/dist/chain-types.js.map +1 -0
  10. package/dist/chainExecutor.d.ts +122 -0
  11. package/dist/chainExecutor.d.ts.map +1 -0
  12. package/dist/chainExecutor.js +454 -0
  13. package/dist/chainExecutor.js.map +1 -0
  14. package/dist/chainResolver.d.ts +100 -0
  15. package/dist/chainResolver.d.ts.map +1 -0
  16. package/dist/chainResolver.js +519 -0
  17. package/dist/chainResolver.js.map +1 -0
  18. package/dist/chainResolver.test.d.ts +5 -0
  19. package/dist/chainResolver.test.d.ts.map +1 -0
  20. package/dist/chainResolver.test.js +201 -0
  21. package/dist/chainResolver.test.js.map +1 -0
  22. package/dist/execute.d.ts +4 -1
  23. package/dist/execute.d.ts.map +1 -1
  24. package/dist/execute.js +3 -0
  25. package/dist/execute.js.map +1 -1
  26. package/dist/index.js +382 -2
  27. package/dist/index.js.map +1 -1
  28. package/landing/public/logos/chattgpt.svg +1 -0
  29. package/landing/public/logos/claude.svg +1 -0
  30. package/landing/public/logos/gemini.svg +1 -0
  31. package/landing/public/logos/grok.svg +1 -0
  32. package/landing/src/app/page.tsx +12 -21
  33. package/landing/src/app/workspace/chains/page.tsx +520 -0
  34. package/landing/src/components/AITestimonials.tsx +15 -9
  35. package/landing/src/components/ChainStepDetail.tsx +310 -0
  36. package/landing/src/components/ChainTrace.tsx +261 -0
  37. package/landing/src/lib/stats.json +1 -1
  38. package/package.json +1 -1
  39. package/src/chain-types.ts +270 -0
  40. package/src/chainExecutor.ts +730 -0
  41. package/src/chainResolver.test.ts +246 -0
  42. package/src/chainResolver.ts +658 -0
  43. package/src/execute.ts +23 -0
  44. package/src/index.ts +423 -2
@@ -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
+ }