@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,1396 @@
1
+ /**
2
+ * Sandbox Lifecycle - E2B sandbox spawn and session management
3
+ *
4
+ * NON-BLOCKING flow (avoids E2B gRPC stream timeout):
5
+ * 1. Create session in pending state
6
+ * 2. Spawn E2B sandbox, start OpenCode server
7
+ * 3. POST async prompt to OpenCode (returns immediately)
8
+ * 4. Schedule polling action to check for completion
9
+ * 5. Polling action collects results when done (fresh E2B connection each time)
10
+ *
11
+ * Why non-blocking? The E2B SDK uses @connectrpc/connect for gRPC which
12
+ * times out after ~50-60s inside Convex actions, even though the sandbox
13
+ * continues running. By using scheduled polling with fresh connections,
14
+ * we avoid the stream timeout issue entirely.
15
+ */
16
+
17
+ import { v } from "convex/values";
18
+ import { action, mutation, query, internalMutation, internalQuery, internalAction, ActionCtx } from "../_generated/server";
19
+ import { api, internal } from "../_generated/api";
20
+
21
+ // Agent config defaults (used when no model config is provided via args)
22
+ // These are fallback defaults - the parent app should pass model config from unified settings
23
+ const DEFAULT_AGENT_CONFIG = {
24
+ provider: "openrouter",
25
+ primaryModel: "anthropic/claude-sonnet-4",
26
+ models: ["anthropic/claude-sonnet-4", "anthropic/claude-3.5-sonnet"],
27
+ providerPreferences: { quantizations: ["bf16", "fp16"] },
28
+ parameters: { reasoning: true },
29
+ maxTokens: 16384,
30
+ };
31
+
32
+ /**
33
+ * Get agent config from args (unified settings) or fallback to defaults
34
+ */
35
+ function getAgentConfig(config: any = {}): typeof DEFAULT_AGENT_CONFIG {
36
+ if (config.model) {
37
+ // Build fallback models array: explicitly provided or construct from primary
38
+ const fallbackModels = config.fallbackModels || [];
39
+ const models = [config.model, ...fallbackModels];
40
+
41
+ return {
42
+ provider: "openrouter",
43
+ primaryModel: config.model,
44
+ models,
45
+ providerPreferences: DEFAULT_AGENT_CONFIG.providerPreferences,
46
+ parameters: DEFAULT_AGENT_CONFIG.parameters,
47
+ maxTokens: config.maxTokens || DEFAULT_AGENT_CONFIG.maxTokens,
48
+ };
49
+ }
50
+ return DEFAULT_AGENT_CONFIG;
51
+ }
52
+
53
+ // ============================================
54
+ // Metrics & Timing
55
+ // ============================================
56
+
57
+ interface TimingMetrics {
58
+ startTime: number;
59
+ steps: Array<{ name: string; startMs: number; durationMs: number }>;
60
+ totals: {
61
+ sandboxCreate?: number;
62
+ serverStartup?: number;
63
+ authConfig?: number;
64
+ sessionCreate?: number;
65
+ promptSend?: number;
66
+ totalSetup?: number;
67
+ agentExecution?: number;
68
+ resultCollection?: number;
69
+ totalDuration?: number;
70
+ };
71
+ }
72
+
73
+ function createMetrics(): TimingMetrics {
74
+ return {
75
+ startTime: Date.now(),
76
+ steps: [],
77
+ totals: {},
78
+ };
79
+ }
80
+
81
+ function recordStep(metrics: TimingMetrics, name: string, startMs: number): void {
82
+ const durationMs = Date.now() - startMs;
83
+ metrics.steps.push({ name, startMs: startMs - metrics.startTime, durationMs });
84
+ console.log(`ā±ļø [${name}] ${durationMs}ms`);
85
+ }
86
+
87
+ function formatMetrics(metrics: TimingMetrics): string {
88
+ const totalDuration = Date.now() - metrics.startTime;
89
+ const lines = [
90
+ `\nšŸ“Š TIMING REPORT (total: ${(totalDuration / 1000).toFixed(1)}s)`,
91
+ "─".repeat(50),
92
+ ];
93
+
94
+ for (const step of metrics.steps) {
95
+ const pct = totalDuration > 0 ? ((step.durationMs / totalDuration) * 100).toFixed(1) : "0";
96
+ const bar = "ā–ˆ".repeat(Math.min(20, Math.round(step.durationMs / (totalDuration / 20))));
97
+ lines.push(`${step.name.padEnd(25)} ${(step.durationMs / 1000).toFixed(2)}s ${bar} ${pct}%`);
98
+ }
99
+
100
+ lines.push("─".repeat(50));
101
+ return lines.join("\n");
102
+ }
103
+
104
+ const statusType = v.union(
105
+ v.literal("pending"),
106
+ v.literal("running"),
107
+ v.literal("completed"),
108
+ v.literal("failed"),
109
+ v.literal("cancelled")
110
+ );
111
+
112
+ // ============================================
113
+ // Session CRUD
114
+ // ============================================
115
+
116
+ export const createSession = mutation({
117
+ args: { projectId: v.string(), config: v.optional(v.any()), secret: v.optional(v.string()) },
118
+ handler: async (ctx, args) => {
119
+ return await ctx.db.insert("agentSessions", {
120
+ projectId: args.projectId,
121
+ status: "pending",
122
+ config: args.config,
123
+ secret: args.secret,
124
+ createdAt: Date.now(),
125
+ updatedAt: Date.now(),
126
+ });
127
+ },
128
+ });
129
+
130
+ export const getSession = query({
131
+ args: { sessionId: v.id("agentSessions") },
132
+ handler: async (ctx, args) => ctx.db.get(args.sessionId),
133
+ });
134
+
135
+ // Aliases for backward compatibility
136
+ export const get = getSession;
137
+ export const create = createSession;
138
+
139
+ export const getSessionDetails = query({
140
+ args: { sessionId: v.id("agentSessions") },
141
+ handler: async (ctx, args) => {
142
+ const session = await ctx.db.get(args.sessionId);
143
+ if (!session) return null;
144
+ const logs = await ctx.db.query("agentSessionLogs")
145
+ .withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
146
+ .order("asc").take(500);
147
+ const output = session.output as any;
148
+ return {
149
+ ...session,
150
+ logs: logs.map(l => l.message),
151
+ // Structured output fields:
152
+ response: output?.response || output?.text || "", // Final answer for user
153
+ thinking: output?.thinking || [], // Chain of thought / tool activity
154
+ artifacts: output?.artifacts || [],
155
+ toolCalls: output?.toolCalls || [],
156
+ todos: output?.todos || [],
157
+ diffs: output?.diffs || [],
158
+ };
159
+ },
160
+ });
161
+
162
+ /** Get session metrics for timing analysis */
163
+ export const getSessionMetrics = query({
164
+ args: { sessionId: v.id("agentSessions") },
165
+ handler: async (ctx, args) => {
166
+ const session = await ctx.db.get(args.sessionId);
167
+ if (!session) return null;
168
+
169
+ const output = session.output as any;
170
+ const metrics = output?.metrics || {};
171
+
172
+ // Calculate totals
173
+ const setupMs = metrics.setupMs || 0;
174
+ const agentExecutionMs = metrics.agentExecutionMs || 0;
175
+ const totalMs = setupMs + agentExecutionMs;
176
+
177
+ return {
178
+ sessionId: args.sessionId,
179
+ status: session.status,
180
+ createdAt: session.createdAt,
181
+ completedAt: session.completedAt,
182
+
183
+ // Timing breakdown
184
+ timing: {
185
+ totalMs,
186
+ totalSeconds: (totalMs / 1000).toFixed(1),
187
+
188
+ // Setup phase
189
+ setupMs,
190
+ setupBreakdown: {
191
+ sandboxCreateMs: metrics.sandboxCreateMs,
192
+ serverStartupMs: metrics.serverStartupMs,
193
+ authConfigMs: metrics.authConfigMs,
194
+ sessionCreateMs: metrics.sessionCreateMs,
195
+ promptSendMs: metrics.promptSendMs,
196
+ },
197
+
198
+ // Execution phase
199
+ agentExecutionMs,
200
+ pollCount: metrics.pollCount,
201
+
202
+ // Percentages
203
+ setupPercent: totalMs > 0 ? ((setupMs / totalMs) * 100).toFixed(1) : "0",
204
+ executionPercent: totalMs > 0 ? ((agentExecutionMs / totalMs) * 100).toFixed(1) : "0",
205
+ },
206
+
207
+ // Output stats
208
+ stats: {
209
+ messagesCount: metrics.messagesCount,
210
+ toolCallsCount: metrics.toolCallsCount,
211
+ partsCount: metrics.partsCount,
212
+ },
213
+
214
+ // Detailed steps (if available)
215
+ steps: metrics.steps,
216
+ };
217
+ },
218
+ });
219
+
220
+ export const listSessions = query({
221
+ args: { projectId: v.string(), limit: v.optional(v.number()) },
222
+ handler: async (ctx, args) => {
223
+ const q = ctx.db.query("agentSessions")
224
+ .withIndex("by_project", (q) => q.eq("projectId", args.projectId))
225
+ .order("desc");
226
+ return args.limit ? q.take(args.limit) : q.collect();
227
+ },
228
+ });
229
+
230
+ export const getActiveSessionForCard = query({
231
+ args: { cardId: v.string() },
232
+ handler: async (ctx, args) => {
233
+ const sessions = await ctx.db.query("agentSessions")
234
+ .filter((q) => q.or(q.eq(q.field("status"), "pending"), q.eq(q.field("status"), "running")))
235
+ .collect();
236
+ const session = sessions.find((s: any) => s.config?.cardId?.toString() === args.cardId);
237
+ if (!session) return null;
238
+
239
+ // Include logs from agentSessionLogs table
240
+ const logs = await ctx.db.query("agentSessionLogs")
241
+ .withIndex("by_session", (q) => q.eq("sessionId", session._id))
242
+ .order("asc")
243
+ .take(200);
244
+
245
+ return {
246
+ ...session,
247
+ logs: logs.map(l => l.message),
248
+ };
249
+ },
250
+ });
251
+
252
+ export const getActiveSessionsForCardInternal = internalQuery({
253
+ args: { cardId: v.string() },
254
+ handler: async (ctx, args) => {
255
+ const sessions = await ctx.db.query("agentSessions")
256
+ .filter((q) => q.or(q.eq(q.field("status"), "pending"), q.eq(q.field("status"), "running")))
257
+ .collect();
258
+ return sessions.filter((s: any) => s.config?.cardId?.toString() === args.cardId);
259
+ },
260
+ });
261
+
262
+ export const updateSessionStatus = mutation({
263
+ args: {
264
+ sessionId: v.id("agentSessions"),
265
+ status: statusType,
266
+ sandboxId: v.optional(v.string()),
267
+ output: v.optional(v.any()),
268
+ error: v.optional(v.string()),
269
+ },
270
+ handler: async (ctx, args) => {
271
+ const patch: Record<string, unknown> = { status: args.status, updatedAt: Date.now() };
272
+ if (args.sandboxId) patch.sandboxId = args.sandboxId;
273
+ if (args.output) patch.output = args.output;
274
+ if (args.error) patch.error = args.error;
275
+ if (args.status === "completed" || args.status === "failed") patch.completedAt = Date.now();
276
+ await ctx.db.patch(args.sessionId, patch);
277
+ },
278
+ });
279
+
280
+ export const appendSessionLogs = mutation({
281
+ args: { sessionId: v.id("agentSessions"), logs: v.array(v.string()) },
282
+ handler: async (ctx, args) => {
283
+ const now = Date.now();
284
+ for (const log of args.logs) {
285
+ await ctx.db.insert("agentSessionLogs", { sessionId: args.sessionId, message: log, createdAt: now });
286
+ }
287
+ },
288
+ });
289
+
290
+ export const storeSessionMetrics = internalMutation({
291
+ args: {
292
+ sessionId: v.id("agentSessions"),
293
+ metrics: v.any(),
294
+ },
295
+ handler: async (ctx, args) => {
296
+ const session = await ctx.db.get(args.sessionId);
297
+ if (!session) return;
298
+
299
+ // Store metrics in the session output
300
+ const currentOutput = (session.output as any) || {};
301
+ await ctx.db.patch(args.sessionId, {
302
+ output: {
303
+ ...currentOutput,
304
+ metrics: {
305
+ ...((currentOutput.metrics as any) || {}),
306
+ ...args.metrics,
307
+ },
308
+ },
309
+ updatedAt: Date.now(),
310
+ });
311
+
312
+ // Also log as a timing entry for visibility
313
+ await ctx.db.insert("agentSessionLogs", {
314
+ sessionId: args.sessionId,
315
+ message: `ā±ļø SETUP: ${(args.metrics.setupMs / 1000).toFixed(1)}s (sandbox: ${(args.metrics.sandboxCreateMs / 1000).toFixed(1)}s, server: ${(args.metrics.serverStartupMs / 1000).toFixed(1)}s)`,
316
+ createdAt: Date.now(),
317
+ });
318
+ },
319
+ });
320
+
321
+ export const cancelSessionMutation = mutation({
322
+ args: { sessionId: v.id("agentSessions") },
323
+ handler: async (ctx, args) => {
324
+ await ctx.db.patch(args.sessionId, { status: "cancelled", updatedAt: Date.now() });
325
+ },
326
+ });
327
+
328
+ export const getSessionStatus = internalQuery({
329
+ args: { sessionId: v.id("agentSessions") },
330
+ handler: async (ctx, args) => {
331
+ const session = await ctx.db.get(args.sessionId);
332
+ return session ? { status: session.status, completedAt: session.completedAt } : null;
333
+ },
334
+ });
335
+
336
+ // For gateway callback compatibility
337
+ export const completeSandbox = mutation({
338
+ args: {
339
+ sessionId: v.id("agentSessions"),
340
+ output: v.string(),
341
+ artifacts: v.optional(v.array(v.any())),
342
+ toolCalls: v.optional(v.array(v.any())),
343
+ todos: v.optional(v.array(v.any())),
344
+ diffs: v.optional(v.array(v.any())),
345
+ error: v.optional(v.string()),
346
+ },
347
+ handler: async (ctx, args) => {
348
+ await ctx.db.patch(args.sessionId, {
349
+ status: args.error ? "failed" : "completed",
350
+ output: { text: args.output, artifacts: args.artifacts, toolCalls: args.toolCalls, todos: args.todos },
351
+ updatedAt: Date.now(),
352
+ });
353
+ },
354
+ });
355
+
356
+ /**
357
+ * Called by the event forwarder when session completes.
358
+ * This bypasses the polling entirely for faster completion.
359
+ */
360
+ export const completeFromForwarder = mutation({
361
+ args: {
362
+ sessionId: v.string(),
363
+ sandboxId: v.string(),
364
+ output: v.string(),
365
+ toolCalls: v.array(v.object({ name: v.string(), status: v.optional(v.string()) })),
366
+ todos: v.array(v.any()),
367
+ messagesCount: v.number(),
368
+ },
369
+ handler: async (ctx, args) => {
370
+ // Find the session by string ID (forwarder sends string, not Id type)
371
+ const sessions = await ctx.db
372
+ .query("agentSessions")
373
+ .filter((q) => q.eq(q.field("status"), "running"))
374
+ .collect();
375
+
376
+ const session = sessions.find(
377
+ (s) => s._id.toString() === args.sessionId || s.sandboxId === args.sandboxId
378
+ );
379
+
380
+ if (!session) {
381
+ console.log(`[completeFromForwarder] Session not found: ${args.sessionId}`);
382
+ return { success: false, error: "Session not found" };
383
+ }
384
+
385
+ // Check if already completed
386
+ if (session.status === "completed" || session.status === "failed") {
387
+ console.log(`[completeFromForwarder] Session already ${session.status}`);
388
+ return { success: true, alreadyComplete: true };
389
+ }
390
+
391
+ // Update session with results
392
+ await ctx.db.patch(session._id, {
393
+ status: "completed",
394
+ completedAt: Date.now(),
395
+ updatedAt: Date.now(),
396
+ output: {
397
+ text: args.output,
398
+ toolCalls: args.toolCalls,
399
+ todos: args.todos,
400
+ },
401
+ });
402
+
403
+ // Log completion
404
+ await ctx.db.insert("agentSessionLogs", {
405
+ sessionId: session._id,
406
+ message: `āœ… Completed via event forwarder (${args.messagesCount} messages, ${args.toolCalls.length} tools)`,
407
+ createdAt: Date.now(),
408
+ });
409
+
410
+ console.log(`[completeFromForwarder] āœ… Session ${session._id} completed`);
411
+
412
+ // Kill the sandbox (schedule async to not block)
413
+ // The polling action will notice the session is complete and skip processing
414
+ return { success: true, sessionId: session._id.toString() };
415
+ },
416
+ });
417
+
418
+ export const markSessionRunning = internalMutation({
419
+ args: {
420
+ sessionId: v.id("agentSessions"),
421
+ sandboxId: v.string(),
422
+ sandboxHost: v.optional(v.string()),
423
+ openCodeSessionId: v.optional(v.string()),
424
+ },
425
+ handler: async (ctx, args) => {
426
+ const patch: Record<string, unknown> = {
427
+ status: "running",
428
+ sandboxId: args.sandboxId,
429
+ updatedAt: Date.now(),
430
+ };
431
+ if (args.sandboxHost) patch.sandboxHost = args.sandboxHost;
432
+ if (args.openCodeSessionId) patch.openCodeSessionId = args.openCodeSessionId;
433
+ await ctx.db.patch(args.sessionId, patch);
434
+ },
435
+ });
436
+
437
+ // ============================================
438
+ // Sandbox Actions
439
+ // ============================================
440
+
441
+ /** Start a new agent session and run it */
442
+ export const startSession = action({
443
+ args: { projectId: v.string(), prompt: v.string(), config: v.optional(v.any()) },
444
+ handler: async (ctx, args) => {
445
+ if (args.config?.cardId) {
446
+ const existing = await ctx.runQuery(api.workflows.lifecycleSandbox.getActiveSessionForCard, {
447
+ cardId: args.config.cardId.toString(),
448
+ });
449
+ if (existing) {
450
+ await ctx.runMutation(api.workflows.lifecycleSandbox.cancelSessionMutation, { sessionId: existing._id });
451
+ }
452
+ }
453
+
454
+ const sessionId = await ctx.runMutation(api.workflows.lifecycleSandbox.createSession, {
455
+ projectId: args.projectId,
456
+ config: { prompt: args.prompt, ...args.config },
457
+ });
458
+
459
+ const result = await ctx.runAction(internal.workflows.lifecycleSandbox.runSandbox, {
460
+ sessionId,
461
+ prompt: args.prompt,
462
+ tools: args.config?.tools,
463
+ cardId: args.config?.cardId?.toString(),
464
+ // Pass model config from unified settings (if provided by parent app)
465
+ modelConfig: args.config?.model ? {
466
+ model: args.config.model,
467
+ fallbackModels: args.config.fallbackModels,
468
+ maxTokens: args.config.maxTokens,
469
+ } : undefined,
470
+ });
471
+
472
+ return { sessionId, ...result };
473
+ },
474
+ });
475
+
476
+ /** Spawn sandbox for an existing session (used by workflow callers) */
477
+ export const spawnSandbox = action({
478
+ args: {
479
+ sessionId: v.id("agentSessions"),
480
+ projectId: v.string(),
481
+ prompt: v.string(),
482
+ systemPrompt: v.optional(v.string()),
483
+ cardId: v.optional(v.string()),
484
+ runId: v.optional(v.string()),
485
+ boardId: v.optional(v.string()),
486
+ deliverables: v.optional(v.array(v.any())),
487
+ tools: v.optional(v.array(v.string())),
488
+ useOpenCode: v.optional(v.boolean()),
489
+ // Model config from unified settings
490
+ model: v.optional(v.string()),
491
+ fallbackModels: v.optional(v.array(v.string())),
492
+ maxTokens: v.optional(v.number()),
493
+ },
494
+ handler: async (ctx, args) => {
495
+ let fullPrompt = args.prompt;
496
+ if (args.systemPrompt) {
497
+ fullPrompt = `${args.systemPrompt}\n\n${args.prompt}`;
498
+ }
499
+
500
+ const result = await ctx.runAction(internal.workflows.lifecycleSandbox.runSandbox, {
501
+ sessionId: args.sessionId,
502
+ prompt: fullPrompt,
503
+ tools: args.tools,
504
+ cardId: args.cardId,
505
+ // Pass model config from unified settings
506
+ modelConfig: args.model ? {
507
+ model: args.model,
508
+ fallbackModels: args.fallbackModels,
509
+ maxTokens: args.maxTokens,
510
+ } : undefined,
511
+ });
512
+
513
+ // Check if we're in polling mode (non-blocking)
514
+ // If so, DON'T return output so runAgentStep uses the async DB polling path
515
+ if ((result as any).status === "polling") {
516
+ console.log(`[spawnSandbox] Sandbox started in polling mode for session ${args.sessionId}`);
517
+ return {
518
+ sessionId: args.sessionId,
519
+ polling: true, // Indicator for caller
520
+ error: result.error,
521
+ };
522
+ }
523
+
524
+ // Legacy/immediate mode: return output for immediate completion
525
+ return {
526
+ sessionId: args.sessionId,
527
+ output: result.output || "",
528
+ error: result.error,
529
+ toolCalls: result.toolCalls || [],
530
+ todos: result.todos || [],
531
+ artifacts: [],
532
+ };
533
+ },
534
+ });
535
+
536
+ /**
537
+ * Run sandbox - NON-BLOCKING version
538
+ *
539
+ * This action starts the sandbox, sends the async prompt, and schedules
540
+ * a polling action to check for completion. It returns immediately with
541
+ * "running" status, avoiding the E2B gRPC stream timeout issue.
542
+ *
543
+ * The polling action uses direct HTTP to OpenCode (no E2B SDK) so each
544
+ * poll is independent and doesn't suffer from stream timeouts.
545
+ */
546
+ export const runSandbox = internalAction({
547
+ args: {
548
+ sessionId: v.id("agentSessions"),
549
+ prompt: v.string(),
550
+ tools: v.optional(v.array(v.string())),
551
+ cardId: v.optional(v.string()),
552
+ // Model config from unified settings (passed by parent app)
553
+ modelConfig: v.optional(v.object({
554
+ model: v.string(),
555
+ fallbackModels: v.optional(v.array(v.string())),
556
+ maxTokens: v.optional(v.number()),
557
+ })),
558
+ },
559
+ handler: async (ctx, args) => {
560
+ // Get agent config from args (unified settings) or use defaults
561
+ const agentConfig = getAgentConfig(args.modelConfig);
562
+ // Helper to restore previous stage state (VFS + Beads)
563
+ async function restorePreviousState(
564
+ sandbox: any,
565
+ cardId: string,
566
+ recordStepFn: typeof recordStep,
567
+ metricsFn: TimingMetrics
568
+ ) {
569
+ const stepStart = Date.now();
570
+ let filesRestored = 0;
571
+ let beadsRestored = false;
572
+
573
+ try {
574
+ // 1. Get previous artifacts from Convex
575
+ const artifacts = await ctx.runQuery(api.features.kanban.file_sync.getCardFiles, {
576
+ cardId: cardId as any, // TypeScript will validate at runtime
577
+ });
578
+
579
+ // 2. Write artifacts to sandbox VFS
580
+ if (artifacts && artifacts.length > 0) {
581
+ console.log(`[Sandbox] šŸ“‚ Restoring ${artifacts.length} files from previous stages...`);
582
+
583
+ for (const artifact of artifacts) {
584
+ const targetPath = artifact.path?.startsWith('/home/user/workspace')
585
+ ? artifact.path
586
+ : `/home/user/workspace/${artifact.path || artifact.name}`;
587
+
588
+ // Ensure parent directory exists
589
+ const parentDir = targetPath.substring(0, targetPath.lastIndexOf('/'));
590
+ await sandbox.commands.run(`mkdir -p "${parentDir}"`);
591
+
592
+ // Write file content
593
+ // Handle binary files (PDFs) vs text
594
+ if (artifact.type === 'pdf' && artifact.content.startsWith('JVBERi')) {
595
+ // PDF is base64 - decode and write
596
+ await sandbox.commands.run(`echo "${artifact.content}" | base64 -d > "${targetPath}"`);
597
+ } else {
598
+ // Text file - escape for shell and write
599
+ // Use heredoc for safer handling of special chars
600
+ const escapedContent = artifact.content
601
+ .replace(/\\/g, '\\\\')
602
+ .replace(/'/g, "'\\''");
603
+ await sandbox.commands.run(`cat > "${targetPath}" << 'ARTIFACT_EOF'
604
+ ${artifact.content}
605
+ ARTIFACT_EOF`);
606
+ }
607
+
608
+ filesRestored++;
609
+ }
610
+ console.log(`[Sandbox] āœ… Restored ${filesRestored} files to VFS`);
611
+ }
612
+
613
+ // 3. Get and restore Beads state
614
+ const beadsState = await ctx.runQuery(api.workflows.crudLorobeads.getLatestSnapshot, {
615
+ cardId: cardId as any,
616
+ });
617
+
618
+ if (beadsState && beadsState.beadsState) {
619
+ console.log(`[Sandbox] 🧠 Restoring Beads state from previous stage...`);
620
+ const beadsJson = typeof beadsState.beadsState === 'string'
621
+ ? beadsState.beadsState
622
+ : JSON.stringify(beadsState.beadsState);
623
+
624
+ // Write beads.json with previous state
625
+ await sandbox.commands.run(`cat > /home/user/beads.json << 'BEADS_EOF'
626
+ ${beadsJson}
627
+ BEADS_EOF`);
628
+ beadsRestored = true;
629
+ console.log(`[Sandbox] āœ… Beads state restored`);
630
+ }
631
+ } catch (e) {
632
+ console.warn(`[Sandbox] āš ļø State restoration error (non-fatal): ${e}`);
633
+ }
634
+
635
+ recordStepFn(metricsFn, "state_restore", stepStart);
636
+ return { filesRestored, beadsRestored };
637
+ }
638
+ const metrics = createMetrics();
639
+ let stepStart = Date.now();
640
+
641
+ const { Sandbox } = await import("@e2b/code-interpreter");
642
+ const jose = await import("jose");
643
+ recordStep(metrics, "import_modules", stepStart);
644
+
645
+ let sandbox: InstanceType<typeof Sandbox> | null = null;
646
+
647
+ try {
648
+ stepStart = Date.now();
649
+ // Generate JWT for sandbox → Convex callbacks
650
+ const secret = process.env.SANDBOX_JWT_SECRET;
651
+ if (!secret) throw new Error("SANDBOX_JWT_SECRET not configured");
652
+
653
+ const jwt = await new jose.SignJWT({ sessionId: args.sessionId, cardId: args.cardId })
654
+ .setProtectedHeader({ alg: "HS256" })
655
+ .setExpirationTime("1h")
656
+ .sign(new TextEncoder().encode(secret));
657
+ recordStep(metrics, "jwt_generation", stepStart);
658
+
659
+ // Convex site URL for callbacks
660
+ const convexUrl =
661
+ process.env.CONVEX_CLOUD_URL?.replace(".convex.cloud", ".convex.site") ||
662
+ process.env.CONVEX_URL?.replace(".convex.cloud", ".convex.site") ||
663
+ "";
664
+
665
+ // 1. Create sandbox with 10 min timeout
666
+ stepStart = Date.now();
667
+ sandbox = await Sandbox.create("project-social-sandbox", {
668
+ timeoutMs: 600000, // 10 minutes
669
+ envs: {
670
+ HOME: "/home/user",
671
+ PATH: "/home/user/.bun/bin:/usr/local/bin:/usr/bin:/bin",
672
+ OPENCODE_CONFIG: "/home/user/.opencode/opencode.json",
673
+ OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || "",
674
+ CONVEX_URL: convexUrl,
675
+ SANDBOX_JWT: jwt,
676
+ CARD_ID: args.cardId || "",
677
+ SESSION_ID: args.sessionId,
678
+ },
679
+ });
680
+ recordStep(metrics, "sandbox_create", stepStart);
681
+ metrics.totals.sandboxCreate = Date.now() - stepStart;
682
+
683
+ const sandboxId = sandbox.sandboxId;
684
+ const port = 4096;
685
+ const sandboxHost = sandbox.getHost(port);
686
+ const baseUrl = `https://${sandboxHost}`;
687
+
688
+ // 1.5. Restore previous stage state (VFS files + Beads)
689
+ // This runs BEFORE OpenCode starts so files are in place
690
+ if (args.cardId) {
691
+ const restoreResult = await restorePreviousState(sandbox, args.cardId, recordStep, metrics);
692
+ if (restoreResult.filesRestored > 0 || restoreResult.beadsRestored) {
693
+ console.log(`[Sandbox] šŸ”„ State restored: ${restoreResult.filesRestored} files, beads=${restoreResult.beadsRestored}`);
694
+ }
695
+ }
696
+
697
+ // 2. Start OpenCode server (don't await - start polling immediately)
698
+ stepStart = Date.now();
699
+ sandbox.commands.run(
700
+ `cd /home/user && /home/user/.bun/bin/opencode serve --port ${port} --hostname 0.0.0.0`,
701
+ { background: true }
702
+ ); // Fire and forget - server starts in background
703
+
704
+ // 3. Wait for server to be ready - ULTRA-AGGRESSIVE POLLING
705
+ // Poll immediately (no initial delay), short timeout, tight loop
706
+ let serverReadyLoops = 0;
707
+ const maxAttempts = 150; // 15 seconds max
708
+ let serverReady = false;
709
+ for (let i = 0; i < maxAttempts; i++) {
710
+ serverReadyLoops++;
711
+ try {
712
+ // Very short timeout - we just want to know if it's up
713
+ const res = await fetch(`${baseUrl}/global/health`, { signal: AbortSignal.timeout(200) });
714
+ if (res.ok) {
715
+ serverReady = true;
716
+ break;
717
+ }
718
+ } catch {
719
+ /* not ready */
720
+ }
721
+ // Ultra-aggressive: 50ms for first 40 attempts (2s), then 100ms
722
+ if (i < 40) {
723
+ await new Promise((r) => setTimeout(r, 50));
724
+ } else {
725
+ await new Promise((r) => setTimeout(r, 100));
726
+ }
727
+ }
728
+ if (!serverReady) throw new Error("OpenCode server failed to start");
729
+ recordStep(metrics, "server_startup", stepStart);
730
+ metrics.totals.serverStartup = Date.now() - stepStart;
731
+ console.log(`ā±ļø [server_startup] Ready after ${serverReadyLoops} polls (${((Date.now() - stepStart) / 1000).toFixed(2)}s)`);
732
+
733
+ // 4. Configure auth + create session IN PARALLEL
734
+ stepStart = Date.now();
735
+ const [authRes, sessionRes] = await Promise.all([
736
+ fetch(`${baseUrl}/auth/openrouter`, {
737
+ method: "PUT",
738
+ headers: { "Content-Type": "application/json" },
739
+ body: JSON.stringify({ type: "api", key: process.env.OPENROUTER_API_KEY }),
740
+ }),
741
+ fetch(`${baseUrl}/session`, {
742
+ method: "POST",
743
+ headers: { "Content-Type": "application/json" },
744
+ body: JSON.stringify({ title: "Agent Task" }),
745
+ }),
746
+ ]);
747
+
748
+ if (!authRes.ok) {
749
+ const authErr = await authRes.text();
750
+ throw new Error(`Auth config failed: ${authRes.status} - ${authErr}`);
751
+ }
752
+ const authBody = await authRes.text();
753
+ console.log(`[Sandbox] Auth response: ${authBody.slice(0, 200)}`);
754
+ console.log(`[Sandbox] OpenRouter key present: ${!!process.env.OPENROUTER_API_KEY}, length: ${process.env.OPENROUTER_API_KEY?.length || 0}`);
755
+ recordStep(metrics, "auth_config", stepStart);
756
+ metrics.totals.authConfig = Date.now() - stepStart;
757
+
758
+ if (!sessionRes.ok) throw new Error(`Session creation failed: ${sessionRes.status}`);
759
+ const ocSession = await sessionRes.json();
760
+ const openCodeSessionId = ocSession.id;
761
+ recordStep(metrics, "opencode_session", stepStart);
762
+ metrics.totals.sessionCreate = Date.now() - stepStart;
763
+
764
+ // 6. Build prompt (pure JS, instant)
765
+ let fullPrompt = args.prompt;
766
+ if (args.tools?.length) {
767
+ fullPrompt += `\n\n## ALLOWED TOOLS\nYou may ONLY use: ${args.tools.join(", ")}`;
768
+ }
769
+
770
+ // Log prompt size for debugging latency issues
771
+ const promptChars = fullPrompt.length;
772
+ const estimatedTokens = Math.round(promptChars / 4); // ~4 chars per token estimate
773
+ console.log(`šŸ“ [Prompt] ${promptChars} chars, ~${estimatedTokens} tokens estimated`);
774
+
775
+ // 7. Update convex with session status
776
+ stepStart = Date.now();
777
+ const forwarderPath = "/home/user/scripts/event-forwarder.ts";
778
+ console.log(`[Sandbox] JWT length: ${jwt.length}, first 20 chars: ${jwt.slice(0, 20)}...`);
779
+ console.log(`[Sandbox] CONVEX_URL in sandbox: ${convexUrl}`);
780
+
781
+ await ctx.runMutation(internal.workflows.lifecycleSandbox.markSessionRunning, {
782
+ sessionId: args.sessionId,
783
+ sandboxId,
784
+ sandboxHost,
785
+ openCodeSessionId,
786
+ });
787
+ recordStep(metrics, "convex_update", stepStart);
788
+
789
+ // 8. Send async prompt FIRST (before forwarder)
790
+ // This ensures OpenCode is processing when the forwarder connects
791
+ stepStart = Date.now();
792
+ const asyncRes = await fetch(`${baseUrl}/session/${openCodeSessionId}/prompt_async`, {
793
+ method: "POST",
794
+ headers: { "Content-Type": "application/json" },
795
+ body: JSON.stringify({
796
+ model: {
797
+ providerID: agentConfig.provider,
798
+ modelID: agentConfig.primaryModel,
799
+ },
800
+ // Fallback models from config
801
+ route: "fallback",
802
+ models: agentConfig.models,
803
+ // Provider preferences (@preset/fastbutgood)
804
+ provider: agentConfig.providerPreferences,
805
+ // Parameters (reasoning enabled)
806
+ ...agentConfig.parameters,
807
+ parts: [{ type: "text", text: fullPrompt }],
808
+ max_tokens: agentConfig.maxTokens,
809
+ maxTokens: agentConfig.maxTokens,
810
+ }),
811
+ });
812
+
813
+ if (!asyncRes.ok) {
814
+ const errBody = await asyncRes.text();
815
+ throw new Error(`Async message failed: ${asyncRes.status} ${errBody.slice(0, 200)}`);
816
+ }
817
+ const promptBody = await asyncRes.text();
818
+ console.log(`[Sandbox] Prompt response: ${promptBody.slice(0, 300)}`);
819
+ console.log(`[Sandbox] Model: ${agentConfig.primaryModel}, Provider: ${agentConfig.provider}`);
820
+ recordStep(metrics, "prompt_send", stepStart);
821
+ metrics.totals.promptSend = Date.now() - stepStart;
822
+
823
+ // 9. NOW start event forwarder (after prompt is sent)
824
+ // This prevents race condition where forwarder sees "idle" before prompt starts
825
+ stepStart = Date.now();
826
+ sandbox.commands.run(
827
+ `SESSION_ID="${args.sessionId}" E2B_SANDBOX_ID="${sandboxId}" SANDBOX_JWT="${jwt}" /home/user/.bun/bin/bun run ${forwarderPath} "${openCodeSessionId}" > /tmp/forwarder.log 2>&1 &`,
828
+ { background: true }
829
+ );
830
+ recordStep(metrics, "event_forwarder", stepStart);
831
+ console.log(`[Sandbox] Started event forwarder for session ${openCodeSessionId}`);
832
+
833
+ // Calculate total setup time
834
+ metrics.totals.totalSetup = Date.now() - metrics.startTime;
835
+
836
+ // Log timing report
837
+ console.log(formatMetrics(metrics));
838
+ console.log(`[Sandbox] āœ… Async prompt sent, scheduling polling action`);
839
+
840
+ // Store metrics in session for later analysis
841
+ await ctx.runMutation(internal.workflows.lifecycleSandbox.storeSessionMetrics, {
842
+ sessionId: args.sessionId,
843
+ metrics: {
844
+ setupMs: metrics.totals.totalSetup,
845
+ sandboxCreateMs: metrics.totals.sandboxCreate,
846
+ serverStartupMs: metrics.totals.serverStartup,
847
+ authConfigMs: metrics.totals.authConfig,
848
+ sessionCreateMs: metrics.totals.sessionCreate,
849
+ promptSendMs: metrics.totals.promptSend,
850
+ steps: metrics.steps,
851
+ },
852
+ });
853
+
854
+ // 10. Schedule timeout watchdog (10 minutes)
855
+ // The event forwarder handles real-time streaming and completion.
856
+ // This is just a safety net in case the forwarder dies.
857
+ await ctx.scheduler.runAfter(600000, internal.workflows.lifecycleSandbox.timeoutWatchdog, {
858
+ sessionId: args.sessionId,
859
+ sandboxId,
860
+ startTime: Date.now(),
861
+ });
862
+
863
+ // Return immediately - polling action will complete the session
864
+ return { success: true, output: "", toolCalls: [], todos: [], status: "polling" };
865
+ } catch (error) {
866
+ const message = error instanceof Error ? error.message : String(error);
867
+ console.error(`[Sandbox] āŒ Error during setup: ${message}`);
868
+ await ctx.runMutation(api.workflows.lifecycleSandbox.updateSessionStatus, {
869
+ sessionId: args.sessionId,
870
+ status: "failed",
871
+ error: message,
872
+ });
873
+
874
+ // Kill sandbox on error
875
+ if (sandbox) {
876
+ try {
877
+ await sandbox.kill();
878
+ } catch {
879
+ /* ignore */
880
+ }
881
+ }
882
+
883
+ return { success: false, error: message, output: "" };
884
+ }
885
+ // NOTE: We do NOT kill the sandbox here - the polling action will do that when done
886
+ },
887
+ });
888
+
889
+ /**
890
+ * Timeout Watchdog
891
+ *
892
+ * Simple safety net that runs after 10 minutes. If the session is still running
893
+ * (event forwarder failed), it marks it as timed out and kills the sandbox.
894
+ */
895
+ export const timeoutWatchdog = internalAction({
896
+ args: {
897
+ sessionId: v.id("agentSessions"),
898
+ sandboxId: v.string(),
899
+ startTime: v.number(),
900
+ },
901
+ handler: async (ctx, args) => {
902
+ // Check if session is already completed
903
+ const session = await ctx.runQuery(internal.workflows.lifecycleSandbox.getSessionStatus, {
904
+ sessionId: args.sessionId,
905
+ });
906
+
907
+ if (session?.status === "completed" || session?.status === "failed" || session?.status === "cancelled") {
908
+ console.log(`[Watchdog] Session ${args.sessionId} already ${session.status}, cleaning up sandbox`);
909
+ } else {
910
+ // Session still running after 10 minutes - mark as timed out
911
+ const elapsed = Math.round((Date.now() - args.startTime) / 1000);
912
+ console.log(`[Watchdog] Session ${args.sessionId} timed out after ${elapsed}s`);
913
+
914
+ await ctx.runMutation(api.workflows.lifecycleSandbox.updateSessionStatus, {
915
+ sessionId: args.sessionId,
916
+ status: "failed",
917
+ error: `Timeout: Session did not complete within 10 minutes`,
918
+ });
919
+ }
920
+
921
+ // Always try to kill the sandbox
922
+ try {
923
+ const { Sandbox } = await import("@e2b/code-interpreter");
924
+ const sandbox = await Sandbox.connect(args.sandboxId);
925
+ await sandbox.kill();
926
+ console.log(`[Watchdog] Killed sandbox ${args.sandboxId}`);
927
+ } catch (e) {
928
+ console.log(`[Watchdog] Sandbox cleanup: ${e}`);
929
+ }
930
+ },
931
+ });
932
+
933
+ /**
934
+ * Poll for sandbox completion (LEGACY - kept for backward compatibility)
935
+ *
936
+ * The event forwarder now handles real-time streaming and completion.
937
+ * This polling action is only used if explicitly scheduled.
938
+ */
939
+ export const pollSandboxCompletion = internalAction({
940
+ args: {
941
+ sessionId: v.id("agentSessions"),
942
+ sandboxId: v.string(),
943
+ sandboxHost: v.string(),
944
+ openCodeSessionId: v.string(),
945
+ pollCount: v.number(),
946
+ startTime: v.number(),
947
+ lastMessageCount: v.optional(v.number()), // Track seen messages for streaming
948
+ lastPartsCount: v.optional(v.number()), // Track total parts seen (messages update in-place)
949
+ },
950
+ handler: async (ctx, args) => {
951
+ const baseUrl = `https://${args.sandboxHost}`;
952
+ const maxPolls = 20; // 10 minutes at 30s intervals (safety net only)
953
+ const pollInterval = 30000; // 30 seconds - event forwarder handles real-time
954
+ const lastMessageCount = args.lastMessageCount || 0;
955
+ const lastPartsCount = args.lastPartsCount || 0;
956
+
957
+ try {
958
+ // ═══════════════════════════════════════════════════════════════════════
959
+ // CHECK IF ALREADY COMPLETED BY EVENT FORWARDER
960
+ // ═══════════════════════════════════════════════════════════════════════
961
+ const session = await ctx.runQuery(internal.workflows.lifecycleSandbox.getSessionStatus, {
962
+ sessionId: args.sessionId,
963
+ });
964
+
965
+ if (session?.status === "completed" || session?.status === "failed") {
966
+ console.log(`[Poll ${args.pollCount}] Session already ${session.status} (via forwarder), cleaning up`);
967
+
968
+ // Kill the sandbox
969
+ try {
970
+ const { Sandbox } = await import("@e2b/code-interpreter");
971
+ const sandbox = await Sandbox.connect(args.sandboxId);
972
+ await sandbox.kill();
973
+ console.log(`[Poll] Killed sandbox ${args.sandboxId}`);
974
+ } catch (e) {
975
+ console.log(`[Poll] Sandbox cleanup: ${e}`);
976
+ }
977
+ return;
978
+ }
979
+
980
+ // ═══════════════════════════════════════════════════════════════════════
981
+ // LIVE MESSAGE STREAMING - Fetch and forward new messages each poll
982
+ // (backup for when event forwarder misses events)
983
+ // ═══════════════════════════════════════════════════════════════════════
984
+ let newMessageCount = lastMessageCount;
985
+ let newPartsCount = lastPartsCount;
986
+ try {
987
+ const msgRes = await fetch(`${baseUrl}/session/${args.openCodeSessionId}/message`, {
988
+ headers: { Accept: "application/json" },
989
+ signal: AbortSignal.timeout(5000),
990
+ });
991
+ if (msgRes.ok) {
992
+ const msgText = await msgRes.text();
993
+ const messages = msgText ? JSON.parse(msgText) : [];
994
+ newMessageCount = messages.length;
995
+
996
+ // Count total parts across all messages
997
+ let totalParts = 0;
998
+ const allParts: Array<{ role: string; part: any; partIndex: number }> = [];
999
+ for (const msg of messages) {
1000
+ const role = msg.info?.role || msg.role || "unknown";
1001
+ for (const part of msg.parts || []) {
1002
+ allParts.push({ role, part, partIndex: totalParts });
1003
+ totalParts++;
1004
+ }
1005
+ }
1006
+ newPartsCount = totalParts;
1007
+
1008
+ // Debug: Log progress
1009
+ if (args.pollCount % 3 === 0 || totalParts > lastPartsCount) {
1010
+ console.log(`[Poll ${args.pollCount}] šŸ“¬ Messages: ${messages.length}, Parts: ${totalParts} (last seen: ${lastPartsCount})`);
1011
+ }
1012
+
1013
+ // Stream new parts to Convex (parts we haven't seen before)
1014
+ if (totalParts > lastPartsCount) {
1015
+ const newParts = allParts.slice(lastPartsCount);
1016
+ const logsToAdd: string[] = [];
1017
+
1018
+ // Helper to make tool names human-readable
1019
+ const formatToolName = (name: string): string => {
1020
+ if (!name || name === "unknown") return "Tool";
1021
+ // Handle namespaced tools like "automation.saveArtifact" -> "Save Artifact"
1022
+ if (name.includes(".")) {
1023
+ name = name.split(".").pop() || name;
1024
+ }
1025
+ // Convert camelCase/snake_case to readable
1026
+ name = name.replace(/_/g, " ");
1027
+ name = name.replace(/([A-Z])/g, " $1").trim();
1028
+ return name.charAt(0).toUpperCase() + name.slice(1);
1029
+ };
1030
+ console.log(`[Poll ${args.pollCount}] šŸ“ Processing ${newParts.length} new parts`);
1031
+
1032
+ for (const { role, part } of newParts) {
1033
+ // Only log meaningful events:
1034
+ // - Tool calls (starting and completed)
1035
+ // - Stage completion signals
1036
+ // - Artifact saves
1037
+ // - Bead operations
1038
+ // Skip: reasoning, user echoes, step markers, patches, verbose text
1039
+
1040
+ if (part.type === "tool" || part.type === "tool-invocation" || part.toolInvocation) {
1041
+ // Tool call starting
1042
+ const rawName = part.tool || part.toolInvocation?.toolName || part.toolName || "";
1043
+ if (!rawName) continue; // Skip if no tool name
1044
+
1045
+ const toolName = formatToolName(rawName);
1046
+ const state = part.state?.status || part.toolInvocation?.state?.status || "calling";
1047
+
1048
+ // Special handling for specific tools
1049
+ if (rawName.includes("saveArtifact")) {
1050
+ logsToAdd.push(`šŸ“„ Saving artifact...`);
1051
+ } else if (rawName.includes("completeStage")) {
1052
+ logsToAdd.push(`šŸ Completing stage...`);
1053
+ } else if (rawName.includes("beads.create") || rawName.includes("beads.close") || rawName.includes("beads.update")) {
1054
+ const action = rawName.includes("create") ? "Creating" : rawName.includes("close") ? "Completing" : "Updating";
1055
+ logsToAdd.push(`šŸ“‹ ${action} task...`);
1056
+ } else if (state === "calling" || state === "pending") {
1057
+ logsToAdd.push(`šŸ”§ ${toolName}...`);
1058
+ }
1059
+ } else if (part.type === "tool-result" || part.toolResult) {
1060
+ // Tool completed
1061
+ const rawName = part.toolName || part.toolResult?.toolName || "";
1062
+ const toolName = formatToolName(rawName);
1063
+
1064
+ // Check for errors in result
1065
+ const hasError = part.toolResult?.error || part.state?.error;
1066
+ if (hasError) {
1067
+ logsToAdd.push(`āŒ ${toolName} failed`);
1068
+ } else if (rawName.includes("saveArtifact")) {
1069
+ // Get artifact name from result if available
1070
+ const result = part.toolResult?.result || part.result;
1071
+ const name = result?.saved || result?.name || "artifact";
1072
+ logsToAdd.push(`āœ… Saved: ${name}`);
1073
+ } else if (rawName.includes("completeStage")) {
1074
+ logsToAdd.push(`āœ… Stage completed`);
1075
+ } else if (rawName.includes("beads.create")) {
1076
+ const result = part.toolResult?.result || part.result;
1077
+ const title = result?.title || "task";
1078
+ logsToAdd.push(`āœ… Created: ${title}`);
1079
+ } else if (rawName.includes("beads.close")) {
1080
+ logsToAdd.push(`āœ… Task completed`);
1081
+ } else {
1082
+ // Generic tool completion
1083
+ logsToAdd.push(`āœ… ${toolName}`);
1084
+ }
1085
+ }
1086
+ // Skip: text, reasoning, step-start, step-finish, patch, unknown
1087
+ }
1088
+
1089
+ if (logsToAdd.length > 0) {
1090
+ console.log(`[Poll ${args.pollCount}] šŸ“Ø Streaming ${logsToAdd.length} new log entries`);
1091
+ await ctx.runMutation(api.workflows.lifecycleSandbox.appendSessionLogs, {
1092
+ sessionId: args.sessionId,
1093
+ logs: logsToAdd,
1094
+ });
1095
+ }
1096
+ }
1097
+ }
1098
+ } catch (e) {
1099
+ // Don't fail the poll if message streaming fails
1100
+ console.log(`[Poll ${args.pollCount}] āš ļø Message fetch error: ${e}`);
1101
+ }
1102
+
1103
+ // ═══════════════════════════════════════════════════════════════════════
1104
+ // STATUS CHECK - Is the LLM still processing?
1105
+ // ═══════════════════════════════════════════════════════════════════════
1106
+ const statusRes = await fetch(`${baseUrl}/session/status`, {
1107
+ headers: { Accept: "application/json" },
1108
+ signal: AbortSignal.timeout(10000), // 10s timeout for the fetch itself
1109
+ });
1110
+
1111
+ if (!statusRes.ok) {
1112
+ // Check if sandbox is dead (E2B returns 502 with specific messages)
1113
+ const errorBody = await statusRes.text().catch(() => "");
1114
+ console.log(`[Poll ${args.pollCount}] Status fetch failed: ${statusRes.status}, body: ${errorBody.slice(0, 200)}`);
1115
+
1116
+ // If sandbox is dead or port not open, fail fast
1117
+ if (errorBody.includes("not found") || errorBody.includes("not open") || statusRes.status === 502) {
1118
+ throw new Error(`Sandbox is no longer reachable: ${errorBody.slice(0, 100)}`);
1119
+ }
1120
+
1121
+ // Schedule retry if not too many polls
1122
+ if (args.pollCount < maxPolls) {
1123
+ await ctx.scheduler.runAfter(pollInterval, internal.workflows.lifecycleSandbox.pollSandboxCompletion, {
1124
+ ...args,
1125
+ pollCount: args.pollCount + 1,
1126
+ lastMessageCount: newMessageCount,
1127
+ lastPartsCount: newPartsCount,
1128
+ });
1129
+ } else {
1130
+ throw new Error("Max polls reached, status endpoint not responding");
1131
+ }
1132
+ return;
1133
+ }
1134
+
1135
+ const allStatuses = await statusRes.json();
1136
+ const sessionStatus = allStatuses[args.openCodeSessionId];
1137
+
1138
+ // Log every poll
1139
+ const elapsed = Math.round((Date.now() - args.startTime) / 1000);
1140
+
1141
+ // IMPORTANT: When OpenCode finishes a session, it REMOVES it from the status list!
1142
+ // So if sessionStatus is undefined but we had a valid session, it means completion.
1143
+ if (sessionStatus === undefined) {
1144
+ // Session not in status list - check if it's because it completed
1145
+ // by verifying we have messages (meaning the session existed and ran)
1146
+ console.log(`[Poll ${args.pollCount}] ${elapsed}s elapsed - session not in status list, checking for completion...`);
1147
+
1148
+ // Quick check: try to fetch messages to see if session existed
1149
+ const msgCheckRes = await fetch(`${baseUrl}/session/${args.openCodeSessionId}/message`, {
1150
+ headers: { Accept: "application/json" },
1151
+ signal: AbortSignal.timeout(5000),
1152
+ });
1153
+
1154
+ if (msgCheckRes.ok) {
1155
+ const msgText = await msgCheckRes.text();
1156
+ const messages = msgText ? JSON.parse(msgText) : [];
1157
+
1158
+ if (messages.length > 0) {
1159
+ // We have messages, so the session ran and completed (removed from status)
1160
+ console.log(`[Poll ${args.pollCount}] āœ… Session completed (removed from status list, has ${messages.length} messages)`);
1161
+ // Fall through to result collection below
1162
+ } else if (args.pollCount < 5) {
1163
+ // No messages yet and early in polling - maybe still initializing
1164
+ console.log(`[Poll ${args.pollCount}] No messages yet, scheduling retry...`);
1165
+ await ctx.scheduler.runAfter(pollInterval, internal.workflows.lifecycleSandbox.pollSandboxCompletion, {
1166
+ ...args,
1167
+ pollCount: args.pollCount + 1,
1168
+ lastMessageCount: newMessageCount,
1169
+ lastPartsCount: newPartsCount,
1170
+ });
1171
+ return;
1172
+ } else {
1173
+ // No messages after several polls - something is wrong
1174
+ throw new Error("Session has no messages after multiple polls");
1175
+ }
1176
+ } else {
1177
+ throw new Error(`Failed to check session messages: ${msgCheckRes.status}`);
1178
+ }
1179
+ } else {
1180
+ // OpenCode returns {type: "busy"} or {type: "idle"}, not {status: ...}
1181
+ const status = sessionStatus?.type || sessionStatus?.status || "unknown";
1182
+ console.log(`[Poll ${args.pollCount}] ${elapsed}s elapsed, type=${status}, raw=${JSON.stringify(sessionStatus).slice(0, 100)}`);
1183
+
1184
+ // Check if still processing
1185
+ if (status === "busy" || status === "processing") {
1186
+ if (args.pollCount < maxPolls) {
1187
+ // Schedule next poll
1188
+ await ctx.scheduler.runAfter(pollInterval, internal.workflows.lifecycleSandbox.pollSandboxCompletion, {
1189
+ ...args,
1190
+ pollCount: args.pollCount + 1,
1191
+ lastMessageCount: newMessageCount,
1192
+ lastPartsCount: newPartsCount,
1193
+ });
1194
+ } else {
1195
+ throw new Error(`Timeout: OpenCode still processing after ${elapsed}s`);
1196
+ }
1197
+ return;
1198
+ }
1199
+
1200
+ // Check for error
1201
+ if (status === "error" || status === "failed") {
1202
+ throw new Error(`OpenCode error: ${JSON.stringify(sessionStatus)}`);
1203
+ }
1204
+
1205
+ // type === "idle" || type === "ready" - session is done
1206
+ console.log(`[Poll ${args.pollCount}] āœ… OpenCode completed in ${elapsed}s (status: ${status})`);
1207
+ }
1208
+
1209
+ // Collect results via direct HTTP
1210
+ const [messagesRes, todosRes, diffsRes] = await Promise.all([
1211
+ fetch(`${baseUrl}/session/${args.openCodeSessionId}/message`, { headers: { Accept: "application/json" } }),
1212
+ fetch(`${baseUrl}/session/${args.openCodeSessionId}/todo`, { headers: { Accept: "application/json" } }),
1213
+ fetch(`${baseUrl}/session/${args.openCodeSessionId}/diff`, { headers: { Accept: "application/json" } }),
1214
+ ]);
1215
+
1216
+ // Safe JSON parse helper
1217
+ const safeJsonParse = (text: string, name: string): any[] => {
1218
+ if (!text || text.trim() === "") return [];
1219
+ try {
1220
+ return JSON.parse(text);
1221
+ } catch (e: any) {
1222
+ console.error(`[Sandbox] Failed to parse ${name}: ${e.message}`);
1223
+ return [];
1224
+ }
1225
+ };
1226
+
1227
+ const messages = safeJsonParse(await messagesRes.text(), "messages");
1228
+ const todos = safeJsonParse(await todosRes.text(), "todos");
1229
+ const diffs = safeJsonParse(await diffsRes.text(), "diffs");
1230
+
1231
+ console.log(`[Sandbox] Collected: ${messages.length} messages, ${todos.length} todos, ${diffs.length} diffs`);
1232
+
1233
+ // Extract assistant responses with structured output:
1234
+ // - thinking: All reasoning and intermediate activity (for progress UI)
1235
+ // - response: Final answer for the user (last text from last assistant message)
1236
+ // - toolCalls: Tool invocations for audit trail
1237
+ const thinkingParts: string[] = [];
1238
+ const toolCalls: Array<{ name: string; status: string; args?: any; result?: string }> = [];
1239
+ let finalResponse = "";
1240
+
1241
+ // Process messages in order, but we need the LAST assistant text for final response
1242
+ const assistantMessages = messages.filter((m: any) => m.role !== "user");
1243
+
1244
+ for (let i = 0; i < assistantMessages.length; i++) {
1245
+ const msg = assistantMessages[i];
1246
+ const isLastMessage = i === assistantMessages.length - 1;
1247
+ const parts = msg.parts || [];
1248
+
1249
+ for (let j = 0; j < parts.length; j++) {
1250
+ const part = parts[j];
1251
+ const isLastPart = j === parts.length - 1;
1252
+
1253
+ if (part.type === "text" && part.text) {
1254
+ // Skip if it looks like the system prompt being echoed
1255
+ if (part.text.startsWith("# ") && part.text.includes("## Context") && part.text.includes("## Rules")) {
1256
+ console.log(`[Sandbox] Skipping system prompt echo (${part.text.length} chars)`);
1257
+ continue;
1258
+ }
1259
+
1260
+ if (isLastMessage && isLastPart) {
1261
+ // Last text in last message = final response
1262
+ finalResponse = part.text;
1263
+ } else {
1264
+ // Earlier text = part of thinking/progress
1265
+ thinkingParts.push(part.text);
1266
+ }
1267
+ } else if (part.type === "reasoning" && part.text) {
1268
+ // Chain of thought reasoning
1269
+ thinkingParts.push(`šŸ’­ ${part.text}`);
1270
+ } else if (part.type === "tool-invocation" || part.type === "tool") {
1271
+ const toolName = part.toolInvocation?.toolName || part.toolName || part.callID?.match(/tool_([^_]+_[^_]+)/)?.[1]?.replace("_", ".") || "unknown";
1272
+ const status = part.toolInvocation?.state?.status || part.state || "calling";
1273
+ const args = part.toolInvocation?.args || part.args;
1274
+ toolCalls.push({ name: toolName, status, args });
1275
+ thinkingParts.push(`šŸ”§ ${toolName}(${JSON.stringify(args || {}).slice(0, 100)})`);
1276
+ } else if (part.type === "tool-result") {
1277
+ const toolName = part.toolName || part.toolResult?.toolName || "unknown";
1278
+ const result = typeof part.result === "string" ? part.result : JSON.stringify(part.result || {});
1279
+ // Update the last matching tool call with result
1280
+ const lastCall = [...toolCalls].reverse().find(t => t.name === toolName);
1281
+ if (lastCall) lastCall.result = result.slice(0, 500);
1282
+ thinkingParts.push(`āœ… ${toolName} → ${result.slice(0, 100)}`);
1283
+ }
1284
+ }
1285
+ }
1286
+
1287
+ // Fallback: if no final response identified, use the last thinking part
1288
+ if (!finalResponse && thinkingParts.length > 0) {
1289
+ // Find last non-tool, non-reasoning text
1290
+ finalResponse = thinkingParts.pop() || "";
1291
+ }
1292
+
1293
+ console.log(`[Sandbox] Extracted: thinking=${thinkingParts.length} parts, response=${finalResponse.length} chars, tools=${toolCalls.length}`);
1294
+
1295
+ // Calculate final timing metrics
1296
+ const totalExecutionMs = Date.now() - args.startTime;
1297
+ const executionMetrics = {
1298
+ agentExecutionMs: totalExecutionMs,
1299
+ pollCount: args.pollCount,
1300
+ messagesCount: messages.length,
1301
+ toolCallsCount: toolCalls.length,
1302
+ partsCount: newPartsCount,
1303
+ };
1304
+
1305
+ // Log final timing report
1306
+ console.log(`\nšŸ“Š EXECUTION COMPLETE`);
1307
+ console.log(`─`.repeat(50));
1308
+ console.log(`Total execution: ${(totalExecutionMs / 1000).toFixed(1)}s`);
1309
+ console.log(`Poll count: ${args.pollCount}`);
1310
+ console.log(`Messages: ${messages.length}`);
1311
+ console.log(`Tool calls: ${toolCalls.length}`);
1312
+ console.log(`─`.repeat(50));
1313
+
1314
+ // Update session with results and metrics
1315
+ // Output structure:
1316
+ // - response: Final answer for the user (what they should see)
1317
+ // - thinking: Chain of thought and tool activity (for progress/debug UI)
1318
+ // - text: Legacy field (now just the response for backwards compat)
1319
+ // - toolCalls, todos, diffs: Structured data for processing
1320
+ await ctx.runMutation(api.workflows.lifecycleSandbox.updateSessionStatus, {
1321
+ sessionId: args.sessionId,
1322
+ status: "completed",
1323
+ output: {
1324
+ response: finalResponse,
1325
+ thinking: thinkingParts,
1326
+ text: finalResponse, // Legacy compat - now just the response
1327
+ toolCalls,
1328
+ todos,
1329
+ diffs: diffs.map((d: any) => d.path),
1330
+ },
1331
+ });
1332
+
1333
+ // Store execution metrics
1334
+ await ctx.runMutation(internal.workflows.lifecycleSandbox.storeSessionMetrics, {
1335
+ sessionId: args.sessionId,
1336
+ metrics: executionMetrics,
1337
+ });
1338
+
1339
+ // Add final timing log entry
1340
+ await ctx.runMutation(api.workflows.lifecycleSandbox.appendSessionLogs, {
1341
+ sessionId: args.sessionId,
1342
+ logs: [`ā±ļø EXECUTION: ${(totalExecutionMs / 1000).toFixed(1)}s (${args.pollCount} polls, ${toolCalls.length} tools)`],
1343
+ });
1344
+
1345
+ // Kill the sandbox
1346
+ try {
1347
+ const { Sandbox } = await import("@e2b/code-interpreter");
1348
+ const sandbox = await Sandbox.connect(args.sandboxId);
1349
+ await sandbox.kill();
1350
+ console.log(`[Sandbox] Killed sandbox ${args.sandboxId}`);
1351
+ } catch (e) {
1352
+ console.log(`[Sandbox] Failed to kill sandbox (may already be dead): ${e}`);
1353
+ }
1354
+ } catch (error) {
1355
+ const message = error instanceof Error ? error.message : String(error);
1356
+ console.error(`[Poll ${args.pollCount}] āŒ Error: ${message}`);
1357
+
1358
+ await ctx.runMutation(api.workflows.lifecycleSandbox.updateSessionStatus, {
1359
+ sessionId: args.sessionId,
1360
+ status: "failed",
1361
+ error: message,
1362
+ });
1363
+
1364
+ // Try to kill sandbox on error
1365
+ try {
1366
+ const { Sandbox } = await import("@e2b/code-interpreter");
1367
+ const sandbox = await Sandbox.connect(args.sandboxId);
1368
+ await sandbox.kill();
1369
+ } catch {
1370
+ /* ignore */
1371
+ }
1372
+ }
1373
+ },
1374
+ });
1375
+
1376
+ /** Cancel a running session */
1377
+ export const cancelSession = action({
1378
+ args: { sessionId: v.id("agentSessions") },
1379
+ handler: async (ctx, args) => {
1380
+ const session = await ctx.runQuery(api.workflows.lifecycleSandbox.getSession, { sessionId: args.sessionId });
1381
+ if (!session) throw new Error("Session not found");
1382
+
1383
+ await ctx.runMutation(api.workflows.lifecycleSandbox.updateSessionStatus, {
1384
+ sessionId: args.sessionId,
1385
+ status: "cancelled",
1386
+ });
1387
+
1388
+ if (session.sandboxId) {
1389
+ try {
1390
+ const { Sandbox } = await import("@e2b/code-interpreter");
1391
+ const sandbox = await Sandbox.connect(session.sandboxId);
1392
+ await sandbox.kill();
1393
+ } catch { /* already dead */ }
1394
+ }
1395
+ },
1396
+ });