@lakitu/sdk 0.1.63 → 0.1.65
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/convex/cloud/workflows/compileSandbox.ts +9 -37
- package/convex/sandbox/agent/index.ts +11 -90
- package/convex/sandbox/http.ts +107 -0
- package/convex/sandbox/index.ts +2 -9
- package/convex/sandbox/nodeActions/codeExec.ts +145 -33
- package/convex/sandbox/schema.ts +132 -1
- package/convex/sandbox/sync/index.ts +237 -0
- package/ksa/_shared/trace.ts +240 -0
- package/package.json +5 -1
- package/template/build.ts +17 -10
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sandbox Compiler
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Manages compilation manifest for sandbox definitions.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -27,11 +27,11 @@ export const saveManifest = internalMutation({
|
|
|
27
27
|
.query("compiledSandbox")
|
|
28
28
|
.withIndex("by_version", (q) => q.eq("version", args.version))
|
|
29
29
|
.collect();
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
for (const entry of existing) {
|
|
32
32
|
await ctx.db.delete(entry._id);
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
|
|
35
35
|
// Insert new entries
|
|
36
36
|
for (const item of args.manifest) {
|
|
37
37
|
await ctx.db.insert("compiledSandbox", {
|
|
@@ -43,7 +43,7 @@ export const saveManifest = internalMutation({
|
|
|
43
43
|
createdAt: Date.now(),
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
|
|
47
47
|
return { saved: args.manifest.length };
|
|
48
48
|
},
|
|
49
49
|
});
|
|
@@ -60,15 +60,15 @@ export const getManifest = query({
|
|
|
60
60
|
.withIndex("by_version", (q) => q.eq("version", args.version!))
|
|
61
61
|
.collect();
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
|
|
64
64
|
// Get latest version
|
|
65
65
|
const latest = await ctx.db
|
|
66
66
|
.query("compiledSandbox")
|
|
67
67
|
.order("desc")
|
|
68
68
|
.first();
|
|
69
|
-
|
|
69
|
+
|
|
70
70
|
if (!latest) return [];
|
|
71
|
-
|
|
71
|
+
|
|
72
72
|
return await ctx.db
|
|
73
73
|
.query("compiledSandbox")
|
|
74
74
|
.withIndex("by_version", (q) => q.eq("version", latest.version))
|
|
@@ -84,7 +84,7 @@ export const getLatestVersion = query({
|
|
|
84
84
|
.query("compiledSandbox")
|
|
85
85
|
.order("desc")
|
|
86
86
|
.first();
|
|
87
|
-
|
|
87
|
+
|
|
88
88
|
return latest?.version ?? null;
|
|
89
89
|
},
|
|
90
90
|
});
|
|
@@ -112,35 +112,7 @@ export const getToolImplementation = query({
|
|
|
112
112
|
.query("customTools")
|
|
113
113
|
.withIndex("by_toolId", (q) => q.eq("toolId", args.toolId))
|
|
114
114
|
.first();
|
|
115
|
-
|
|
115
|
+
|
|
116
116
|
return tool?.implementation || null;
|
|
117
117
|
},
|
|
118
118
|
});
|
|
119
|
-
|
|
120
|
-
// ============================================
|
|
121
|
-
// Built-in Metadata Queries (deprecated - metadata now in Lakitu)
|
|
122
|
-
// ============================================
|
|
123
|
-
|
|
124
|
-
/** Get all built-in tool metadata */
|
|
125
|
-
export const getBuiltInTools = query({
|
|
126
|
-
args: {},
|
|
127
|
-
handler: async () => [],
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
/** Get all built-in skill metadata */
|
|
131
|
-
export const getBuiltInSkills = query({
|
|
132
|
-
args: {},
|
|
133
|
-
handler: async () => [],
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
/** Get all built-in deliverable metadata */
|
|
137
|
-
export const getBuiltInDeliverables = query({
|
|
138
|
-
args: {},
|
|
139
|
-
handler: async () => [],
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
/** Get all agent definitions */
|
|
143
|
-
export const getAgents = query({
|
|
144
|
-
args: {},
|
|
145
|
-
handler: async () => [],
|
|
146
|
-
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 4. Response returns through the chain
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { action,
|
|
14
|
+
import { action, query, mutation } from "../_generated/server";
|
|
15
15
|
import { api, internal } from "../_generated/api";
|
|
16
16
|
import { v } from "convex/values";
|
|
17
17
|
import type { ChainOfThoughtStep, StepStatus } from "../../../shared/chain-of-thought";
|
|
@@ -68,7 +68,7 @@ interface GatewayConfig {
|
|
|
68
68
|
jwt: string;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// Module-level gateway config (set by
|
|
71
|
+
// Module-level gateway config (set by startCodeExecThread)
|
|
72
72
|
let gatewayConfig: GatewayConfig | null = null;
|
|
73
73
|
|
|
74
74
|
// Module-level chain-of-thought steps for real-time UI (in-memory per sandbox session)
|
|
@@ -105,7 +105,7 @@ function createToolStep(
|
|
|
105
105
|
status: StepStatus
|
|
106
106
|
): Omit<ChainOfThoughtStep, "id" | "timestamp"> {
|
|
107
107
|
const stepType = getStepTypeForTool(toolName);
|
|
108
|
-
|
|
108
|
+
|
|
109
109
|
switch (stepType) {
|
|
110
110
|
case "search": {
|
|
111
111
|
// Extract URLs from search results
|
|
@@ -123,14 +123,14 @@ function createToolStep(
|
|
|
123
123
|
results: urls.length > 0 ? urls : undefined,
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
|
|
127
127
|
case "browser": {
|
|
128
128
|
const action = toolName.replace("browser_", "") as any;
|
|
129
129
|
return {
|
|
130
130
|
type: "browser",
|
|
131
131
|
status,
|
|
132
132
|
action: action === "open" ? "navigate" : action,
|
|
133
|
-
label: toolName === "browser_open"
|
|
133
|
+
label: toolName === "browser_open"
|
|
134
134
|
? `Navigating to ${(args as any).url}`
|
|
135
135
|
: toolName === "browser_screenshot"
|
|
136
136
|
? "Taking screenshot"
|
|
@@ -139,9 +139,9 @@ function createToolStep(
|
|
|
139
139
|
screenshot: toolName === "browser_screenshot" ? (result as any)?.screenshot : undefined,
|
|
140
140
|
};
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
|
|
143
143
|
case "file": {
|
|
144
|
-
const operation = toolName.includes("read") ? "read"
|
|
144
|
+
const operation = toolName.includes("read") ? "read"
|
|
145
145
|
: toolName.includes("edit") ? "edit"
|
|
146
146
|
: toolName.includes("pdf") ? "save"
|
|
147
147
|
: "write";
|
|
@@ -156,7 +156,7 @@ function createToolStep(
|
|
|
156
156
|
: `Saving ${path}`,
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
|
-
|
|
159
|
+
|
|
160
160
|
default:
|
|
161
161
|
return {
|
|
162
162
|
type: "tool",
|
|
@@ -306,83 +306,6 @@ function createThreadId(): string {
|
|
|
306
306
|
return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
-
// ============================================
|
|
310
|
-
// Legacy Tool Execution Loop (DEPRECATED)
|
|
311
|
-
// ============================================
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* @deprecated Use startCodeExecThread instead. Legacy JSON tool calling is no longer supported.
|
|
315
|
-
*/
|
|
316
|
-
async function runAgentLoop(
|
|
317
|
-
_ctx: any,
|
|
318
|
-
_systemPrompt: string,
|
|
319
|
-
_userPrompt: string,
|
|
320
|
-
_maxSteps: number = 10,
|
|
321
|
-
_threadId?: string
|
|
322
|
-
): Promise<{ text: string; toolCalls: ToolCall[] }> {
|
|
323
|
-
throw new Error(
|
|
324
|
-
"Legacy tool calling mode is deprecated. Use startCodeExecThread instead, " +
|
|
325
|
-
"which uses the new KSA (Knowledge, Skills, Abilities) architecture with code execution."
|
|
326
|
-
);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ============================================
|
|
330
|
-
// Agent Actions
|
|
331
|
-
// ============================================
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Start a new agent thread
|
|
335
|
-
* @deprecated Use startCodeExecThread instead. Legacy JSON tool calling is no longer supported.
|
|
336
|
-
*/
|
|
337
|
-
export const startThread = action({
|
|
338
|
-
args: {
|
|
339
|
-
prompt: v.string(),
|
|
340
|
-
context: v.optional(v.any()),
|
|
341
|
-
},
|
|
342
|
-
handler: async (_ctx, _args): Promise<AgentResult> => {
|
|
343
|
-
throw new Error(
|
|
344
|
-
"startThread is deprecated. Use startCodeExecThread instead, " +
|
|
345
|
-
"which uses the new KSA (Knowledge, Skills, Abilities) architecture with code execution."
|
|
346
|
-
);
|
|
347
|
-
},
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Continue an existing thread
|
|
352
|
-
* @deprecated Use startCodeExecThread instead. Legacy JSON tool calling is no longer supported.
|
|
353
|
-
*/
|
|
354
|
-
export const continueThread = action({
|
|
355
|
-
args: {
|
|
356
|
-
threadId: v.string(),
|
|
357
|
-
prompt: v.string(),
|
|
358
|
-
},
|
|
359
|
-
handler: async (_ctx, _args): Promise<AgentResult> => {
|
|
360
|
-
throw new Error(
|
|
361
|
-
"continueThread is deprecated. Use startCodeExecThread instead, " +
|
|
362
|
-
"which uses the new KSA (Knowledge, Skills, Abilities) architecture with code execution."
|
|
363
|
-
);
|
|
364
|
-
},
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Run agent with timeout for chained execution
|
|
369
|
-
* @deprecated Use startCodeExecThread instead. Legacy JSON tool calling is no longer supported.
|
|
370
|
-
*/
|
|
371
|
-
export const runWithTimeout = internalAction({
|
|
372
|
-
args: {
|
|
373
|
-
prompt: v.string(),
|
|
374
|
-
context: v.optional(v.any()),
|
|
375
|
-
timeoutMs: v.number(),
|
|
376
|
-
checkpointId: v.optional(v.id("checkpoints")),
|
|
377
|
-
},
|
|
378
|
-
handler: async (_ctx, _args) => {
|
|
379
|
-
throw new Error(
|
|
380
|
-
"runWithTimeout is deprecated. Use startCodeExecThread instead, " +
|
|
381
|
-
"which uses the new KSA (Knowledge, Skills, Abilities) architecture with code execution."
|
|
382
|
-
);
|
|
383
|
-
},
|
|
384
|
-
});
|
|
385
|
-
|
|
386
309
|
// ============================================
|
|
387
310
|
// Queries
|
|
388
311
|
// ============================================
|
|
@@ -412,7 +335,7 @@ export const getChainOfThoughtSteps = query({
|
|
|
412
335
|
});
|
|
413
336
|
|
|
414
337
|
// ============================================
|
|
415
|
-
// Code Execution Mode
|
|
338
|
+
// Code Execution Mode
|
|
416
339
|
// ============================================
|
|
417
340
|
|
|
418
341
|
import { runCodeExecLoop, getSteps } from "./codeExecLoop";
|
|
@@ -421,12 +344,10 @@ import { getCodeExecSystemPrompt, generateKSAInstructions } from "../prompts/cod
|
|
|
421
344
|
/**
|
|
422
345
|
* Start a thread using code execution mode.
|
|
423
346
|
*
|
|
424
|
-
* This is the
|
|
347
|
+
* This is the primary agent entry point:
|
|
425
348
|
* - LLM generates TypeScript code
|
|
426
|
-
* - Code imports from
|
|
349
|
+
* - Code imports from KSA modules and executes
|
|
427
350
|
* - No JSON tool calls
|
|
428
|
-
*
|
|
429
|
-
* Use this instead of startThread for the new code execution model.
|
|
430
351
|
*/
|
|
431
352
|
export const startCodeExecThread = action({
|
|
432
353
|
args: {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox HTTP Endpoints
|
|
3
|
+
*
|
|
4
|
+
* Provides observability endpoints for the E2B sandbox.
|
|
5
|
+
* These run on port 3211 (site port) alongside Convex backend.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { httpRouter } from "convex/server";
|
|
9
|
+
import { httpAction } from "./_generated/server";
|
|
10
|
+
|
|
11
|
+
const http = httpRouter();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Metrics endpoint - returns sandbox health and resource usage.
|
|
15
|
+
* Used by pool health checks and Claude Code observability.
|
|
16
|
+
*
|
|
17
|
+
* GET /metrics
|
|
18
|
+
*
|
|
19
|
+
* Response:
|
|
20
|
+
* {
|
|
21
|
+
* uptime: number, // Process uptime in seconds
|
|
22
|
+
* cpu: { usage: number, cores: number },
|
|
23
|
+
* memory: { total, free, used, heapUsed, heapTotal },
|
|
24
|
+
* timestamp: number
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
http.route({
|
|
28
|
+
path: "/metrics",
|
|
29
|
+
method: "GET",
|
|
30
|
+
handler: httpAction(async () => {
|
|
31
|
+
// Dynamic imports for Node.js APIs (required for Convex bundling)
|
|
32
|
+
const os = await import("os");
|
|
33
|
+
|
|
34
|
+
const metrics = {
|
|
35
|
+
uptime: process.uptime(),
|
|
36
|
+
cpu: {
|
|
37
|
+
usage: os.loadavg()[0], // 1-minute load average
|
|
38
|
+
cores: os.cpus().length,
|
|
39
|
+
},
|
|
40
|
+
memory: {
|
|
41
|
+
total: os.totalmem(),
|
|
42
|
+
free: os.freemem(),
|
|
43
|
+
used: os.totalmem() - os.freemem(),
|
|
44
|
+
heapUsed: process.memoryUsage().heapUsed,
|
|
45
|
+
heapTotal: process.memoryUsage().heapTotal,
|
|
46
|
+
},
|
|
47
|
+
services: {
|
|
48
|
+
convex: "running", // We're responding, so Convex is up
|
|
49
|
+
},
|
|
50
|
+
timestamp: Date.now(),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return new Response(JSON.stringify(metrics), {
|
|
54
|
+
status: 200,
|
|
55
|
+
headers: {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
"Cache-Control": "no-cache",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Health check endpoint - simple ping for pool management.
|
|
65
|
+
* Used by E2B pool to verify sandbox is responsive.
|
|
66
|
+
*
|
|
67
|
+
* GET /health
|
|
68
|
+
*/
|
|
69
|
+
http.route({
|
|
70
|
+
path: "/health",
|
|
71
|
+
method: "GET",
|
|
72
|
+
handler: httpAction(async () => {
|
|
73
|
+
return new Response(JSON.stringify({ status: "ok", timestamp: Date.now() }), {
|
|
74
|
+
status: 200,
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
});
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Version endpoint - returns SDK version for debugging.
|
|
82
|
+
* Used by pool health checks.
|
|
83
|
+
*
|
|
84
|
+
* GET /version
|
|
85
|
+
*/
|
|
86
|
+
http.route({
|
|
87
|
+
path: "/version",
|
|
88
|
+
method: "GET",
|
|
89
|
+
handler: httpAction(async () => {
|
|
90
|
+
// Read version from package.json at runtime
|
|
91
|
+
const fs = await import("fs/promises");
|
|
92
|
+
let version = "unknown";
|
|
93
|
+
try {
|
|
94
|
+
const pkg = JSON.parse(await fs.readFile("/home/user/lakitu/package.json", "utf-8"));
|
|
95
|
+
version = pkg.version || "unknown";
|
|
96
|
+
} catch {
|
|
97
|
+
// Fallback if file not found
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return new Response(JSON.stringify({ version, timestamp: Date.now() }), {
|
|
101
|
+
status: 200,
|
|
102
|
+
headers: { "Content-Type": "application/json" },
|
|
103
|
+
});
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export default http;
|
package/convex/sandbox/index.ts
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
// Agent
|
|
9
9
|
// ============================================
|
|
10
10
|
export {
|
|
11
|
-
|
|
12
|
-
continueThread,
|
|
13
|
-
runWithTimeout,
|
|
11
|
+
startCodeExecThread,
|
|
14
12
|
getThreadMessages,
|
|
15
13
|
getStreamDeltas,
|
|
14
|
+
getChainOfThoughtSteps,
|
|
16
15
|
} from "./agent";
|
|
17
16
|
export * as decisions from "./agent/decisions";
|
|
18
17
|
|
|
@@ -38,12 +37,6 @@ export * as sync from "./planning/sync";
|
|
|
38
37
|
export * as context from "./context";
|
|
39
38
|
export * as session from "./context/session";
|
|
40
39
|
|
|
41
|
-
// ============================================
|
|
42
|
-
// Tools (DEPRECATED - Use KSAs instead)
|
|
43
|
-
// ============================================
|
|
44
|
-
// Legacy tool system has been removed. Use the KSA (Knowledge, Skills, Abilities)
|
|
45
|
-
// architecture with code execution mode instead. See packages/lakitu/ksa/
|
|
46
|
-
|
|
47
40
|
// ============================================
|
|
48
41
|
// Prompts
|
|
49
42
|
// ============================================
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"use node";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Code Execution Action
|
|
4
|
+
* Code Execution Action (with Streaming Support)
|
|
5
5
|
*
|
|
6
6
|
* Executes TypeScript code generated by the LLM.
|
|
7
7
|
* This is the core of the code execution model - instead of JSON tool calls,
|
|
8
8
|
* the agent writes code that imports from ksa/ and we execute it.
|
|
9
|
+
*
|
|
10
|
+
* Streaming: Output chunks are forwarded to cloud via fire-and-forget
|
|
11
|
+
* for real-time visibility during long-running executions.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
import { internalAction } from "../_generated/server";
|
|
@@ -15,11 +18,14 @@ import { v } from "convex/values";
|
|
|
15
18
|
const MAX_TIMEOUT_MS = 120_000; // 2 minutes
|
|
16
19
|
const MAX_OUTPUT_LENGTH = 50_000; // 50KB
|
|
17
20
|
|
|
21
|
+
// Chunk size for streaming (balance between latency and overhead)
|
|
22
|
+
const STREAM_CHUNK_INTERVAL_MS = 100; // Batch output every 100ms
|
|
23
|
+
|
|
18
24
|
/**
|
|
19
|
-
* Execute TypeScript code in the sandbox.
|
|
25
|
+
* Execute TypeScript code in the sandbox with streaming output.
|
|
20
26
|
*
|
|
21
27
|
* The code can import from /home/user/ksa/ to use available capabilities.
|
|
22
|
-
* Output is captured
|
|
28
|
+
* Output is captured and optionally streamed to the cloud.
|
|
23
29
|
*/
|
|
24
30
|
export const execute = internalAction({
|
|
25
31
|
args: {
|
|
@@ -33,20 +39,25 @@ export const execute = internalAction({
|
|
|
33
39
|
CARD_ID: v.optional(v.string()),
|
|
34
40
|
THREAD_ID: v.optional(v.string()),
|
|
35
41
|
})),
|
|
42
|
+
// Streaming configuration
|
|
43
|
+
streamConfig: v.optional(v.object({
|
|
44
|
+
sessionId: v.optional(v.string()),
|
|
45
|
+
stepId: v.optional(v.string()),
|
|
46
|
+
cloudUrl: v.optional(v.string()),
|
|
47
|
+
})),
|
|
36
48
|
},
|
|
37
49
|
handler: async (_ctx, args): Promise<{
|
|
38
50
|
success: boolean;
|
|
39
51
|
output: string;
|
|
40
52
|
error?: string;
|
|
41
53
|
exitCode: number;
|
|
54
|
+
streamedChunks?: number; // Track how many chunks were streamed
|
|
42
55
|
}> => {
|
|
43
56
|
// Dynamic imports for Node.js modules (required for Convex bundling)
|
|
44
|
-
const {
|
|
45
|
-
const { promisify } = await import("util");
|
|
57
|
+
const { spawn } = await import("child_process");
|
|
46
58
|
const fs = await import("fs/promises");
|
|
47
59
|
const crypto = await import("crypto");
|
|
48
60
|
|
|
49
|
-
const execAsync = promisify(exec);
|
|
50
61
|
const timeout = Math.min(args.timeoutMs || 60_000, MAX_TIMEOUT_MS);
|
|
51
62
|
|
|
52
63
|
// Generate unique filename for this execution
|
|
@@ -54,32 +65,140 @@ export const execute = internalAction({
|
|
|
54
65
|
const hash = crypto.createHash("md5").update(args.code).digest("hex").slice(0, 8);
|
|
55
66
|
const filename = `/home/user/agent_exec_${Date.now()}_${hash}.ts`;
|
|
56
67
|
|
|
68
|
+
// Streaming state
|
|
69
|
+
let streamBuffer = "";
|
|
70
|
+
let streamedChunks = 0;
|
|
71
|
+
let streamInterval: NodeJS.Timeout | null = null;
|
|
72
|
+
|
|
73
|
+
// Fire-and-forget stream chunk to cloud
|
|
74
|
+
const flushStreamBuffer = () => {
|
|
75
|
+
if (!streamBuffer || !args.streamConfig?.cloudUrl) return;
|
|
76
|
+
|
|
77
|
+
const chunk = streamBuffer;
|
|
78
|
+
streamBuffer = "";
|
|
79
|
+
streamedChunks++;
|
|
80
|
+
|
|
81
|
+
// Non-blocking POST to cloud
|
|
82
|
+
fetch(`${args.streamConfig.cloudUrl}/agent/stream`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
Authorization: `Bearer ${args.env?.SANDBOX_JWT || ""}`,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
sessionId: args.streamConfig.sessionId,
|
|
90
|
+
stepId: args.streamConfig.stepId,
|
|
91
|
+
chunk,
|
|
92
|
+
chunkIndex: streamedChunks,
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
}),
|
|
95
|
+
}).catch(() => {
|
|
96
|
+
// Fire-and-forget - ignore errors
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
57
100
|
try {
|
|
58
101
|
// Write code to temp file
|
|
59
102
|
await fs.writeFile(filename, args.code, "utf-8");
|
|
60
103
|
|
|
61
|
-
// Execute with bun
|
|
104
|
+
// Execute with bun using spawn (for streaming)
|
|
62
105
|
const bunPath = "/home/user/.bun/bin/bun";
|
|
63
|
-
const
|
|
64
|
-
timeout,
|
|
106
|
+
const proc = spawn(bunPath, ["run", filename], {
|
|
65
107
|
cwd: "/home/user/workspace",
|
|
66
108
|
env: {
|
|
67
109
|
...process.env,
|
|
68
|
-
// Ensure PATH includes bun
|
|
69
110
|
PATH: "/home/user/.bun/bin:/usr/local/bin:/usr/bin:/bin",
|
|
70
111
|
HOME: "/home/user",
|
|
71
|
-
// Make KSAs available via import path
|
|
72
112
|
NODE_PATH: "/home/user",
|
|
73
|
-
// Gateway config for KSAs to call cloud services
|
|
74
113
|
...(args.env?.CONVEX_URL && { CONVEX_URL: args.env.CONVEX_URL }),
|
|
75
114
|
...(args.env?.GATEWAY_URL && { GATEWAY_URL: args.env.GATEWAY_URL }),
|
|
76
115
|
...(args.env?.SANDBOX_JWT && { SANDBOX_JWT: args.env.SANDBOX_JWT }),
|
|
77
116
|
...(args.env?.CARD_ID && { CARD_ID: args.env.CARD_ID }),
|
|
78
117
|
...(args.env?.THREAD_ID && { THREAD_ID: args.env.THREAD_ID }),
|
|
79
118
|
},
|
|
80
|
-
maxBuffer: MAX_OUTPUT_LENGTH * 2,
|
|
81
119
|
});
|
|
82
120
|
|
|
121
|
+
// Collect output
|
|
122
|
+
let stdout = "";
|
|
123
|
+
let stderr = "";
|
|
124
|
+
|
|
125
|
+
// Start streaming interval if configured
|
|
126
|
+
if (args.streamConfig?.cloudUrl) {
|
|
127
|
+
streamInterval = setInterval(flushStreamBuffer, STREAM_CHUNK_INTERVAL_MS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Handle stdout
|
|
131
|
+
proc.stdout?.on("data", (data: Buffer) => {
|
|
132
|
+
const text = data.toString();
|
|
133
|
+
stdout += text;
|
|
134
|
+
|
|
135
|
+
// Add to stream buffer if streaming enabled
|
|
136
|
+
if (args.streamConfig?.cloudUrl) {
|
|
137
|
+
streamBuffer += text;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Handle stderr
|
|
142
|
+
proc.stderr?.on("data", (data: Buffer) => {
|
|
143
|
+
const text = data.toString();
|
|
144
|
+
stderr += text;
|
|
145
|
+
|
|
146
|
+
// Add to stream buffer if streaming enabled
|
|
147
|
+
if (args.streamConfig?.cloudUrl) {
|
|
148
|
+
streamBuffer += `[stderr] ${text}`;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Wait for process to complete with timeout
|
|
153
|
+
const result = await new Promise<{ success: boolean; exitCode: number; error?: string }>((resolve) => {
|
|
154
|
+
let resolved = false;
|
|
155
|
+
|
|
156
|
+
// Timeout handler
|
|
157
|
+
const timeoutId = setTimeout(() => {
|
|
158
|
+
if (!resolved) {
|
|
159
|
+
resolved = true;
|
|
160
|
+
proc.kill("SIGTERM");
|
|
161
|
+
resolve({
|
|
162
|
+
success: false,
|
|
163
|
+
exitCode: 124,
|
|
164
|
+
error: `Execution timed out after ${timeout}ms`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}, timeout);
|
|
168
|
+
|
|
169
|
+
// Normal completion
|
|
170
|
+
proc.on("close", (code) => {
|
|
171
|
+
if (!resolved) {
|
|
172
|
+
resolved = true;
|
|
173
|
+
clearTimeout(timeoutId);
|
|
174
|
+
resolve({
|
|
175
|
+
success: code === 0,
|
|
176
|
+
exitCode: code ?? 1,
|
|
177
|
+
error: code !== 0 ? `Process exited with code ${code}` : undefined,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Error handler
|
|
183
|
+
proc.on("error", (err) => {
|
|
184
|
+
if (!resolved) {
|
|
185
|
+
resolved = true;
|
|
186
|
+
clearTimeout(timeoutId);
|
|
187
|
+
resolve({
|
|
188
|
+
success: false,
|
|
189
|
+
exitCode: 1,
|
|
190
|
+
error: err.message,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Stop streaming interval and flush remaining buffer
|
|
197
|
+
if (streamInterval) {
|
|
198
|
+
clearInterval(streamInterval);
|
|
199
|
+
flushStreamBuffer();
|
|
200
|
+
}
|
|
201
|
+
|
|
83
202
|
// Combine and truncate output
|
|
84
203
|
let output = stdout || "";
|
|
85
204
|
if (stderr) {
|
|
@@ -90,34 +209,27 @@ export const execute = internalAction({
|
|
|
90
209
|
}
|
|
91
210
|
|
|
92
211
|
return {
|
|
93
|
-
success:
|
|
212
|
+
success: result.success,
|
|
94
213
|
output: output.trim(),
|
|
95
|
-
|
|
214
|
+
error: result.error,
|
|
215
|
+
exitCode: result.exitCode,
|
|
216
|
+
streamedChunks: args.streamConfig?.cloudUrl ? streamedChunks : undefined,
|
|
96
217
|
};
|
|
97
218
|
} catch (error: unknown) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// Capture any partial output
|
|
103
|
-
if (err.stdout) output += err.stdout;
|
|
104
|
-
if (err.stderr) output += `\n[stderr]\n${err.stderr}`;
|
|
105
|
-
|
|
106
|
-
// Check for timeout
|
|
107
|
-
if (message.includes("TIMEOUT") || message.includes("timed out")) {
|
|
108
|
-
return {
|
|
109
|
-
success: false,
|
|
110
|
-
output: output.trim(),
|
|
111
|
-
error: `Execution timed out after ${timeout}ms`,
|
|
112
|
-
exitCode: 124,
|
|
113
|
-
};
|
|
219
|
+
// Stop streaming on error
|
|
220
|
+
if (streamInterval) {
|
|
221
|
+
clearInterval(streamInterval);
|
|
114
222
|
}
|
|
115
223
|
|
|
224
|
+
const err = error as { message?: string };
|
|
225
|
+
const message = err.message || String(error);
|
|
226
|
+
|
|
116
227
|
return {
|
|
117
228
|
success: false,
|
|
118
|
-
output:
|
|
229
|
+
output: "",
|
|
119
230
|
error: message,
|
|
120
|
-
exitCode:
|
|
231
|
+
exitCode: 1,
|
|
232
|
+
streamedChunks: args.streamConfig?.cloudUrl ? streamedChunks : undefined,
|
|
121
233
|
};
|
|
122
234
|
} finally {
|
|
123
235
|
// Clean up temp file
|
package/convex/sandbox/schema.ts
CHANGED
|
@@ -476,7 +476,7 @@ export default defineSchema({
|
|
|
476
476
|
.index("by_thread", ["threadId"]),
|
|
477
477
|
|
|
478
478
|
// ============================================
|
|
479
|
-
// Sync Queue - Items to sync to cloud
|
|
479
|
+
// Sync Queue - Items to sync to cloud (legacy)
|
|
480
480
|
// ============================================
|
|
481
481
|
|
|
482
482
|
syncQueue: defineTable({
|
|
@@ -507,4 +507,135 @@ export default defineSchema({
|
|
|
507
507
|
})
|
|
508
508
|
.index("by_status", ["status"])
|
|
509
509
|
.index("by_type", ["type"]),
|
|
510
|
+
|
|
511
|
+
// ============================================
|
|
512
|
+
// Sync Outbox - Transactional outbox for cloud sync
|
|
513
|
+
// ============================================
|
|
514
|
+
// Records all local writes that need to sync to cloud.
|
|
515
|
+
// Uses idempotency keys for at-least-once delivery without duplicates.
|
|
516
|
+
|
|
517
|
+
syncOutbox: defineTable({
|
|
518
|
+
// Idempotency key - UUID generated at write time, ensures no duplicates
|
|
519
|
+
idempotencyKey: v.string(),
|
|
520
|
+
|
|
521
|
+
// Entity reference
|
|
522
|
+
entityType: v.string(), // "brands.products", "brands.assets", "artifacts", etc.
|
|
523
|
+
entityId: v.string(), // Local ID of the entity
|
|
524
|
+
|
|
525
|
+
// Operation
|
|
526
|
+
operation: v.union(
|
|
527
|
+
v.literal("create"),
|
|
528
|
+
v.literal("update"),
|
|
529
|
+
v.literal("delete"),
|
|
530
|
+
v.literal("upsert")
|
|
531
|
+
),
|
|
532
|
+
|
|
533
|
+
// Payload to send to cloud
|
|
534
|
+
payload: v.any(),
|
|
535
|
+
|
|
536
|
+
// Cloud target (gateway path)
|
|
537
|
+
cloudPath: v.string(), // e.g., "features.brands.core.products.saveProducts"
|
|
538
|
+
|
|
539
|
+
// Status tracking
|
|
540
|
+
status: v.union(
|
|
541
|
+
v.literal("pending"),
|
|
542
|
+
v.literal("syncing"),
|
|
543
|
+
v.literal("synced"),
|
|
544
|
+
v.literal("failed"),
|
|
545
|
+
v.literal("skipped")
|
|
546
|
+
),
|
|
547
|
+
|
|
548
|
+
// Retry tracking
|
|
549
|
+
attempts: v.number(),
|
|
550
|
+
maxAttempts: v.number(),
|
|
551
|
+
lastAttemptAt: v.optional(v.number()),
|
|
552
|
+
lastError: v.optional(v.string()),
|
|
553
|
+
|
|
554
|
+
// Result tracking
|
|
555
|
+
cloudId: v.optional(v.string()), // ID returned from cloud
|
|
556
|
+
syncedAt: v.optional(v.number()),
|
|
557
|
+
|
|
558
|
+
// Metadata
|
|
559
|
+
priority: v.number(), // 0=highest, 10=lowest
|
|
560
|
+
createdAt: v.number(),
|
|
561
|
+
sessionId: v.optional(v.string()),
|
|
562
|
+
threadId: v.optional(v.string()),
|
|
563
|
+
})
|
|
564
|
+
.index("by_status", ["status"])
|
|
565
|
+
.index("by_idempotency", ["idempotencyKey"])
|
|
566
|
+
.index("by_entity", ["entityType", "entityId"])
|
|
567
|
+
.index("by_session", ["sessionId", "status"])
|
|
568
|
+
.index("by_priority", ["status", "priority", "createdAt"]),
|
|
569
|
+
|
|
570
|
+
// ============================================
|
|
571
|
+
// Marketing Copy - Extracted marketing content
|
|
572
|
+
// ============================================
|
|
573
|
+
|
|
574
|
+
discoveredMarketingCopy: defineTable({
|
|
575
|
+
domain: v.string(),
|
|
576
|
+
sourceUrl: v.string(),
|
|
577
|
+
copyType: v.union(
|
|
578
|
+
v.literal("headline"),
|
|
579
|
+
v.literal("tagline"),
|
|
580
|
+
v.literal("value_prop"),
|
|
581
|
+
v.literal("cta"),
|
|
582
|
+
v.literal("testimonial"),
|
|
583
|
+
v.literal("feature_description"),
|
|
584
|
+
v.literal("other")
|
|
585
|
+
),
|
|
586
|
+
content: v.string(),
|
|
587
|
+
context: v.optional(v.string()),
|
|
588
|
+
extractedAt: v.number(),
|
|
589
|
+
threadId: v.optional(v.string()),
|
|
590
|
+
syncedToCloud: v.boolean(),
|
|
591
|
+
})
|
|
592
|
+
.index("by_domain", ["domain"])
|
|
593
|
+
.index("by_type", ["copyType"])
|
|
594
|
+
.index("by_synced", ["syncedToCloud"]),
|
|
595
|
+
|
|
596
|
+
// ============================================
|
|
597
|
+
// Services - Extracted service offerings
|
|
598
|
+
// ============================================
|
|
599
|
+
|
|
600
|
+
discoveredServices: defineTable({
|
|
601
|
+
domain: v.string(),
|
|
602
|
+
sourceUrl: v.string(),
|
|
603
|
+
name: v.string(),
|
|
604
|
+
serviceType: v.union(
|
|
605
|
+
v.literal("consulting"),
|
|
606
|
+
v.literal("implementation"),
|
|
607
|
+
v.literal("support"),
|
|
608
|
+
v.literal("training"),
|
|
609
|
+
v.literal("managed_service"),
|
|
610
|
+
v.literal("professional_service"),
|
|
611
|
+
v.literal("other")
|
|
612
|
+
),
|
|
613
|
+
description: v.optional(v.string()),
|
|
614
|
+
pricing: v.optional(v.string()),
|
|
615
|
+
extractedAt: v.number(),
|
|
616
|
+
threadId: v.optional(v.string()),
|
|
617
|
+
syncedToCloud: v.boolean(),
|
|
618
|
+
})
|
|
619
|
+
.index("by_domain", ["domain"])
|
|
620
|
+
.index("by_type", ["serviceType"])
|
|
621
|
+
.index("by_synced", ["syncedToCloud"]),
|
|
622
|
+
|
|
623
|
+
// ============================================
|
|
624
|
+
// Reviews - Extracted reviews/testimonials
|
|
625
|
+
// ============================================
|
|
626
|
+
|
|
627
|
+
discoveredReviews: defineTable({
|
|
628
|
+
domain: v.string(),
|
|
629
|
+
sourceUrl: v.string(),
|
|
630
|
+
reviewerName: v.optional(v.string()),
|
|
631
|
+
rating: v.optional(v.number()),
|
|
632
|
+
content: v.string(),
|
|
633
|
+
date: v.optional(v.string()),
|
|
634
|
+
platform: v.optional(v.string()),
|
|
635
|
+
extractedAt: v.number(),
|
|
636
|
+
threadId: v.optional(v.string()),
|
|
637
|
+
syncedToCloud: v.boolean(),
|
|
638
|
+
})
|
|
639
|
+
.index("by_domain", ["domain"])
|
|
640
|
+
.index("by_synced", ["syncedToCloud"]),
|
|
510
641
|
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Outbox - Local to Cloud Sync
|
|
3
|
+
*
|
|
4
|
+
* Transactional outbox pattern for syncing local sandbox data to cloud.
|
|
5
|
+
* All local writes are recorded atomically, then synced via gateway.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { v } from "convex/values";
|
|
9
|
+
import { mutation, query } from "../_generated/server";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Outbox Operations
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Add an item to the sync outbox.
|
|
17
|
+
* Called atomically with the local write.
|
|
18
|
+
*/
|
|
19
|
+
export const addToOutbox = mutation({
|
|
20
|
+
args: {
|
|
21
|
+
idempotencyKey: v.string(),
|
|
22
|
+
entityType: v.string(),
|
|
23
|
+
entityId: v.string(),
|
|
24
|
+
operation: v.union(
|
|
25
|
+
v.literal("create"),
|
|
26
|
+
v.literal("update"),
|
|
27
|
+
v.literal("delete"),
|
|
28
|
+
v.literal("upsert")
|
|
29
|
+
),
|
|
30
|
+
payload: v.any(),
|
|
31
|
+
cloudPath: v.string(),
|
|
32
|
+
priority: v.optional(v.number()),
|
|
33
|
+
sessionId: v.optional(v.string()),
|
|
34
|
+
threadId: v.optional(v.string()),
|
|
35
|
+
},
|
|
36
|
+
handler: async (ctx, args) => {
|
|
37
|
+
// Check for existing entry with same idempotency key
|
|
38
|
+
const existing = await ctx.db
|
|
39
|
+
.query("syncOutbox")
|
|
40
|
+
.withIndex("by_idempotency", q => q.eq("idempotencyKey", args.idempotencyKey))
|
|
41
|
+
.first();
|
|
42
|
+
|
|
43
|
+
if (existing) {
|
|
44
|
+
// Already in outbox, skip
|
|
45
|
+
return existing._id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return await ctx.db.insert("syncOutbox", {
|
|
49
|
+
idempotencyKey: args.idempotencyKey,
|
|
50
|
+
entityType: args.entityType,
|
|
51
|
+
entityId: args.entityId,
|
|
52
|
+
operation: args.operation,
|
|
53
|
+
payload: args.payload,
|
|
54
|
+
cloudPath: args.cloudPath,
|
|
55
|
+
status: "pending",
|
|
56
|
+
attempts: 0,
|
|
57
|
+
maxAttempts: 3,
|
|
58
|
+
priority: args.priority ?? 5,
|
|
59
|
+
createdAt: Date.now(),
|
|
60
|
+
sessionId: args.sessionId,
|
|
61
|
+
threadId: args.threadId,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get pending items from outbox, ordered by priority and creation time.
|
|
68
|
+
*/
|
|
69
|
+
export const getPendingItems = query({
|
|
70
|
+
args: {
|
|
71
|
+
limit: v.optional(v.number()),
|
|
72
|
+
sessionId: v.optional(v.string()),
|
|
73
|
+
},
|
|
74
|
+
handler: async (ctx, args) => {
|
|
75
|
+
const limit = args.limit ?? 50;
|
|
76
|
+
|
|
77
|
+
if (args.sessionId) {
|
|
78
|
+
return await ctx.db
|
|
79
|
+
.query("syncOutbox")
|
|
80
|
+
.withIndex("by_session", q => q.eq("sessionId", args.sessionId).eq("status", "pending"))
|
|
81
|
+
.take(limit);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return await ctx.db
|
|
85
|
+
.query("syncOutbox")
|
|
86
|
+
.withIndex("by_priority", q => q.eq("status", "pending"))
|
|
87
|
+
.take(limit);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Mark item as syncing (in progress).
|
|
93
|
+
*/
|
|
94
|
+
export const markSyncing = mutation({
|
|
95
|
+
args: {
|
|
96
|
+
id: v.id("syncOutbox"),
|
|
97
|
+
},
|
|
98
|
+
handler: async (ctx, args) => {
|
|
99
|
+
const item = await ctx.db.get(args.id);
|
|
100
|
+
if (!item) return null;
|
|
101
|
+
|
|
102
|
+
await ctx.db.patch(args.id, {
|
|
103
|
+
status: "syncing",
|
|
104
|
+
lastAttemptAt: Date.now(),
|
|
105
|
+
attempts: item.attempts + 1,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return item;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Mark item as synced (success).
|
|
114
|
+
*/
|
|
115
|
+
export const markSynced = mutation({
|
|
116
|
+
args: {
|
|
117
|
+
id: v.id("syncOutbox"),
|
|
118
|
+
cloudId: v.optional(v.string()),
|
|
119
|
+
},
|
|
120
|
+
handler: async (ctx, args) => {
|
|
121
|
+
await ctx.db.patch(args.id, {
|
|
122
|
+
status: "synced",
|
|
123
|
+
syncedAt: Date.now(),
|
|
124
|
+
cloudId: args.cloudId,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Mark item as failed.
|
|
131
|
+
*/
|
|
132
|
+
export const markFailed = mutation({
|
|
133
|
+
args: {
|
|
134
|
+
id: v.id("syncOutbox"),
|
|
135
|
+
error: v.string(),
|
|
136
|
+
},
|
|
137
|
+
handler: async (ctx, args) => {
|
|
138
|
+
const item = await ctx.db.get(args.id);
|
|
139
|
+
if (!item) return;
|
|
140
|
+
|
|
141
|
+
const newStatus = item.attempts >= item.maxAttempts ? "failed" : "pending";
|
|
142
|
+
|
|
143
|
+
await ctx.db.patch(args.id, {
|
|
144
|
+
status: newStatus,
|
|
145
|
+
lastError: args.error,
|
|
146
|
+
lastAttemptAt: Date.now(),
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get outbox statistics.
|
|
153
|
+
*/
|
|
154
|
+
export const getStats = query({
|
|
155
|
+
args: {
|
|
156
|
+
sessionId: v.optional(v.string()),
|
|
157
|
+
},
|
|
158
|
+
handler: async (ctx, args) => {
|
|
159
|
+
let items;
|
|
160
|
+
if (args.sessionId) {
|
|
161
|
+
items = await ctx.db
|
|
162
|
+
.query("syncOutbox")
|
|
163
|
+
.withIndex("by_session", q => q.eq("sessionId", args.sessionId))
|
|
164
|
+
.collect();
|
|
165
|
+
} else {
|
|
166
|
+
items = await ctx.db.query("syncOutbox").collect();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
total: items.length,
|
|
171
|
+
pending: items.filter(i => i.status === "pending").length,
|
|
172
|
+
syncing: items.filter(i => i.status === "syncing").length,
|
|
173
|
+
synced: items.filter(i => i.status === "synced").length,
|
|
174
|
+
failed: items.filter(i => i.status === "failed").length,
|
|
175
|
+
byType: items.reduce((acc, i) => {
|
|
176
|
+
acc[i.entityType] = (acc[i.entityType] || 0) + 1;
|
|
177
|
+
return acc;
|
|
178
|
+
}, {} as Record<string, number>),
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Clear synced items (cleanup).
|
|
185
|
+
*/
|
|
186
|
+
export const clearSynced = mutation({
|
|
187
|
+
args: {
|
|
188
|
+
olderThanMs: v.optional(v.number()),
|
|
189
|
+
},
|
|
190
|
+
handler: async (ctx, args) => {
|
|
191
|
+
const cutoff = Date.now() - (args.olderThanMs ?? 24 * 60 * 60 * 1000); // 24h default
|
|
192
|
+
|
|
193
|
+
const synced = await ctx.db
|
|
194
|
+
.query("syncOutbox")
|
|
195
|
+
.withIndex("by_status", q => q.eq("status", "synced"))
|
|
196
|
+
.collect();
|
|
197
|
+
|
|
198
|
+
let deleted = 0;
|
|
199
|
+
for (const item of synced) {
|
|
200
|
+
if (item.syncedAt && item.syncedAt < cutoff) {
|
|
201
|
+
await ctx.db.delete(item._id);
|
|
202
|
+
deleted++;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { deleted };
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Retry failed items.
|
|
212
|
+
*/
|
|
213
|
+
export const retryFailed = mutation({
|
|
214
|
+
args: {
|
|
215
|
+
entityType: v.optional(v.string()),
|
|
216
|
+
},
|
|
217
|
+
handler: async (ctx, args) => {
|
|
218
|
+
const failed = await ctx.db
|
|
219
|
+
.query("syncOutbox")
|
|
220
|
+
.withIndex("by_status", q => q.eq("status", "failed"))
|
|
221
|
+
.collect();
|
|
222
|
+
|
|
223
|
+
let retried = 0;
|
|
224
|
+
for (const item of failed) {
|
|
225
|
+
if (!args.entityType || item.entityType === args.entityType) {
|
|
226
|
+
await ctx.db.patch(item._id, {
|
|
227
|
+
status: "pending",
|
|
228
|
+
attempts: 0,
|
|
229
|
+
lastError: undefined,
|
|
230
|
+
});
|
|
231
|
+
retried++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { retried };
|
|
236
|
+
},
|
|
237
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KSA Tracing Module
|
|
3
|
+
*
|
|
4
|
+
* Provides tracing utilities for KSA gateway calls.
|
|
5
|
+
* Enables per-step call tracing for observability into agent execution.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* 1. Agent loop calls setStepContext() at start of each step
|
|
9
|
+
* 2. KSAs use tracedGatewayCall() instead of callGateway()
|
|
10
|
+
* 3. Agent loop calls flushTraces() after step completion
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { callGateway, fireAndForget } from "./gateway";
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export interface TraceEntry {
|
|
20
|
+
traceId: string;
|
|
21
|
+
stepId: string | null;
|
|
22
|
+
event: "ksa_call_start" | "ksa_call_end";
|
|
23
|
+
path: string;
|
|
24
|
+
timestamp: number;
|
|
25
|
+
durationMs?: number;
|
|
26
|
+
success?: boolean;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StepContext {
|
|
31
|
+
stepId: string;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
stepNumber?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Tracing State (per-sandbox)
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
let currentStepContext: StepContext | null = null;
|
|
41
|
+
let traceBuffer: TraceEntry[] = [];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate a unique trace ID for KSA calls.
|
|
45
|
+
* Format: ksa-{timestamp}-{random} for easy identification.
|
|
46
|
+
*/
|
|
47
|
+
function generateTraceId(): string {
|
|
48
|
+
const timestamp = Date.now().toString(36);
|
|
49
|
+
const random = Math.random().toString(36).substring(2, 6);
|
|
50
|
+
return `ksa-${timestamp}-${random}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Context Management
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Set the current step context for tracing.
|
|
59
|
+
* Call this at the start of each agent step.
|
|
60
|
+
*
|
|
61
|
+
* @param context - Step context with stepId and optional metadata
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* setStepContext({ stepId: 'step-001', sessionId: 'sess-123', stepNumber: 1 });
|
|
65
|
+
*/
|
|
66
|
+
export function setStepContext(context: StepContext): void {
|
|
67
|
+
currentStepContext = context;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Clear the current step context.
|
|
72
|
+
* Call this after step completion.
|
|
73
|
+
*/
|
|
74
|
+
export function clearStepContext(): void {
|
|
75
|
+
currentStepContext = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the current step context (for debugging).
|
|
80
|
+
*/
|
|
81
|
+
export function getStepContext(): StepContext | null {
|
|
82
|
+
return currentStepContext;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Traced Gateway Calls
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Make a traced gateway call.
|
|
91
|
+
* Records start/end events with timing for observability.
|
|
92
|
+
*
|
|
93
|
+
* @param path - Service path (e.g., 'services.Valyu.internal.search')
|
|
94
|
+
* @param args - Arguments to pass to the service
|
|
95
|
+
* @param type - Operation type (query, mutation, action)
|
|
96
|
+
* @returns Service response data
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* const data = await tracedGatewayCall('features.brands.core.crud.get', { id: 'abc123' });
|
|
100
|
+
*/
|
|
101
|
+
export async function tracedGatewayCall<T = unknown>(
|
|
102
|
+
path: string,
|
|
103
|
+
args: Record<string, unknown>,
|
|
104
|
+
type?: "query" | "mutation" | "action"
|
|
105
|
+
): Promise<T> {
|
|
106
|
+
const traceId = generateTraceId();
|
|
107
|
+
const startTime = Date.now();
|
|
108
|
+
const stepId = currentStepContext?.stepId ?? null;
|
|
109
|
+
|
|
110
|
+
// Record start event
|
|
111
|
+
traceBuffer.push({
|
|
112
|
+
traceId,
|
|
113
|
+
stepId,
|
|
114
|
+
event: "ksa_call_start",
|
|
115
|
+
path,
|
|
116
|
+
timestamp: startTime,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const result = await callGateway<T>(path, args, type);
|
|
121
|
+
|
|
122
|
+
// Record success event
|
|
123
|
+
traceBuffer.push({
|
|
124
|
+
traceId,
|
|
125
|
+
stepId,
|
|
126
|
+
event: "ksa_call_end",
|
|
127
|
+
path,
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
durationMs: Date.now() - startTime,
|
|
130
|
+
success: true,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
} catch (error: unknown) {
|
|
135
|
+
const err = error as { message?: string };
|
|
136
|
+
|
|
137
|
+
// Record failure event
|
|
138
|
+
traceBuffer.push({
|
|
139
|
+
traceId,
|
|
140
|
+
stepId,
|
|
141
|
+
event: "ksa_call_end",
|
|
142
|
+
path,
|
|
143
|
+
timestamp: Date.now(),
|
|
144
|
+
durationMs: Date.now() - startTime,
|
|
145
|
+
success: false,
|
|
146
|
+
error: err.message || String(error),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// =============================================================================
|
|
154
|
+
// Trace Buffer Management
|
|
155
|
+
// =============================================================================
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get all traces in the buffer without clearing.
|
|
159
|
+
* Useful for debugging.
|
|
160
|
+
*/
|
|
161
|
+
export function getTraces(): TraceEntry[] {
|
|
162
|
+
return [...traceBuffer];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Flush traces from the buffer and return them.
|
|
167
|
+
* Call this after each step to collect traces.
|
|
168
|
+
*
|
|
169
|
+
* @returns Array of trace entries
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* const traces = flushTraces();
|
|
173
|
+
* console.log(`Step had ${traces.length} KSA calls`);
|
|
174
|
+
*/
|
|
175
|
+
export function flushTraces(): TraceEntry[] {
|
|
176
|
+
const traces = [...traceBuffer];
|
|
177
|
+
traceBuffer = [];
|
|
178
|
+
return traces;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Flush traces to the cloud via fire-and-forget.
|
|
183
|
+
* Use this for real-time observability without blocking.
|
|
184
|
+
*
|
|
185
|
+
* @param cloudPath - Path to send traces (default: 'components.lakitu.workflows.sandboxConvex.appendTraces')
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* flushTracesToCloud();
|
|
189
|
+
*/
|
|
190
|
+
export function flushTracesToCloud(
|
|
191
|
+
cloudPath = "components.lakitu.workflows.sandboxConvex.appendTraces"
|
|
192
|
+
): void {
|
|
193
|
+
const traces = flushTraces();
|
|
194
|
+
if (traces.length === 0) return;
|
|
195
|
+
|
|
196
|
+
const sessionId = currentStepContext?.sessionId;
|
|
197
|
+
if (!sessionId) {
|
|
198
|
+
console.warn("[trace] Cannot flush traces: no sessionId in context");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
fireAndForget(cloudPath, {
|
|
203
|
+
sessionId,
|
|
204
|
+
traces,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get trace statistics for the current buffer.
|
|
210
|
+
* Useful for debugging and observability.
|
|
211
|
+
*/
|
|
212
|
+
export function getTraceStats(): {
|
|
213
|
+
totalCalls: number;
|
|
214
|
+
successfulCalls: number;
|
|
215
|
+
failedCalls: number;
|
|
216
|
+
avgDurationMs: number;
|
|
217
|
+
callsByPath: Record<string, number>;
|
|
218
|
+
} {
|
|
219
|
+
const endTraces = traceBuffer.filter((t) => t.event === "ksa_call_end");
|
|
220
|
+
|
|
221
|
+
const successfulCalls = endTraces.filter((t) => t.success).length;
|
|
222
|
+
const failedCalls = endTraces.filter((t) => !t.success).length;
|
|
223
|
+
|
|
224
|
+
const durations = endTraces.filter((t) => t.durationMs !== undefined).map((t) => t.durationMs!);
|
|
225
|
+
const avgDurationMs =
|
|
226
|
+
durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
|
|
227
|
+
|
|
228
|
+
const callsByPath: Record<string, number> = {};
|
|
229
|
+
for (const trace of endTraces) {
|
|
230
|
+
callsByPath[trace.path] = (callsByPath[trace.path] || 0) + 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
totalCalls: endTraces.length,
|
|
235
|
+
successfulCalls,
|
|
236
|
+
failedCalls,
|
|
237
|
+
avgDurationMs,
|
|
238
|
+
callsByPath,
|
|
239
|
+
};
|
|
240
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lakitu/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.65",
|
|
4
4
|
"description": "Self-hosted AI agent framework for Convex + E2B with code execution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/sdk/index.js",
|
|
@@ -71,6 +71,10 @@
|
|
|
71
71
|
"./ksa/browser": {
|
|
72
72
|
"bun": "./ksa/browser.ts",
|
|
73
73
|
"default": "./ksa/browser.ts"
|
|
74
|
+
},
|
|
75
|
+
"./ksa/trace": {
|
|
76
|
+
"bun": "./ksa/_shared/trace.ts",
|
|
77
|
+
"default": "./ksa/_shared/trace.ts"
|
|
74
78
|
}
|
|
75
79
|
},
|
|
76
80
|
"files": [
|
package/template/build.ts
CHANGED
|
@@ -280,7 +280,7 @@ const customTemplate = (baseId: string, buildDir: string, config: TemplateConfig
|
|
|
280
280
|
if (aptPackages.length > 0 || pipPackages.length > 0 || npmPackages.length > 0) {
|
|
281
281
|
console.log("Installing custom packages from template config...");
|
|
282
282
|
const installCmds: string[] = [];
|
|
283
|
-
|
|
283
|
+
|
|
284
284
|
if (aptPackages.length > 0) {
|
|
285
285
|
console.log(` APT: ${aptPackages.join(", ")}`);
|
|
286
286
|
installCmds.push(generateAptInstall(aptPackages));
|
|
@@ -396,20 +396,27 @@ async function buildCustom(baseId = "lakitu-base") {
|
|
|
396
396
|
${LAKITU_DIR}/ ${BUILD_DIR}/lakitu/`.quiet();
|
|
397
397
|
await $`cp ${import.meta.dir}/e2b/start.sh ${BUILD_DIR}/`;
|
|
398
398
|
|
|
399
|
-
// Copy project-specific KSAs
|
|
400
|
-
|
|
401
|
-
|
|
399
|
+
// Copy project-specific KSAs
|
|
400
|
+
// Supported paths (checked in order):
|
|
401
|
+
// 1. convex/lakitu/ksa/ - Alternative location (colocated with convex backend)
|
|
402
|
+
// 2. lakitu/ - Standard location for project.social (27 KSA modules)
|
|
403
|
+
//
|
|
404
|
+
// Note: project.social uses lakitu/ at the project root as its standard KSA location.
|
|
405
|
+
// The convex/lakitu/ksa/ path is supported for projects that prefer colocating KSAs
|
|
406
|
+
// with their Convex backend code.
|
|
407
|
+
const ALT_KSA_DIR = `${LAKITU_DIR}/../../convex/lakitu/ksa`;
|
|
408
|
+
const STANDARD_KSA_DIR = `${LAKITU_DIR}/../../lakitu`;
|
|
402
409
|
await $`mkdir -p ${BUILD_DIR}/project-ksa`.quiet();
|
|
403
|
-
|
|
404
|
-
// Try
|
|
410
|
+
|
|
411
|
+
// Try alternative location first (convex/lakitu/ksa/), fall back to standard (lakitu/)
|
|
405
412
|
try {
|
|
406
|
-
await $`test -d ${
|
|
407
|
-
await $`cp -r ${
|
|
413
|
+
await $`test -d ${ALT_KSA_DIR}`.quiet();
|
|
414
|
+
await $`cp -r ${ALT_KSA_DIR}/* ${BUILD_DIR}/project-ksa/`.quiet();
|
|
408
415
|
console.log("Copied project KSAs from convex/lakitu/ksa/");
|
|
409
416
|
} catch {
|
|
410
417
|
try {
|
|
411
|
-
await $`cp -r ${
|
|
412
|
-
console.log("Copied project KSAs from lakitu/
|
|
418
|
+
await $`cp -r ${STANDARD_KSA_DIR}/* ${BUILD_DIR}/project-ksa/`.quiet();
|
|
419
|
+
console.log("Copied project KSAs from lakitu/");
|
|
413
420
|
} catch {
|
|
414
421
|
console.log("No project KSAs found");
|
|
415
422
|
}
|