@nordsym/apiclaw 1.3.13 → 1.4.1

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