@lakitu/sdk 0.1.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 (111) hide show
  1. package/README.md +166 -0
  2. package/convex/_generated/api.d.ts +45 -0
  3. package/convex/_generated/api.js +23 -0
  4. package/convex/_generated/dataModel.d.ts +58 -0
  5. package/convex/_generated/server.d.ts +143 -0
  6. package/convex/_generated/server.js +93 -0
  7. package/convex/cloud/CLAUDE.md +238 -0
  8. package/convex/cloud/_generated/api.ts +84 -0
  9. package/convex/cloud/_generated/component.ts +861 -0
  10. package/convex/cloud/_generated/dataModel.ts +60 -0
  11. package/convex/cloud/_generated/server.ts +156 -0
  12. package/convex/cloud/convex.config.ts +16 -0
  13. package/convex/cloud/index.ts +29 -0
  14. package/convex/cloud/intentSchema/generate.ts +447 -0
  15. package/convex/cloud/intentSchema/index.ts +16 -0
  16. package/convex/cloud/intentSchema/types.ts +418 -0
  17. package/convex/cloud/ksaPolicy.ts +554 -0
  18. package/convex/cloud/mail.ts +92 -0
  19. package/convex/cloud/schema.ts +322 -0
  20. package/convex/cloud/utils/kanbanContext.ts +229 -0
  21. package/convex/cloud/workflows/agentBoard.ts +451 -0
  22. package/convex/cloud/workflows/agentPrompt.ts +272 -0
  23. package/convex/cloud/workflows/agentThread.ts +374 -0
  24. package/convex/cloud/workflows/compileSandbox.ts +146 -0
  25. package/convex/cloud/workflows/crudBoard.ts +217 -0
  26. package/convex/cloud/workflows/crudKSAs.ts +262 -0
  27. package/convex/cloud/workflows/crudLorobeads.ts +371 -0
  28. package/convex/cloud/workflows/crudSkills.ts +205 -0
  29. package/convex/cloud/workflows/crudThreads.ts +708 -0
  30. package/convex/cloud/workflows/lifecycleSandbox.ts +1396 -0
  31. package/convex/cloud/workflows/sandboxConvex.ts +1046 -0
  32. package/convex/sandbox/README.md +90 -0
  33. package/convex/sandbox/_generated/api.d.ts +2934 -0
  34. package/convex/sandbox/_generated/api.js +23 -0
  35. package/convex/sandbox/_generated/dataModel.d.ts +60 -0
  36. package/convex/sandbox/_generated/server.d.ts +143 -0
  37. package/convex/sandbox/_generated/server.js +93 -0
  38. package/convex/sandbox/actions/bash.ts +130 -0
  39. package/convex/sandbox/actions/browser.ts +282 -0
  40. package/convex/sandbox/actions/file.ts +336 -0
  41. package/convex/sandbox/actions/lsp.ts +325 -0
  42. package/convex/sandbox/actions/pdf.ts +119 -0
  43. package/convex/sandbox/agent/codeExecLoop.ts +535 -0
  44. package/convex/sandbox/agent/decisions.ts +284 -0
  45. package/convex/sandbox/agent/index.ts +515 -0
  46. package/convex/sandbox/agent/subagents.ts +651 -0
  47. package/convex/sandbox/brandResearch/index.ts +417 -0
  48. package/convex/sandbox/context/index.ts +7 -0
  49. package/convex/sandbox/context/session.ts +402 -0
  50. package/convex/sandbox/convex.config.ts +17 -0
  51. package/convex/sandbox/index.ts +51 -0
  52. package/convex/sandbox/nodeActions/codeExec.ts +130 -0
  53. package/convex/sandbox/planning/beads.ts +187 -0
  54. package/convex/sandbox/planning/index.ts +8 -0
  55. package/convex/sandbox/planning/sync.ts +194 -0
  56. package/convex/sandbox/prompts/codeExec.ts +852 -0
  57. package/convex/sandbox/prompts/modes.ts +231 -0
  58. package/convex/sandbox/prompts/system.ts +142 -0
  59. package/convex/sandbox/schema.ts +510 -0
  60. package/convex/sandbox/state/artifacts.ts +99 -0
  61. package/convex/sandbox/state/checkpoints.ts +341 -0
  62. package/convex/sandbox/state/files.ts +383 -0
  63. package/convex/sandbox/state/index.ts +10 -0
  64. package/convex/sandbox/state/verification.actions.ts +268 -0
  65. package/convex/sandbox/state/verification.ts +101 -0
  66. package/convex/sandbox/tsconfig.json +25 -0
  67. package/convex/sandbox/utils/codeExecHelpers.ts +52 -0
  68. package/dist/cli/commands/build.d.ts +19 -0
  69. package/dist/cli/commands/build.d.ts.map +1 -0
  70. package/dist/cli/commands/build.js +223 -0
  71. package/dist/cli/commands/init.d.ts +16 -0
  72. package/dist/cli/commands/init.d.ts.map +1 -0
  73. package/dist/cli/commands/init.js +148 -0
  74. package/dist/cli/commands/publish.d.ts +12 -0
  75. package/dist/cli/commands/publish.d.ts.map +1 -0
  76. package/dist/cli/commands/publish.js +33 -0
  77. package/dist/cli/index.d.ts +14 -0
  78. package/dist/cli/index.d.ts.map +1 -0
  79. package/dist/cli/index.js +40 -0
  80. package/dist/sdk/builders.d.ts +104 -0
  81. package/dist/sdk/builders.d.ts.map +1 -0
  82. package/dist/sdk/builders.js +214 -0
  83. package/dist/sdk/index.d.ts +29 -0
  84. package/dist/sdk/index.d.ts.map +1 -0
  85. package/dist/sdk/index.js +38 -0
  86. package/dist/sdk/types.d.ts +107 -0
  87. package/dist/sdk/types.d.ts.map +1 -0
  88. package/dist/sdk/types.js +6 -0
  89. package/ksa/README.md +263 -0
  90. package/ksa/_generated/REFERENCE.md +2954 -0
  91. package/ksa/_generated/registry.ts +257 -0
  92. package/ksa/_shared/configReader.ts +302 -0
  93. package/ksa/_shared/configSchemas.ts +649 -0
  94. package/ksa/_shared/gateway.ts +175 -0
  95. package/ksa/_shared/ksaBehaviors.ts +411 -0
  96. package/ksa/_shared/ksaProxy.ts +248 -0
  97. package/ksa/_shared/localDb.ts +302 -0
  98. package/ksa/index.ts +134 -0
  99. package/package.json +93 -0
  100. package/runtime/browser/agent-browser.ts +330 -0
  101. package/runtime/entrypoint.ts +194 -0
  102. package/runtime/lsp/manager.ts +366 -0
  103. package/runtime/pdf/pdf-generator.ts +50 -0
  104. package/runtime/pdf/renderer.ts +357 -0
  105. package/runtime/pdf/schema.ts +97 -0
  106. package/runtime/services/file-watcher.ts +191 -0
  107. package/template/build.ts +307 -0
  108. package/template/e2b/Dockerfile +69 -0
  109. package/template/e2b/e2b.toml +13 -0
  110. package/template/e2b/prebuild.sh +68 -0
  111. package/template/e2b/start.sh +14 -0
