@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.
- package/README.md +166 -0
- package/convex/_generated/api.d.ts +45 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +58 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/cloud/CLAUDE.md +238 -0
- package/convex/cloud/_generated/api.ts +84 -0
- package/convex/cloud/_generated/component.ts +861 -0
- package/convex/cloud/_generated/dataModel.ts +60 -0
- package/convex/cloud/_generated/server.ts +156 -0
- package/convex/cloud/convex.config.ts +16 -0
- package/convex/cloud/index.ts +29 -0
- package/convex/cloud/intentSchema/generate.ts +447 -0
- package/convex/cloud/intentSchema/index.ts +16 -0
- package/convex/cloud/intentSchema/types.ts +418 -0
- package/convex/cloud/ksaPolicy.ts +554 -0
- package/convex/cloud/mail.ts +92 -0
- package/convex/cloud/schema.ts +322 -0
- package/convex/cloud/utils/kanbanContext.ts +229 -0
- package/convex/cloud/workflows/agentBoard.ts +451 -0
- package/convex/cloud/workflows/agentPrompt.ts +272 -0
- package/convex/cloud/workflows/agentThread.ts +374 -0
- package/convex/cloud/workflows/compileSandbox.ts +146 -0
- package/convex/cloud/workflows/crudBoard.ts +217 -0
- package/convex/cloud/workflows/crudKSAs.ts +262 -0
- package/convex/cloud/workflows/crudLorobeads.ts +371 -0
- package/convex/cloud/workflows/crudSkills.ts +205 -0
- package/convex/cloud/workflows/crudThreads.ts +708 -0
- package/convex/cloud/workflows/lifecycleSandbox.ts +1396 -0
- package/convex/cloud/workflows/sandboxConvex.ts +1046 -0
- package/convex/sandbox/README.md +90 -0
- package/convex/sandbox/_generated/api.d.ts +2934 -0
- package/convex/sandbox/_generated/api.js +23 -0
- package/convex/sandbox/_generated/dataModel.d.ts +60 -0
- package/convex/sandbox/_generated/server.d.ts +143 -0
- package/convex/sandbox/_generated/server.js +93 -0
- package/convex/sandbox/actions/bash.ts +130 -0
- package/convex/sandbox/actions/browser.ts +282 -0
- package/convex/sandbox/actions/file.ts +336 -0
- package/convex/sandbox/actions/lsp.ts +325 -0
- package/convex/sandbox/actions/pdf.ts +119 -0
- package/convex/sandbox/agent/codeExecLoop.ts +535 -0
- package/convex/sandbox/agent/decisions.ts +284 -0
- package/convex/sandbox/agent/index.ts +515 -0
- package/convex/sandbox/agent/subagents.ts +651 -0
- package/convex/sandbox/brandResearch/index.ts +417 -0
- package/convex/sandbox/context/index.ts +7 -0
- package/convex/sandbox/context/session.ts +402 -0
- package/convex/sandbox/convex.config.ts +17 -0
- package/convex/sandbox/index.ts +51 -0
- package/convex/sandbox/nodeActions/codeExec.ts +130 -0
- package/convex/sandbox/planning/beads.ts +187 -0
- package/convex/sandbox/planning/index.ts +8 -0
- package/convex/sandbox/planning/sync.ts +194 -0
- package/convex/sandbox/prompts/codeExec.ts +852 -0
- package/convex/sandbox/prompts/modes.ts +231 -0
- package/convex/sandbox/prompts/system.ts +142 -0
- package/convex/sandbox/schema.ts +510 -0
- package/convex/sandbox/state/artifacts.ts +99 -0
- package/convex/sandbox/state/checkpoints.ts +341 -0
- package/convex/sandbox/state/files.ts +383 -0
- package/convex/sandbox/state/index.ts +10 -0
- package/convex/sandbox/state/verification.actions.ts +268 -0
- package/convex/sandbox/state/verification.ts +101 -0
- package/convex/sandbox/tsconfig.json +25 -0
- package/convex/sandbox/utils/codeExecHelpers.ts +52 -0
- package/dist/cli/commands/build.d.ts +19 -0
- package/dist/cli/commands/build.d.ts.map +1 -0
- package/dist/cli/commands/build.js +223 -0
- package/dist/cli/commands/init.d.ts +16 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +148 -0
- package/dist/cli/commands/publish.d.ts +12 -0
- package/dist/cli/commands/publish.d.ts.map +1 -0
- package/dist/cli/commands/publish.js +33 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +40 -0
- package/dist/sdk/builders.d.ts +104 -0
- package/dist/sdk/builders.d.ts.map +1 -0
- package/dist/sdk/builders.js +214 -0
- package/dist/sdk/index.d.ts +29 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +38 -0
- package/dist/sdk/types.d.ts +107 -0
- package/dist/sdk/types.d.ts.map +1 -0
- package/dist/sdk/types.js +6 -0
- package/ksa/README.md +263 -0
- package/ksa/_generated/REFERENCE.md +2954 -0
- package/ksa/_generated/registry.ts +257 -0
- package/ksa/_shared/configReader.ts +302 -0
- package/ksa/_shared/configSchemas.ts +649 -0
- package/ksa/_shared/gateway.ts +175 -0
- package/ksa/_shared/ksaBehaviors.ts +411 -0
- package/ksa/_shared/ksaProxy.ts +248 -0
- package/ksa/_shared/localDb.ts +302 -0
- package/ksa/index.ts +134 -0
- package/package.json +93 -0
- package/runtime/browser/agent-browser.ts +330 -0
- package/runtime/entrypoint.ts +194 -0
- package/runtime/lsp/manager.ts +366 -0
- package/runtime/pdf/pdf-generator.ts +50 -0
- package/runtime/pdf/renderer.ts +357 -0
- package/runtime/pdf/schema.ts +97 -0
- package/runtime/services/file-watcher.ts +191 -0
- package/template/build.ts +307 -0
- package/template/e2b/Dockerfile +69 -0
- package/template/e2b/e2b.toml +13 -0
- package/template/e2b/prebuild.sh +68 -0
- 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
|
+
});
|