@@ -0,0 +1,1046 @@
1
+ /**
2
+ * Sandbox Convex Lifecycle
3
+ *
4
+ * Manages E2B sandboxes running self-hosted Convex with the Agent SDK.
5
+ *
6
+ * Key differences from lifecycleSandbox.ts:
7
+ * - Uses self-hosted Convex backend instead of OpenCode
8
+ * - Native Convex streaming (no SSE/event forwarder)
9
+ * - Direct Convex client communication
10
+ * - Checkpoint-based chaining for long tasks
11
+ *
12
+ * Flow:
13
+ * 1. Create session in cloud Convex
14
+ * 2. Spawn E2B sandbox with self-hosted Convex
15
+ * 3. Wait for Convex backend to be ready
16
+ * 4. Deploy sandbox-agent functions
17
+ * 5. Start agent thread with prompt
18
+ * 6. Poll stream deltas for real-time UI
19
+ * 7. Collect results on completion
20
+ * 8. Checkpoint if timeout (for chaining)
21
+ */
22
+
23
+ import { v } from "convex/values";
24
+ import {
25
+ action,
26
+ mutation,
27
+ query,
28
+ internalMutation,
29
+ internalQuery,
30
+ internalAction,
31
+ } from "../_generated/server";
32
+ import { api, internal } from "../_generated/api";
33
+ import * as jose from "jose";
34
+ import { getServicePathsForKSAs, getDefaultKSAs, validateKSAs } from "../ksaPolicy";
35
+
36
+ // =============================================================================
37
+ // LOCAL E2B HELPERS (avoid calling parent app's internal functions)
38
+ // =============================================================================
39
+
40
+ /**
41
+ * Call a Lakitu sandbox action using Convex client
42
+ * Local implementation to avoid parent app dependency
43
+ */
44
+ async function callLakituAction(
45
+ sandboxUrl: string,
46
+ functionPath: string,
47
+ functionArgs: any,
48
+ timeoutMs?: number
49
+ ): Promise<{ success: boolean; data?: any; error?: string; durationMs: number }> {
50
+ const start = Date.now();
51
+ const { ConvexHttpClient } = await import("convex/browser");
52
+
53
+ try {
54
+ const client = new ConvexHttpClient(sandboxUrl);
55
+
56
+ const [modulePath, funcName] = functionPath.split(":");
57
+ if (!modulePath || !funcName) {
58
+ throw new Error(`Invalid function path: ${functionPath}`);
59
+ }
60
+
61
+ console.log(`[callLakituAction] Calling ${functionPath}`);
62
+
63
+ const { anyApi } = await import("convex/server");
64
+
65
+ const pathParts = modulePath.split("/");
66
+ let funcRef: any = anyApi;
67
+ for (const part of pathParts) {
68
+ funcRef = funcRef[part];
69
+ }
70
+ funcRef = funcRef[funcName];
71
+
72
+ const result = await client.action(funcRef, functionArgs);
73
+
74
+ return {
75
+ success: true,
76
+ data: result,
77
+ durationMs: Date.now() - start,
78
+ };
79
+ } catch (e: any) {
80
+ console.error(`[callLakituAction] Error:`, e);
81
+ return {
82
+ success: false,
83
+ error: e.message,
84
+ durationMs: Date.now() - start,
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Sandbox cleanup - handled by parent app or natural timeout
91
+ * Components can't use E2B SDK (requires Node.js), so we just log.
92
+ * Sandboxes have automatic 10-minute timeouts in E2B.
93
+ */
94
+ function logSandboxCleanup(sandboxId: string): void {
95
+ console.log(`[sandboxConvex] Sandbox ${sandboxId} will be cleaned up by timeout or parent app`);
96
+ }
97
+
98
+ // Cloud Convex URL for gateway calls
99
+ const CLOUD_CONVEX_URL =
100
+ process.env.CONVEX_URL || "https://earnest-shrimp-308.convex.cloud";
101
+
102
+ // Gateway URL (HTTP actions endpoint) - .convex.site for HTTP routes
103
+ const GATEWAY_URL = (process.env.CONVEX_URL || "https://earnest-shrimp-308.convex.cloud")
104
+ .replace(".convex.cloud", ".convex.site");
105
+
106
+ /**
107
+ * Generate a JWT for sandbox -> cloud gateway auth
108
+ * @param sessionId - Session ID to encode in JWT
109
+ * @param providedJwt - Optional pre-generated JWT (for when env var isn't accessible)
110
+ */
111
+ async function generateSandboxJwt(sessionId: string, providedJwt?: string): Promise<string> {
112
+ // If a JWT was provided (e.g., by the parent app wrapper), use it
113
+ if (providedJwt) {
114
+ return providedJwt;
115
+ }
116
+
117
+ const secret = process.env.SANDBOX_JWT_SECRET;
118
+ if (!secret) {
119
+ throw new Error("SANDBOX_JWT_SECRET not configured");
120
+ }
121
+
122
+ const jwt = await new jose.SignJWT({ sessionId })
123
+ .setProtectedHeader({ alg: "HS256" })
124
+ .setIssuedAt()
125
+ .setExpirationTime("24h")
126
+ .sign(new TextEncoder().encode(secret));
127
+
128
+ return jwt;
129
+ }
130
+
131
+ // ============================================
132
+ // Types
133
+ // ============================================
134
+
135
+ const sessionStatusType = v.union(
136
+ v.literal("pending"),
137
+ v.literal("starting"),
138
+ v.literal("running"),
139
+ v.literal("completed"),
140
+ v.literal("failed"),
141
+ v.literal("cancelled"),
142
+ v.literal("checkpointed"),
143
+ );
144
+
145
+ // ============================================
146
+ // Session CRUD
147
+ // ============================================
148
+
149
+ /**
150
+ * Create a new Convex sandbox session
151
+ */
152
+ export const createSession = mutation({
153
+ args: {
154
+ projectId: v.string(),
155
+ prompt: v.string(),
156
+ config: v.optional(v.any()),
157
+ },
158
+ handler: async (ctx, args) => {
159
+ return await ctx.db.insert("convexSandboxSessions", {
160
+ projectId: args.projectId,
161
+ prompt: args.prompt,
162
+ status: "pending",
163
+ config: args.config,
164
+ createdAt: Date.now(),
165
+ updatedAt: Date.now(),
166
+ iteration: 0,
167
+ });
168
+ },
169
+ });
170
+
171
+ /**
172
+ * Get session by ID
173
+ */
174
+ export const getSession = query({
175
+ args: { sessionId: v.id("convexSandboxSessions") },
176
+ handler: async (ctx, args) => {
177
+ return await ctx.db.get(args.sessionId);
178
+ },
179
+ });
180
+
181
+ /**
182
+ * Get session with logs
183
+ */
184
+ export const getSessionWithLogs = query({
185
+ args: { sessionId: v.id("convexSandboxSessions") },
186
+ handler: async (ctx, args) => {
187
+ const session = await ctx.db.get(args.sessionId);
188
+ if (!session) return null;
189
+
190
+ const logs = await ctx.db
191
+ .query("convexSandboxLogs")
192
+ .withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
193
+ .order("asc")
194
+ .take(500);
195
+
196
+ return {
197
+ ...session,
198
+ logs: logs.map((l) => ({
199
+ message: l.message,
200
+ level: l.level,
201
+ timestamp: l.timestamp,
202
+ })),
203
+ };
204
+ },
205
+ });
206
+
207
+ /**
208
+ * Get active session for a card (projectId = cardId)
209
+ * Used by frontend to subscribe to real-time logs
210
+ */
211
+ export const getActiveSessionForCard = query({
212
+ args: { cardId: v.string() },
213
+ handler: async (ctx, args) => {
214
+ // Find the most recent running/pending session for this card
215
+ const sessions = await ctx.db
216
+ .query("convexSandboxSessions")
217
+ .withIndex("by_project", (q) => q.eq("projectId", args.cardId))
218
+ .order("desc")
219
+ .take(5);
220
+
221
+ // Find active session (running, starting, or pending)
222
+ const activeSession = sessions.find(
223
+ (s) => s.status === "running" || s.status === "starting" || s.status === "pending"
224
+ );
225
+
226
+ const sessionToUse = activeSession || sessions[0];
227
+ if (!sessionToUse) return null;
228
+
229
+ // Get logs for session
230
+ const rawLogs = await ctx.db
231
+ .query("convexSandboxLogs")
232
+ .withIndex("by_session", (q) => q.eq("sessionId", sessionToUse._id))
233
+ .order("asc")
234
+ .take(500);
235
+
236
+ // Parse logs - return structured format
237
+ const logs = rawLogs.map((l) => {
238
+ // Try to parse as JSON (structured log)
239
+ if (l.stepType) {
240
+ try {
241
+ return JSON.parse(l.message);
242
+ } catch {
243
+ return { type: "text", label: l.message };
244
+ }
245
+ }
246
+ // Plain string log - wrap in basic structure
247
+ return { type: "text", label: l.message };
248
+ });
249
+
250
+ return {
251
+ ...sessionToUse,
252
+ logs, // Now returns array of structured objects
253
+ };
254
+ },
255
+ });
256
+
257
+ /**
258
+ * Get active session for a thread (projectId = "thread-{threadId}")
259
+ * Used by frontend to subscribe to real-time chain of thought
260
+ */
261
+ export const getActiveSessionForThread = query({
262
+ args: { threadId: v.string() },
263
+ handler: async (ctx, args) => {
264
+ const projectId = `thread-${args.threadId}`;
265
+
266
+ // Find the most recent running/pending session for this thread
267
+ const sessions = await ctx.db
268
+ .query("convexSandboxSessions")
269
+ .withIndex("by_project", (q) => q.eq("projectId", projectId))
270
+ .order("desc")
271
+ .take(5);
272
+
273
+ // Find active session (running, starting, or pending)
274
+ const activeSession = sessions.find(
275
+ (s) => s.status === "running" || s.status === "starting" || s.status === "pending"
276
+ );
277
+
278
+ const sessionToUse = activeSession || sessions[0];
279
+ if (!sessionToUse) return null;
280
+
281
+ // Get logs for session
282
+ const rawLogs = await ctx.db
283
+ .query("convexSandboxLogs")
284
+ .withIndex("by_session", (q) => q.eq("sessionId", sessionToUse._id))
285
+ .order("asc")
286
+ .take(500);
287
+
288
+ // Parse logs - return structured format
289
+ const logs = rawLogs.map((l) => {
290
+ // Try to parse as JSON (structured log)
291
+ if (l.stepType) {
292
+ try {
293
+ return JSON.parse(l.message);
294
+ } catch {
295
+ return { type: "text", label: l.message };
296
+ }
297
+ }
298
+ // Plain string log - wrap in basic structure
299
+ return { type: "text", label: l.message };
300
+ });
301
+
302
+ return {
303
+ ...sessionToUse,
304
+ logs, // Now returns array of structured objects
305
+ };
306
+ },
307
+ });
308
+
309
+ /**
310
+ * List sessions for a project
311
+ */
312
+ export const listSessions = query({
313
+ args: {
314
+ projectId: v.string(),
315
+ limit: v.optional(v.number()),
316
+ },
317
+ handler: async (ctx, args) => {
318
+ return await ctx.db
319
+ .query("convexSandboxSessions")
320
+ .withIndex("by_project", (q) => q.eq("projectId", args.projectId))
321
+ .order("desc")
322
+ .take(args.limit ?? 50);
323
+ },
324
+ });
325
+
326
+ /**
327
+ * Update session status
328
+ */
329
+ export const updateSession = internalMutation({
330
+ args: {
331
+ sessionId: v.id("convexSandboxSessions"),
332
+ status: v.optional(sessionStatusType),
333
+ sandboxId: v.optional(v.string()),
334
+ sandboxUrl: v.optional(v.string()),
335
+ threadId: v.optional(v.string()),
336
+ output: v.optional(v.any()),
337
+ error: v.optional(v.string()),
338
+ checkpointId: v.optional(v.string()),
339
+ metrics: v.optional(v.any()),
340
+ config: v.optional(v.any()),
341
+ },
342
+ handler: async (ctx, args) => {
343
+ const { sessionId, ...updates } = args;
344
+ const filtered = Object.fromEntries(
345
+ Object.entries(updates).filter(([_, v]) => v !== undefined),
346
+ );
347
+
348
+ await ctx.db.patch(sessionId, {
349
+ ...filtered,
350
+ updatedAt: Date.now(),
351
+ ...(args.status === "completed" || args.status === "failed"
352
+ ? { completedAt: Date.now() }
353
+ : {}),
354
+ });
355
+ },
356
+ });
357
+
358
+ /**
359
+ * Append log to session
360
+ */
361
+ export const appendLog = internalMutation({
362
+ args: {
363
+ sessionId: v.id("convexSandboxSessions"),
364
+ message: v.string(),
365
+ level: v.optional(
366
+ v.union(v.literal("info"), v.literal("warn"), v.literal("error")),
367
+ ),
368
+ },
369
+ handler: async (ctx, args) => {
370
+ await ctx.db.insert("convexSandboxLogs", {
371
+ sessionId: args.sessionId,
372
+ message: args.message,
373
+ level: args.level ?? "info",
374
+ timestamp: Date.now(),
375
+ });
376
+ },
377
+ });
378
+
379
+ /**
380
+ * Append multiple logs - supports both string and structured formats
381
+ */
382
+ export const appendLogs = mutation({
383
+ args: {
384
+ sessionId: v.id("convexSandboxSessions"),
385
+ logs: v.array(v.union(
386
+ v.string(),
387
+ v.object({
388
+ type: v.string(), // thinking, tool, search, file, text
389
+ label: v.string(),
390
+ status: v.optional(v.string()), // active, complete, error
391
+ icon: v.optional(v.string()),
392
+ details: v.optional(v.string()),
393
+ })
394
+ )),
395
+ },
396
+ handler: async (ctx, args) => {
397
+ const now = Date.now();
398
+ for (const log of args.logs) {
399
+ if (typeof log === "string") {
400
+ // Legacy string format
401
+ await ctx.db.insert("convexSandboxLogs", {
402
+ sessionId: args.sessionId,
403
+ message: log,
404
+ level: "info",
405
+ timestamp: now,
406
+ });
407
+ } else {
408
+ // Structured format - store as JSON in message field
409
+ await ctx.db.insert("convexSandboxLogs", {
410
+ sessionId: args.sessionId,
411
+ message: JSON.stringify(log),
412
+ level: "info",
413
+ timestamp: now,
414
+ // Store type separately for filtering
415
+ stepType: log.type,
416
+ });
417
+ }
418
+ }
419
+ },
420
+ });
421
+
422
+ // ============================================
423
+ // Sandbox Actions
424
+ // ============================================
425
+
426
+ /**
427
+ * Start a Convex sandbox session
428
+ */
429
+ export const startSession = action({
430
+ args: {
431
+ projectId: v.string(),
432
+ prompt: v.string(),
433
+ config: v.optional(v.any()),
434
+ },
435
+ handler: async (ctx, args) => {
436
+ // Create session
437
+ const sessionId = await ctx.runMutation(
438
+ api.workflows.sandboxConvex.createSession,
439
+ {
440
+ projectId: args.projectId,
441
+ prompt: args.prompt,
442
+ config: args.config,
443
+ },
444
+ );
445
+
446
+ // Run sandbox
447
+ const result = await ctx.runAction(
448
+ internal.workflows.sandboxConvex.runConvexSandbox,
449
+ {
450
+ sessionId,
451
+ prompt: args.prompt,
452
+ config: args.config,
453
+ },
454
+ );
455
+
456
+ return { sessionId, ...result };
457
+ },
458
+ });
459
+
460
+ /**
461
+ * Run the Convex sandbox - OPTIMIZED
462
+ *
463
+ * Key optimizations:
464
+ * 1. Single E2B action for create+start+waitReady (was 3 actions + 4 log mutations)
465
+ * 2. Fire-and-forget logs via scheduler (non-blocking)
466
+ * 3. Minimal awaits in critical path
467
+ */
468
+ export const runConvexSandbox = internalAction({
469
+ args: {
470
+ sessionId: v.id("convexSandboxSessions"),
471
+ prompt: v.string(),
472
+ config: v.optional(v.any()),
473
+ checkpointId: v.optional(v.string()),
474
+ },
475
+ handler: async (ctx, args) => {
476
+ const startTime = Date.now();
477
+ let sandboxId: string | null = null;
478
+
479
+ // Helper: fire-and-forget log (non-blocking)
480
+ const logAsync = (message: string) => {
481
+ ctx.scheduler.runAfter(0, internal.workflows.sandboxConvex.appendLog, {
482
+ sessionId: args.sessionId,
483
+ message,
484
+ });
485
+ };
486
+
487
+ // Single mutation: update status + log in one call
488
+ await ctx.runMutation(
489
+ internal.workflows.sandboxConvex.updateSession,
490
+ {
491
+ sessionId: args.sessionId,
492
+ status: "starting",
493
+ },
494
+ );
495
+ logAsync("🚀 Starting Convex sandbox...");
496
+
497
+ // Extract and validate allowedKSAs early for file-system policy enforcement
498
+ const configObj = (args.config || {}) as {
499
+ allowedKSAs?: string[];
500
+ skillConfigs?: Record<string, Record<string, unknown>>;
501
+ [key: string]: any;
502
+ };
503
+ let allowedKSAs = configObj.allowedKSAs;
504
+ const skillConfigs = configObj.skillConfigs || {};
505
+
506
+ // Default to all KSAs if not specified
507
+ if (!allowedKSAs || allowedKSAs.length === 0) {
508
+ allowedKSAs = getDefaultKSAs("all");
509
+ console.log(`[sandboxConvex] No allowedKSAs specified, using defaults: ${allowedKSAs.join(", ")}`);
510
+ } else {
511
+ // Validate the KSA names
512
+ const { valid, invalid } = validateKSAs(allowedKSAs);
513
+ if (invalid.length > 0) {
514
+ console.warn(`[sandboxConvex] Unknown KSAs ignored: ${invalid.join(", ")}`);
515
+ }
516
+ allowedKSAs = valid;
517
+ }
518
+
519
+ try {
520
+ // Generate JWT FIRST - needed as sandbox env var for KSAs
521
+ // Accept pre-generated JWT from config (for when component can't access env var)
522
+ const sandboxJwt = await generateSandboxJwt(args.sessionId, configObj.sandboxJwt);
523
+
524
+ // Get session to check if it's a thread-based session
525
+ const session = await ctx.runQuery(api.workflows.sandboxConvex.getSession, {
526
+ sessionId: args.sessionId,
527
+ });
528
+ const projectId = session?.projectId || "";
529
+ const isThreadSession = projectId.startsWith("thread-");
530
+ const envThreadId = isThreadSession ? projectId.replace("thread-", "") : undefined;
531
+
532
+ // Build env vars - include THREAD_ID for thread-based sessions
533
+ const envs: Record<string, string> = {
534
+ GATEWAY_URL: GATEWAY_URL,
535
+ SANDBOX_JWT: sandboxJwt,
536
+ };
537
+ if (envThreadId) {
538
+ envs.THREAD_ID = envThreadId;
539
+ }
540
+ // Also pass CARD_ID if provided in config (for kanban workflows)
541
+ if (configObj.cardId) {
542
+ envs.CARD_ID = configObj.cardId;
543
+ }
544
+ // Pass WORKSPACE_ID if provided (for workspace-scoped threads)
545
+ if (configObj.workspaceId) {
546
+ envs.WORKSPACE_ID = configObj.workspaceId;
547
+ }
548
+
549
+ // Check if sandbox was pre-created by parent app (preferred for component isolation)
550
+ let sandboxUrl: string;
551
+ let timings: Record<string, number>;
552
+ let fromPool: boolean;
553
+ let deletedKSAs: string[] = [];
554
+
555
+ if (configObj.preCreatedSandbox) {
556
+ // Use pre-created sandbox from parent app
557
+ const preSandbox = configObj.preCreatedSandbox;
558
+ sandboxId = preSandbox.sandboxId;
559
+ sandboxUrl = preSandbox.sandboxUrl;
560
+ timings = preSandbox.timings || { totalMs: 0 };
561
+ fromPool = preSandbox.fromPool || false;
562
+ deletedKSAs = preSandbox.deletedKSAs || [];
563
+ console.log(`[sandboxConvex] Using pre-created sandbox: ${sandboxId}`);
564
+ } else {
565
+ // No pre-created sandbox - this is an error in component context
566
+ // The parent app wrapper (api.lakitu.startSession) should always provide preCreatedSandbox
567
+ throw new Error(
568
+ "preCreatedSandbox not provided. Use api.lakitu.startSession wrapper instead of calling component directly."
569
+ );
570
+ }
571
+
572
+ // Fire-and-forget: log timing details
573
+ if (fromPool) {
574
+ logAsync(`📦 Sandbox ready in ${timings.totalMs}ms (from pool: claim=${timings.claimMs}ms, connect=${timings.connectMs}ms, policy=${timings.policyMs || 0}ms)`);
575
+ } else {
576
+ logAsync(`📦 Sandbox ready in ${timings.totalMs}ms (new: create=${timings.createMs}ms, policy=${timings.policyMs || 0}ms)`);
577
+ }
578
+
579
+ // Update session with sandbox info (single mutation)
580
+ await ctx.runMutation(
581
+ internal.workflows.sandboxConvex.updateSession,
582
+ {
583
+ sessionId: args.sessionId,
584
+ status: "running",
585
+ sandboxId,
586
+ sandboxUrl,
587
+ },
588
+ );
589
+
590
+ logAsync("🤖 Starting agent thread...");
591
+
592
+ // Convert KSA names to service paths for gateway enforcement
593
+ // (allowedKSAs already validated above, before sandbox creation)
594
+ const allowedServices = getServicePathsForKSAs(allowedKSAs);
595
+ console.log(`[sandboxConvex] KSAs: [${allowedKSAs.join(", ")}] => Services: [${allowedServices.join(", ")}]`);
596
+ if (deletedKSAs && deletedKSAs.length > 0) {
597
+ logAsync(`🔒 KSA policy: removed ${deletedKSAs.join(", ")} from sandbox`);
598
+ }
599
+
600
+ // Update session with allowed services for gateway policy
601
+ await ctx.runMutation(
602
+ internal.workflows.sandboxConvex.updateSession,
603
+ {
604
+ sessionId: args.sessionId,
605
+ // Store allowedServices in session config for gateway to check
606
+ config: {
607
+ ...configObj,
608
+ allowedKSAs,
609
+ allowedServices,
610
+ } as any,
611
+ },
612
+ );
613
+
614
+ // Build context with gateway config and sessionId for real-time logs
615
+ // Model config is passed from parent app via configObj.model (from unified settings)
616
+ const agentContext = {
617
+ ...configObj,
618
+ allowedKSAs, // Pass to agent so it knows what's available
619
+ skillConfigs, // KSA-specific configurations from stage
620
+ sessionId: args.sessionId, // For real-time chain of thought forwarding
621
+ // Pass cloud thread ID for artifact uploads (different from sandbox-local threadId)
622
+ cloudThreadId: envThreadId,
623
+ // Model config from unified settings (passed by parent app)
624
+ model: configObj.model,
625
+ fallbackModels: configObj.fallbackModels,
626
+ maxTokens: configObj.maxTokens,
627
+ temperature: configObj.temperature,
628
+ gatewayConfig: {
629
+ convexUrl: CLOUD_CONVEX_URL,
630
+ jwt: sandboxJwt,
631
+ },
632
+ };
633
+
634
+ // Log skill configs if any have custom instructions
635
+ const skillsWithInstructions = Object.entries(skillConfigs)
636
+ .filter(([_, cfg]) => cfg.instructions)
637
+ .map(([name]) => name);
638
+ if (skillsWithInstructions.length > 0) {
639
+ logAsync(`📋 Custom instructions for: ${skillsWithInstructions.join(", ")}`);
640
+ }
641
+
642
+ // Log intent schema if present (generated by agentThread for guidance)
643
+ const intentSchema = configObj.intentSchema as {
644
+ intent?: { summary?: string };
645
+ ksas?: { priority?: string[] };
646
+ meta?: { confidence?: string; latencyMs?: number };
647
+ } | undefined;
648
+ if (intentSchema?.intent?.summary) {
649
+ const priorityKSAs = intentSchema.ksas?.priority?.slice(0, 3).join(", ") || "none";
650
+ const confidence = intentSchema.meta?.confidence || "unknown";
651
+ logAsync(`🎯 Intent: "${intentSchema.intent.summary}" (${confidence} confidence, priority KSAs: ${priorityKSAs})`);
652
+ }
653
+
654
+ console.log(
655
+ `[sandboxConvex] Calling agent with gatewayConfig.convexUrl=${CLOUD_CONVEX_URL}, jwt length=${sandboxJwt.length}`,
656
+ );
657
+
658
+ // Call the sandbox Convex to start the agent using Convex client
659
+ // Uses code execution mode with single execute_code tool
660
+ const agentResult = await callLakituAction(
661
+ sandboxUrl,
662
+ "agent/index:startCodeExecThread",
663
+ {
664
+ prompt: args.prompt,
665
+ context: agentContext,
666
+ },
667
+ 180000, // 3 minutes for agent to complete
668
+ );
669
+
670
+ if (!agentResult.success) {
671
+ throw new Error(`Agent start failed: ${agentResult.error}`);
672
+ }
673
+
674
+ const threadId = agentResult.data?.threadId;
675
+ const agentText = agentResult.data?.text;
676
+ const agentToolCalls = agentResult.data?.toolCalls;
677
+
678
+ // Validate threadId was returned
679
+ if (!threadId) {
680
+ throw new Error(
681
+ `Agent did not return threadId. Response: ${JSON.stringify(agentResult.data)}`,
682
+ );
683
+ }
684
+
685
+ await ctx.runMutation(
686
+ internal.workflows.sandboxConvex.updateSession,
687
+ {
688
+ sessionId: args.sessionId,
689
+ threadId,
690
+ },
691
+ );
692
+
693
+ await ctx.runMutation(
694
+ internal.workflows.sandboxConvex.appendLog,
695
+ {
696
+ sessionId: args.sessionId,
697
+ message: `✅ Agent thread started: ${threadId}`,
698
+ },
699
+ );
700
+
701
+ // Check if agent completed synchronously (Lakitu returns full result)
702
+ if (agentText !== undefined) {
703
+ // Agent completed - extract output and finalize
704
+ const elapsed = Date.now() - startTime;
705
+ const output = {
706
+ response: agentText,
707
+ toolCalls: agentToolCalls || [],
708
+ messageCount: 1,
709
+ };
710
+
711
+ // OPTIMIZED: Single mutation for completion, fire-and-forget log + sandbox kill
712
+ await ctx.runMutation(
713
+ internal.workflows.sandboxConvex.updateSession,
714
+ {
715
+ sessionId: args.sessionId,
716
+ status: "completed",
717
+ output,
718
+ metrics: {
719
+ totalMs: elapsed,
720
+ pollCount: 0,
721
+ synchronous: true,
722
+ },
723
+ },
724
+ );
725
+
726
+ // Fire-and-forget: log + sandbox cleanup (don't await)
727
+ logAsync(`✅ Completed synchronously in ${(elapsed / 1000).toFixed(1)}s`);
728
+ logSandboxCleanup(sandboxId);
729
+
730
+ return {
731
+ success: true,
732
+ status: "completed",
733
+ sandboxId,
734
+ threadId,
735
+ output,
736
+ };
737
+ }
738
+
739
+ // OPTIMIZED: Schedule both in parallel (fire-and-forget)
740
+ ctx.scheduler.runAfter(5000, internal.workflows.sandboxConvex.pollCompletion, {
741
+ sessionId: args.sessionId,
742
+ sandboxId,
743
+ sandboxUrl,
744
+ threadId,
745
+ pollCount: 0,
746
+ startTime,
747
+ });
748
+ ctx.scheduler.runAfter(540000, internal.workflows.sandboxConvex.timeoutWatchdog, {
749
+ sessionId: args.sessionId,
750
+ sandboxId,
751
+ startTime,
752
+ });
753
+
754
+ return {
755
+ success: true,
756
+ status: "running",
757
+ sandboxId,
758
+ threadId,
759
+ };
760
+ } catch (error) {
761
+ const message =
762
+ error instanceof Error ? error.message : String(error);
763
+
764
+ // OPTIMIZED: Single await for critical update, fire-and-forget the rest
765
+ await ctx.runMutation(
766
+ internal.workflows.sandboxConvex.updateSession,
767
+ {
768
+ sessionId: args.sessionId,
769
+ status: "failed",
770
+ error: message,
771
+ },
772
+ );
773
+
774
+ // Fire-and-forget: log + sandbox cleanup
775
+ logAsync(`❌ Error: ${message}`);
776
+ if (sandboxId) {
777
+ logSandboxCleanup(sandboxId);
778
+ }
779
+
780
+ return { success: false, error: message };
781
+ }
782
+ },
783
+ });
784
+
785
+ /**
786
+ * Poll for agent completion - OPTIMIZED
787
+ *
788
+ * Key optimizations:
789
+ * 1. Parallel HTTP fetches for deltas + status
790
+ * 2. Fire-and-forget logs and sandbox cleanup
791
+ * 3. Reduced polling interval (2s instead of 5s)
792
+ */
793
+ export const pollCompletion = internalAction({
794
+ args: {
795
+ sessionId: v.id("convexSandboxSessions"),
796
+ sandboxId: v.string(),
797
+ sandboxUrl: v.string(),
798
+ threadId: v.string(),
799
+ pollCount: v.number(),
800
+ startTime: v.number(),
801
+ },
802
+ handler: async (ctx, args) => {
803
+ const maxPolls = 150; // ~5 minutes at 2s intervals
804
+ const pollInterval = 2000; // Reduced from 5s to 2s
805
+
806
+ // Helper for fire-and-forget log
807
+ const logAsync = (message: string) => {
808
+ ctx.scheduler.runAfter(0, internal.workflows.sandboxConvex.appendLog, {
809
+ sessionId: args.sessionId,
810
+ message,
811
+ });
812
+ };
813
+
814
+ try {
815
+ // Check if session is already completed
816
+ const session = await ctx.runQuery(
817
+ api.workflows.sandboxConvex.getSession,
818
+ { sessionId: args.sessionId },
819
+ );
820
+
821
+ if (
822
+ session?.status === "completed" ||
823
+ session?.status === "failed" ||
824
+ session?.status === "cancelled"
825
+ ) {
826
+ // Already done, fire-and-forget cleanup
827
+ logSandboxCleanup(args.sandboxId);
828
+ return;
829
+ }
830
+
831
+ // OPTIMIZED: Parallel fetches for deltas + status
832
+ const [deltasRes, statusRes] = await Promise.all([
833
+ fetch(`${args.sandboxUrl}/api/run/agent/index/getStreamDeltas`, {
834
+ method: "POST",
835
+ headers: { "Content-Type": "application/json" },
836
+ body: JSON.stringify({ args: { threadId: args.threadId } }),
837
+ signal: AbortSignal.timeout(5000),
838
+ }).catch(() => null),
839
+ fetch(`${args.sandboxUrl}/api/run/agent/index/getThreadMessages`, {
840
+ method: "POST",
841
+ headers: { "Content-Type": "application/json" },
842
+ body: JSON.stringify({ args: { threadId: args.threadId } }),
843
+ signal: AbortSignal.timeout(5000),
844
+ }),
845
+ ]);
846
+
847
+ // Process deltas (fire-and-forget log append)
848
+ if (deltasRes?.ok) {
849
+ const deltas = await deltasRes.json();
850
+ if (deltas?.length > 0) {
851
+ const logs = deltas.map((d: any) => formatDelta(d)).filter(Boolean);
852
+ if (logs.length > 0) {
853
+ // Fire-and-forget
854
+ ctx.scheduler.runAfter(0, api.workflows.sandboxConvex.appendLogs, {
855
+ sessionId: args.sessionId,
856
+ logs,
857
+ });
858
+ }
859
+ }
860
+ }
861
+
862
+ if (!statusRes.ok) {
863
+ throw new Error(`Status fetch failed: ${statusRes.status}`);
864
+ }
865
+
866
+ const messages = await statusRes.json();
867
+ const lastMessage = messages?.[messages.length - 1];
868
+
869
+ // Check if the last message indicates completion
870
+ const isComplete =
871
+ lastMessage?.role === "assistant" &&
872
+ !lastMessage?.inProgress &&
873
+ messages.length > 1;
874
+
875
+ if (isComplete) {
876
+ const elapsed = Date.now() - args.startTime;
877
+ const output = extractOutput(messages);
878
+
879
+ // Critical: update session status (must await)
880
+ await ctx.runMutation(
881
+ internal.workflows.sandboxConvex.updateSession,
882
+ {
883
+ sessionId: args.sessionId,
884
+ status: "completed",
885
+ output,
886
+ metrics: {
887
+ totalMs: elapsed,
888
+ pollCount: args.pollCount,
889
+ messageCount: messages.length,
890
+ },
891
+ },
892
+ );
893
+
894
+ // Fire-and-forget: log + sandbox cleanup
895
+ logAsync(`✅ Completed in ${(elapsed / 1000).toFixed(1)}s`);
896
+ logSandboxCleanup(args.sandboxId);
897
+ return;
898
+ }
899
+
900
+ // Not done, schedule next poll (fire-and-forget)
901
+ if (args.pollCount < maxPolls) {
902
+ ctx.scheduler.runAfter(pollInterval, internal.workflows.sandboxConvex.pollCompletion, {
903
+ ...args,
904
+ pollCount: args.pollCount + 1,
905
+ });
906
+ } else {
907
+ throw new Error(`Timeout: Agent still running after ${maxPolls} polls`);
908
+ }
909
+ } catch (error) {
910
+ const message =
911
+ error instanceof Error ? error.message : String(error);
912
+
913
+ // Critical: update session status (must await)
914
+ await ctx.runMutation(
915
+ internal.workflows.sandboxConvex.updateSession,
916
+ {
917
+ sessionId: args.sessionId,
918
+ status: "failed",
919
+ error: message,
920
+ },
921
+ );
922
+
923
+ // Fire-and-forget: log + cleanup
924
+ logAsync(`❌ Poll error: ${message}`);
925
+ logSandboxCleanup(args.sandboxId);
926
+ }
927
+ },
928
+ });
929
+
930
+ /**
931
+ * Timeout watchdog - OPTIMIZED
932
+ */
933
+ export const timeoutWatchdog = internalAction({
934
+ args: {
935
+ sessionId: v.id("convexSandboxSessions"),
936
+ sandboxId: v.string(),
937
+ startTime: v.number(),
938
+ },
939
+ handler: async (ctx, args) => {
940
+ const session = await ctx.runQuery(
941
+ api.workflows.sandboxConvex.getSession,
942
+ { sessionId: args.sessionId },
943
+ );
944
+
945
+ if (
946
+ session?.status === "completed" ||
947
+ session?.status === "failed" ||
948
+ session?.status === "cancelled"
949
+ ) {
950
+ return;
951
+ }
952
+
953
+ const elapsed = Date.now() - args.startTime;
954
+
955
+ // Critical: update session status (must await)
956
+ await ctx.runMutation(
957
+ internal.workflows.sandboxConvex.updateSession,
958
+ {
959
+ sessionId: args.sessionId,
960
+ status: "failed",
961
+ error: `Timeout after ${(elapsed / 1000 / 60).toFixed(1)} minutes`,
962
+ },
963
+ );
964
+
965
+ // Fire-and-forget: log + cleanup
966
+ ctx.scheduler.runAfter(0, internal.workflows.sandboxConvex.appendLog, {
967
+ sessionId: args.sessionId,
968
+ message: `⏱️ Session timed out after ${(elapsed / 1000 / 60).toFixed(1)} minutes`,
969
+ });
970
+ logSandboxCleanup(args.sandboxId);
971
+ },
972
+ });
973
+
974
+ /**
975
+ * Cancel a session
976
+ */
977
+ export const cancelSession = action({
978
+ args: { sessionId: v.id("convexSandboxSessions") },
979
+ handler: async (ctx, args) => {
980
+ const session = await ctx.runQuery(
981
+ api.workflows.sandboxConvex.getSession,
982
+ { sessionId: args.sessionId },
983
+ );
984
+
985
+ if (!session) {
986
+ throw new Error("Session not found");
987
+ }
988
+
989
+ await ctx.runMutation(
990
+ internal.workflows.sandboxConvex.updateSession,
991
+ {
992
+ sessionId: args.sessionId,
993
+ status: "cancelled",
994
+ },
995
+ );
996
+
997
+ if (session.sandboxId) {
998
+ logSandboxCleanup(session.sandboxId);
999
+ }
1000
+ },
1001
+ });
1002
+
1003
+ // ============================================
1004
+ // Helpers
1005
+ // ============================================
1006
+
1007
+ function formatDelta(delta: any): string | null {
1008
+ if (!delta) return null;
1009
+
1010
+ if (delta.type === "text" && delta.text) {
1011
+ return delta.text.slice(0, 200);
1012
+ }
1013
+
1014
+ if (delta.type === "tool-call") {
1015
+ return `🔧 ${delta.toolName}...`;
1016
+ }
1017
+
1018
+ if (delta.type === "tool-result") {
1019
+ const success = delta.result?.success !== false;
1020
+ return `${success ? "✅" : "❌"} ${delta.toolName}`;
1021
+ }
1022
+
1023
+ return null;
1024
+ }
1025
+
1026
+ function extractOutput(messages: any[]): any {
1027
+ const toolCalls: any[] = [];
1028
+ let response = "";
1029
+
1030
+ for (const msg of messages) {
1031
+ if (msg.role === "assistant") {
1032
+ if (msg.content) {
1033
+ response = msg.content;
1034
+ }
1035
+ if (msg.toolCalls) {
1036
+ toolCalls.push(...msg.toolCalls);
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ return {
1042
+ response,
1043
+ toolCalls,
1044
+ messageCount: messages.length,
1045
+ };
1046
+ }