History

Add scoped agent storage tools with local and Cloudflare backends

This introduces storage/fs/kv/sqlite system tools, workspace storage instance lifecycle APIs, and a new Tools > Storage UI so agents can persist state safely. It also adds execute-flow smoke automation to validate both local and Cloudflare runtime paths end-to-end.

Rhys Sullivan committed 8 months ago

Commit 1df97bf1 parent a445fa831 files · +3810 −39Browse files

Unified · Split

31 files
  1. .env.example +8 −0
  2. bun.lock +22 −0
  3. executor/apps/web/src/components/app-shell.tsx +1 −0
  4. executor/apps/web/src/components/tools/storage.tsx +238 −0
  5. executor/apps/web/src/components/tools/view.tsx +101 −2
  6. executor/apps/web/src/lib/types.ts +25 −0
  7. executor/apps/web/src/routeTree.gen.ts +21 −0
  8. executor/apps/web/src/routes/tools/storage.tsx +17 −0
  9. executor/package.json +3 −0
  10. executor/packages/core/src/types.ts +25 −0
  11. executor/packages/database/convex/_generated/api.d.ts +177 −0
  12. executor/packages/database/convex/database.ts +8 −0
  13. executor/packages/database/convex/database/storage_instances.ts +414 −0
  14. executor/packages/database/convex/schema.ts +33 −0
  15. executor/packages/database/convex/workspace.ts +71 −0
  16. executor/packages/database/package.json +4 −0
  17. executor/packages/database/src/database/mappers.ts +23 −0
  18. executor/packages/database/src/database/validators.ts +13 −0
  19. executor/packages/database/src/runtime/base_tools.ts +202 −30
  20. executor/packages/database/src/runtime/storage_provider.ts +641 −0
  21. executor/packages/database/src/runtime/storage_tool_contracts.ts +540 −0
  22. executor/packages/database/src/runtime/storage_tools.ts +387 −0
  23. executor/packages/database/src/runtime/tool_invocation.ts +9 −2
  24. executor/packages/database/src/runtime/workspace_tools.ts +4 −1
  25. executor/packages/database/src/types/sqljs-asm.d.ts +23 −0
  26. executor/packages/runner-sandbox-host/package.json +1 −0
  27. executor/packages/runner-sandbox-host/src/index.ts +380 −2
  28. executor/packages/runner-sandbox-host/src/types.ts +20 −0
  29. executor/packages/runner-sandbox-host/wrangler.jsonc +19 −1
  30. executor/scripts/dev/storage/smoke.execute-flow.ts +375 −0
  31. package.json +5 −1
.env.example+8−0
@@ -13,6 +13,14 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 12 unmodified lines
13# Leave unset in production to default to Cloudflare runtime.13# Leave unset in production to default to Cloudflare runtime.
14# DANGEROUSLY_ALLOW_LOCAL_VM=114# DANGEROUSLY_ALLOW_LOCAL_VM=1
15 15
16# ── Agent storage backend (optional) ──
17# Default: agentfs-local
18# AGENT_STORAGE_PROVIDER=agentfs-local
19# AGENT_STORAGE_ROOT=/tmp/executor-agentfs
20# For Cloudflare Durable Objects storage proxy (served by runner-sandbox-host):
21# AGENT_STORAGE_PROVIDER=agentfs-cloudflare
22# Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config.
23
16# ── WorkOS (optional — enables auth, omit for anonymous mode) ──24# ── WorkOS (optional — enables auth, omit for anonymous mode) ──
17# WORKOS_CLIENT_ID=client_...25# WORKOS_CLIENT_ID=client_...
18# WORKOS_API_KEY=sk_test_...26# WORKOS_API_KEY=sk_test_...
bun.lock+22−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 "@convex-dev/stripe": "^0.1.3",10 "@convex-dev/stripe": "^0.1.3",
11 "@convex-dev/workos-authkit": "^0.1.6",11 "@convex-dev/workos-authkit": "^0.1.6",
12 "@workos-inc/node": "^8.2.0",12 "@workos-inc/node": "^8.2.0",
13 "agentfs-sdk": "^0.6.0",
13 "convex": "^1.31.7",14 "convex": "^1.31.7",
14 "convex-helpers": "^0.1.111",15 "convex-helpers": "^0.1.111",
15 "jose": "^6.1.0",16 "jose": "^6.1.0",
@@ -149,12 +150,17 @@ · 133 unmodified lines
149 "executor/packages/database": {150 "executor/packages/database": {
150 "name": "@executor/database",151 "name": "@executor/database",
151 "version": "0.0.1",152 "version": "0.0.1",
153 "dependencies": {
154 "convex": "^1.31.7",
155 "sql.js": "^1.14.0",
156 },
152 },157 },
153 "executor/packages/runner-sandbox-host": {158 "executor/packages/runner-sandbox-host": {
154 "name": "@executor/runner-sandbox-host",159 "name": "@executor/runner-sandbox-host",
155 "version": "0.1.0",160 "version": "0.1.0",
156 "dependencies": {161 "dependencies": {
157 "@executor/database": "workspace:*",162 "@executor/database": "workspace:*",
163 "agentfs-sdk": "^0.6.0",
158 "better-result": "^2.7.0",164 "better-result": "^2.7.0",
159 "convex": "^1.31.7",165 "convex": "^1.31.7",
160 },166 },
@@ -1195,6 +1201,18 @@ · 1034 unmodified lines
1195 1201
1196 "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],1202 "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
1197 1203
1204 "@tursodatabase/database": ["@tursodatabase/database@0.4.4", "", { "dependencies": { "@tursodatabase/database-common": "^0.4.4" }, "optionalDependencies": { "@tursodatabase/database-darwin-arm64": "0.4.4", "@tursodatabase/database-linux-arm64-gnu": "0.4.4", "@tursodatabase/database-linux-x64-gnu": "0.4.4", "@tursodatabase/database-win32-x64-msvc": "0.4.4" } }, "sha512-QkQ0qLp0AY8dC9BOrIMyeyUXoFCvhlRaH+wcg013OvzYufSmDnnzg44U0tQkmnXYfUZEIp+IpADO/UeGNL0M2Q=="],
1205
1206 "@tursodatabase/database-common": ["@tursodatabase/database-common@0.4.4", "", {}, "sha512-iuSYWgBQEETjwfWOmi3E4Q++jQTb+VOPXeaKozQqW8fXtgU6ZaYUMmWbqQVMGSS9sAWZlZi9qyPVz0rkT8zoqQ=="],
1207
1208 "@tursodatabase/database-darwin-arm64": ["@tursodatabase/database-darwin-arm64@0.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nQMYrJY1X0sNgNWdbuPdbNCL9S/Ei9KLMwx3hFdyUDYJWU0h7o+kZv50QoNXB7vb7LmEBMeVWZaVEQbHd/Ionw=="],
1209
1210 "@tursodatabase/database-linux-arm64-gnu": ["@tursodatabase/database-linux-arm64-gnu@0.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-6vnnKBgAgfKhspr+nScPevOSrrnZwNJKuUbiHMSagOqNDZCzOAGDcfeC83sKd2QvZquR17l6V3tFxcFN5FnByQ=="],
1211
1212 "@tursodatabase/database-linux-x64-gnu": ["@tursodatabase/database-linux-x64-gnu@0.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-N63wK6ow6wPmD1/BjBhbrR8/Ifo6+A6zavpzgsT5YABVC4wGZBvJ1TvSz935R9n5E8eqQNY8jKiQPJLwpgOjdw=="],
1213
1214 "@tursodatabase/database-win32-x64-msvc": ["@tursodatabase/database-win32-x64-msvc@0.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-TYEl5FaUAImgZew05FvxvcFWLb1AQ+Zeyw/ApEbBDqS7mQmbz8VGs4iq04ECzJG2esfBR+zrLdDEG3X1BnaDTg=="],
1215
1198 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],1216 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
1199 1217
1200 "@types/accepts": ["@types/accepts@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ=="],1218 "@types/accepts": ["@types/accepts@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ=="],
@@ -1333,6 +1351,8 @@ · 132 unmodified lines
1333 1351
1334 "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],1352 "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
1335 1353
1354 "agentfs-sdk": ["agentfs-sdk@0.6.0", "", { "dependencies": { "@tursodatabase/database": "^0.4.0-pre.18", "@tursodatabase/database-common": "^0.4.0-pre.18", "buffer": "^6.0.3" }, "peerDependencies": { "just-bash": ">=2.0.0" }, "optionalPeers": ["just-bash"] }, "sha512-CyrrZyfJk3H+Q6feMvK/S7hlrIKykGzJ+wCq/URgt72A3tBsYHLkDF7ZGOMMu1BasT9HFrWXunyN0PXD646/ig=="],
1355
1336 "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],1356 "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
1337 1357
1338 "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],1358 "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
@@ -2403,6 +2423,8 @@ · 1064 unmodified lines
2403 2423
2404 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],2424 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
2405 2425
2426 "sql.js": ["sql.js@1.14.0", "", {}, "sha512-NXYh+kFqLiYRCNAaHD0PcbjFgXyjuolEKLMk5vRt2DgPENtF1kkNzzMlg42dUk5wIsH8MhUzsRhaUxIisoSlZQ=="],
2427
2406 "srvx": ["srvx@0.10.1", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg=="],2428 "srvx": ["srvx@0.10.1", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg=="],
2407 2429
2408 "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],2430 "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
executor/apps/web/src/components/app-shell.tsx+1−0
@@ -38,6 +38,7 @@ const NAV_ITEMS = [ · 37 unmodified lines
38 label: "Connections",38 label: "Connections",
39 matchPrefixes: ["/tools/connections", "/tools/credentials"],39 matchPrefixes: ["/tools/connections", "/tools/credentials"],
40 },40 },
41 { href: "/tools/storage", label: "Storage" },
41 { href: "/tools/policies", label: "Policies" },42 { href: "/tools/policies", label: "Policies" },
42 {43 {
43 href: "/tools/editor",44 href: "/tools/editor",
executor/apps/web/src/components/tools/storage.tsx+238−0
@@ -0,0 +1,238 @@
1"use client";
2
3import { useMemo, useState } from "react";
4import { Database, FolderOpen, Plus, Trash2, Power } from "lucide-react";
5import { Badge } from "@/components/ui/badge";
6import { Button } from "@/components/ui/button";
7import { Input } from "@/components/ui/input";
8import { Skeleton } from "@/components/ui/skeleton";
9import type { StorageDurability, StorageInstanceRecord, StorageScopeType } from "@/lib/types";
10import { cn } from "@/lib/utils";
11
12type CreateStorageArgs = {
13 scopeType: StorageScopeType;
14 durability: StorageDurability;
15 purpose?: string;
16 ttlHours?: number;
17};
18
19function prettyBytes(value?: number): string {
20 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
21 return "-";
22 }
23
24 const units = ["B", "KB", "MB", "GB", "TB"];
25 let bytes = value;
26 let index = 0;
27 while (bytes >= 1024 && index < units.length - 1) {
28 bytes /= 1024;
29 index += 1;
30 }
31 const precision = bytes >= 100 || index === 0 ? 0 : 1;
32 return `${bytes.toFixed(precision)} ${units[index]}`;
33}
34
35function asLocalDate(value?: number): string {
36 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
37 return "-";
38 }
39 return new Date(value).toLocaleString();
40}
41
42export function StoragePanel({
43 instances,
44 loading,
45 creating,
46 busyInstanceId,
47 onCreate,
48 onClose,
49 onDelete,
50}: {
51 instances: StorageInstanceRecord[];
52 loading: boolean;
53 creating: boolean;
54 busyInstanceId?: string;
55 onCreate: (args: CreateStorageArgs) => Promise<void>;
56 onClose: (instanceId: string) => Promise<void>;
57 onDelete: (instanceId: string) => Promise<void>;
58}) {
59 const [scopeType, setScopeType] = useState<StorageScopeType>("scratch");
60 const [durability, setDurability] = useState<StorageDurability>("ephemeral");
61 const [purpose, setPurpose] = useState("");
62 const [ttlHours, setTtlHours] = useState("24");
63
64 const visibleInstances = useMemo(
65 () => [...instances].sort((a, b) => b.updatedAt - a.updatedAt),
66 [instances],
67 );
68
69 const submitCreate = async () => {
70 const parsedTtl = Number.parseInt(ttlHours, 10);
71 await onCreate({
72 scopeType,
73 durability,
74 ...(purpose.trim().length > 0 ? { purpose: purpose.trim() } : {}),
75 ...(durability === "ephemeral" && Number.isFinite(parsedTtl) ? { ttlHours: parsedTtl } : {}),
76 });
77 setPurpose("");
78 };
79
80 return (
81 <section className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col rounded-none border border-border/50 bg-card/30">
82 <div className="flex items-center justify-between border-b border-border/40 px-4 py-3 sm:px-5">
83 <div className="flex items-center gap-2">
84 <div className="flex h-7 w-7 items-center justify-center border border-border/60 bg-background/80">
85 <Database className="h-4 w-4 text-muted-foreground" />
86 </div>
87 <h2 className="text-sm font-medium">Storage Instances</h2>
88 </div>
89 <Badge variant="outline" className="h-5 px-2 text-[10px] font-mono uppercase tracking-wide text-muted-foreground">
90 {visibleInstances.length}
91 </Badge>
92 </div>
93
94 <div className="border-b border-border/40 bg-background/40 px-4 py-3 sm:px-5">
95 <div className="grid gap-2 md:grid-cols-5">
96 <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
97 Scope
98 <select
99 className="h-8 rounded-none border border-border/60 bg-background px-2 text-xs text-foreground"
100 value={scopeType}
101 onChange={(event) => {
102 const next = event.target.value as StorageScopeType;
103 setScopeType(next);
104 if (next !== "scratch") {
105 setDurability("durable");
106 }
107 }}
108 >
109 <option value="scratch">scratch</option>
110 <option value="account">account</option>
111 <option value="workspace">workspace</option>
112 <option value="organization">organization</option>
113 </select>
114 </label>
115
116 <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
117 Durability
118 <select
119 className="h-8 rounded-none border border-border/60 bg-background px-2 text-xs text-foreground"
120 value={durability}
121 onChange={(event) => setDurability(event.target.value as StorageDurability)}
122 >
123 <option value="ephemeral">ephemeral</option>
124 <option value="durable">durable</option>
125 </select>
126 </label>
127
128 <label className="flex flex-col gap-1 text-[11px] text-muted-foreground md:col-span-2">
129 Purpose
130 <Input
131 value={purpose}
132 onChange={(event) => setPurpose(event.target.value)}
133 placeholder="scratch for repo indexing"
134 className="h-8 rounded-none text-xs"
135 />
136 </label>
137
138 <div className="flex items-end gap-2">
139 <label className="flex flex-1 flex-col gap-1 text-[11px] text-muted-foreground">
140 TTL (hours)
141 <Input
142 value={ttlHours}
143 onChange={(event) => setTtlHours(event.target.value)}
144 disabled={durability !== "ephemeral"}
145 className="h-8 rounded-none text-xs"
146 />
147 </label>
148 <Button size="sm" className="h-8 rounded-none text-xs" disabled={creating} onClick={() => void submitCreate()}>
149 <Plus className="mr-1.5 h-3.5 w-3.5" />
150 Create
151 </Button>
152 </div>
153 </div>
154 </div>
155
156 <div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
157 {loading ? (
158 <div className="space-y-2">
159 {Array.from({ length: 4 }).map((_, i) => (
160 <Skeleton key={i} className="h-16 rounded-none" />
161 ))}
162 </div>
163 ) : visibleInstances.length === 0 ? (
164 <div className="flex h-full min-h-[260px] flex-col items-center justify-center gap-2 border border-dashed border-border/50 bg-background/50">
165 <div className="flex h-10 w-10 items-center justify-center border border-border/60 bg-muted/40">
166 <FolderOpen className="h-5 w-5 text-muted-foreground/50" />
167 </div>
168 <p className="text-sm text-muted-foreground">No storage instances yet</p>
169 <p className="text-[11px] text-muted-foreground/70 text-center max-w-md">
170 Create a scratch instance for short-lived runs or durable instances for shared state.
171 </p>
172 </div>
173 ) : (
174 <div className="space-y-2">
175 {visibleInstances.map((instance) => {
176 const busy = busyInstanceId === instance.id;
177 return (
178 <div
179 key={instance.id}
180 className={cn(
181 "group flex items-center gap-3 border border-border/50 bg-background/70 px-3 py-2.5 transition-colors",
182 busy ? "opacity-60" : "hover:border-border hover:bg-accent/20",
183 )}
184 style={{ boxShadow: "inset 2px 0 0 0 color-mix(in oklch, var(--border) 70%, transparent)" }}
185 >
186 <div className="flex h-8 w-8 shrink-0 items-center justify-center border border-border/60 bg-muted/50 overflow-hidden">
187 <Database className="h-4 w-4 text-muted-foreground" />
188 </div>
189
190 <div className="flex-1 min-w-0">
191 <div className="mb-1 flex items-center gap-1.5 flex-wrap">
192 <span className="text-sm font-medium truncate">{instance.purpose || instance.id}</span>
193 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">{instance.scopeType}</Badge>
194 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">{instance.durability}</Badge>
195 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">{instance.provider}</Badge>
196 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">{instance.status}</Badge>
197 </div>
198
199 <p className="text-[11px] text-muted-foreground">
200 {prettyBytes(instance.sizeBytes)} - {instance.fileCount ?? "-"} inode{instance.fileCount === 1 ? "" : "s"}
201 </p>
202 <p className="mt-0.5 text-[11px] text-muted-foreground">
203 Last used {asLocalDate(instance.lastSeenAt)}
204 {instance.expiresAt ? ` - expires ${asLocalDate(instance.expiresAt)}` : ""}
205 </p>
206 </div>
207
208 <div className="flex items-center gap-1">
209 <Button
210 variant="outline"
211 size="sm"
212 className="h-7 rounded-none text-[11px]"
213 disabled={busy}
214 onClick={() => void onClose(instance.id)}
215 >
216 <Power className="mr-1 h-3 w-3" />
217 Close
218 </Button>
219 <Button
220 variant="outline"
221 size="sm"
222 className="h-7 rounded-none text-[11px] border-destructive/40 text-destructive hover:bg-destructive/10"
223 disabled={busy}
224 onClick={() => void onDelete(instance.id)}
225 >
226 <Trash2 className="mr-1 h-3 w-3" />
227 Delete
228 </Button>
229 </div>
230 </div>
231 );
232 })}
233 </div>
234 )}
235 </div>
236 </section>
237 );
238}
executor/apps/web/src/components/tools/view.tsx+101−2
@@ -10,14 +10,17 @@ import { TaskComposer } from "@/components/tasks/task-composer"; · 9 unmodified lines
10import { CredentialsPanel } from "@/components/tools/credentials";10import { CredentialsPanel } from "@/components/tools/credentials";
11import { ConnectionFormDialog } from "@/components/tools/connection/form-dialog";11import { ConnectionFormDialog } from "@/components/tools/connection/form-dialog";
12import { PoliciesPanel } from "@/components/tools/policies";12import { PoliciesPanel } from "@/components/tools/policies";
13import { StoragePanel } from "@/components/tools/storage";
13import { useSession } from "@/lib/session-context";14import { useSession } from "@/lib/session-context";
14import { useWorkspaceTools } from "@/hooks/use/workspace-tools";15import { useWorkspaceTools } from "@/hooks/use/workspace-tools";
15import { useQuery } from "convex/react";16import { useMutation, useQuery } from "convex/react";
16import { toast } from "sonner";17import { toast } from "sonner";
17import { convexApi } from "@/lib/convex-api";18import { convexApi } from "@/lib/convex-api";
18import type {19import type {
19 ToolSourceRecord,20 ToolSourceRecord,
20 CredentialRecord,21 CredentialRecord,
22 StorageDurability,
23 StorageScopeType,
21} from "@/lib/types";24} from "@/lib/types";
22import {25import {
23 parseWarningSourceName,26 parseWarningSourceName,
@@ -29,7 +32,7 @@ import type { SourceDialogMeta } from "@/components/tools/add/source-dialog"; · 5 unmodified lines
29import type { FilterApproval } from "@/components/tools/explorer-derived";32import type { FilterApproval } from "@/components/tools/explorer-derived";
30import { toolsCatalogQueryParsers } from "@/lib/url-state/tools";33import { toolsCatalogQueryParsers } from "@/lib/url-state/tools";
31 34
32type ToolsTab = "catalog" | "connections" | "policies" | "editor";35type ToolsTab = "catalog" | "connections" | "policies" | "storage" | "editor";
33const INVENTORY_REGENERATION_TOAST_ID = "tool-inventory-regeneration";36const INVENTORY_REGENERATION_TOAST_ID = "tool-inventory-regeneration";
34 37
35function parseToolsTab(pathname: string): ToolsTab {38function parseToolsTab(pathname: string): ToolsTab {
@@ -45,6 +48,9 @@ function parseToolsTab(pathname: string): ToolsTab { · 9 unmodified lines
45 if (last === "policies") {48 if (last === "policies") {
46 return "policies";49 return "policies";
47 }50 }
51 if (last === "storage") {
52 return "storage";
53 }
48 if (last === "editor" || last === "runner") {54 if (last === "editor" || last === "runner") {
49 return "editor";55 return "editor";
50 }56 }
@@ -81,6 +87,8 @@ export function ToolsView() { · 30 unmodified lines
81 const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);87 const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
82 const [connectionDialogEditing, setConnectionDialogEditing] = useState<CredentialRecord | null>(null);88 const [connectionDialogEditing, setConnectionDialogEditing] = useState<CredentialRecord | null>(null);
83 const [connectionDialogSourceKey, setConnectionDialogSourceKey] = useState<string | null>(null);89 const [connectionDialogSourceKey, setConnectionDialogSourceKey] = useState<string | null>(null);
90 const [storageMutationBusyId, setStorageMutationBusyId] = useState<string | undefined>(undefined);
91 const [storageCreateBusy, setStorageCreateBusy] = useState(false);
84 const [regenerationInFlight, setRegenerationInFlight] = useState(false);92 const [regenerationInFlight, setRegenerationInFlight] = useState(false);
85 const lastRegenerationToastMessageRef = useRef<string | null>(null);93 const lastRegenerationToastMessageRef = useRef<string | null>(null);
86 94
@@ -188,8 +196,17 @@ export function ToolsView() { · 101 unmodified lines
188 convexApi.workspace.listCredentials,196 convexApi.workspace.listCredentials,
189 workspaceQueryArgs(context),197 workspaceQueryArgs(context),
190 );198 );
199 const storageInstances = useQuery(
200 convexApi.workspace.listStorageInstances,
201 workspaceQueryArgs(context),
202 );
203 const openStorageInstance = useMutation(convexApi.workspace.openStorageInstance);
204 const closeStorageInstance = useMutation(convexApi.workspace.closeStorageInstance);
205 const deleteStorageInstance = useMutation(convexApi.workspace.deleteStorageInstance);
191 const credentialItems: CredentialRecord[] = credentials ?? [];206 const credentialItems: CredentialRecord[] = credentials ?? [];
192 const credentialsLoading = !!context && credentials === undefined;207 const credentialsLoading = !!context && credentials === undefined;
208 const storageItems = storageInstances ?? [];
209 const storageLoading = !!context && storageInstances === undefined;
193 210
194 const globalWarnings = useMemo(211 const globalWarnings = useMemo(
195 () => warnings.filter((warning) => !parseWarningSourceName(warning)),212 () => warnings.filter((warning) => !parseWarningSourceName(warning)),
@@ -370,6 +387,74 @@ export function ToolsView() { · 174 unmodified lines
370 }387 }
371 };388 };
372 389
390 const handleCreateStorageInstance = useCallback(async (args: {
391 scopeType: StorageScopeType;
392 durability: StorageDurability;
393 purpose?: string;
394 ttlHours?: number;
395 }) => {
396 if (!context || storageCreateBusy) {
397 return;
398 }
399
400 setStorageCreateBusy(true);
401 try {
402 await openStorageInstance({
403 workspaceId: context.workspaceId,
404 sessionId: context.sessionId,
405 scopeType: args.scopeType,
406 durability: args.durability,
407 purpose: args.purpose,
408 ttlHours: args.ttlHours,
409 });
410 toast.success("Storage instance created");
411 } catch (error) {
412 toast.error(error instanceof Error ? error.message : "Failed to create storage instance");
413 } finally {
414 setStorageCreateBusy(false);
415 }
416 }, [context, openStorageInstance, storageCreateBusy]);
417
418 const handleCloseStorageInstance = useCallback(async (instanceId: string) => {
419 if (!context || storageMutationBusyId) {
420 return;
421 }
422
423 setStorageMutationBusyId(instanceId);
424 try {
425 await closeStorageInstance({
426 workspaceId: context.workspaceId,
427 sessionId: context.sessionId,
428 instanceId,
429 });
430 toast.success("Storage instance closed");
431 } catch (error) {
432 toast.error(error instanceof Error ? error.message : "Failed to close storage instance");
433 } finally {
434 setStorageMutationBusyId(undefined);
435 }
436 }, [closeStorageInstance, context, storageMutationBusyId]);
437
438 const handleDeleteStorageInstance = useCallback(async (instanceId: string) => {
439 if (!context || storageMutationBusyId) {
440 return;
441 }
442
443 setStorageMutationBusyId(instanceId);
444 try {
445 await deleteStorageInstance({
446 workspaceId: context.workspaceId,
447 sessionId: context.sessionId,
448 instanceId,
449 });
450 toast.success("Storage instance deleted");
451 } catch (error) {
452 toast.error(error instanceof Error ? error.message : "Failed to delete storage instance");
453 } finally {
454 setStorageMutationBusyId(undefined);
455 }
456 }, [context, deleteStorageInstance, storageMutationBusyId]);
457
373 if (sessionLoading) {458 if (sessionLoading) {
374 return (459 return (
375 <div className="flex h-full min-h-0 flex-col">460 <div className="flex h-full min-h-0 flex-col">
@@ -472,6 +557,20 @@ export function ToolsView() { · 96 unmodified lines
472 </div>557 </div>
473 ) : null}558 ) : null}
474 559
560 {activeTab === "storage" ? (
561 <div className="flex-1 min-h-0 overflow-y-auto p-4 md:p-6 lg:p-8">
562 <StoragePanel
563 instances={storageItems}
564 loading={storageLoading}
565 creating={storageCreateBusy}
566 busyInstanceId={storageMutationBusyId}
567 onCreate={handleCreateStorageInstance}
568 onClose={handleCloseStorageInstance}
569 onDelete={handleDeleteStorageInstance}
570 />
571 </div>
572 ) : null}
573
475 <ConnectionFormDialog574 <ConnectionFormDialog
476 open={connectionDialogOpen}575 open={connectionDialogOpen}
477 onOpenChange={handleConnectionDialogOpenChange}576 onOpenChange={handleConnectionDialogOpenChange}
executor/apps/web/src/lib/types.ts+25−0
@@ -30,6 +30,10 @@ export type ToolSourceScopeType = "organization" | "workspace"; · 29 unmodified lines
30export type CredentialScopeType = "account" | "organization" | "workspace";30export type CredentialScopeType = "account" | "organization" | "workspace";
31export type ToolApprovalMode = "auto" | "required";31export type ToolApprovalMode = "auto" | "required";
32export type ToolSourceType = "mcp" | "openapi" | "graphql";32export type ToolSourceType = "mcp" | "openapi" | "graphql";
33export type StorageScopeType = "scratch" | "account" | "workspace" | "organization";
34export type StorageDurability = "ephemeral" | "durable";
35export type StorageInstanceStatus = "active" | "closed" | "deleted";
36export type StorageProvider = "agentfs-local" | "agentfs-cloudflare";
33 37
34export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";38export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
35 39
@@ -120,6 +124,27 @@ export interface ToolSourceRecord { · 84 unmodified lines
120 updatedAt: number;124 updatedAt: number;
121}125}
122 126
127export interface StorageInstanceRecord {
128 id: string;
129 scopeType: StorageScopeType;
130 durability: StorageDurability;
131 status: StorageInstanceStatus;
132 provider: StorageProvider;
133 backendKey: string;
134 organizationId: string;
135 workspaceId?: string;
136 accountId?: string;
137 createdByAccountId?: string;
138 purpose?: string;
139 sizeBytes?: number;
140 fileCount?: number;
141 createdAt: number;
142 updatedAt: number;
143 lastSeenAt: number;
144 closedAt?: number;
145 expiresAt?: number;
146}
147
123export interface ToolDescriptor {148export interface ToolDescriptor {
124 path: string;149 path: string;
125 description: string;150 description: string;
executor/apps/web/src/routeTree.gen.ts+21−0
@@ -25,6 +25,7 @@ import { Route as BillingRouteImport } from './routes/billing' · 24 unmodified lines
25import { Route as ApprovalsRouteImport } from './routes/approvals'25import { Route as ApprovalsRouteImport } from './routes/approvals'
26import { Route as IndexRouteImport } from './routes/index'26import { Route as IndexRouteImport } from './routes/index'
27import { Route as ToolsIndexRouteImport } from './routes/tools/index'27import { Route as ToolsIndexRouteImport } from './routes/tools/index'
28import { Route as ToolsStorageRouteImport } from './routes/tools/storage'
28import { Route as ToolsPoliciesRouteImport } from './routes/tools/policies'29import { Route as ToolsPoliciesRouteImport } from './routes/tools/policies'
29import { Route as ToolsEditorRouteImport } from './routes/tools/editor'30import { Route as ToolsEditorRouteImport } from './routes/tools/editor'
30import { Route as ToolsConnectionsRouteImport } from './routes/tools/connections'31import { Route as ToolsConnectionsRouteImport } from './routes/tools/connections'
@@ -118,6 +119,11 @@ const ToolsIndexRoute = ToolsIndexRouteImport.update({ · 87 unmodified lines
118 path: '/tools/',119 path: '/tools/',
119 getParentRoute: () => rootRouteImport,120 getParentRoute: () => rootRouteImport,
120} as any)121} as any)
122const ToolsStorageRoute = ToolsStorageRouteImport.update({
123 id: '/tools/storage',
124 path: '/tools/storage',
125 getParentRoute: () => rootRouteImport,
126} as any)
121const ToolsPoliciesRoute = ToolsPoliciesRouteImport.update({127const ToolsPoliciesRoute = ToolsPoliciesRouteImport.update({
122 id: '/tools/policies',128 id: '/tools/policies',
123 path: '/tools/policies',129 path: '/tools/policies',
@@ -199,6 +205,7 @@ export interface FileRoutesByFullPath { · 75 unmodified lines
199 '/tools/connections': typeof ToolsConnectionsRoute205 '/tools/connections': typeof ToolsConnectionsRoute
200 '/tools/editor': typeof ToolsEditorRoute206 '/tools/editor': typeof ToolsEditorRoute
201 '/tools/policies': typeof ToolsPoliciesRoute207 '/tools/policies': typeof ToolsPoliciesRoute
208 '/tools/storage': typeof ToolsStorageRoute
202 '/tools/': typeof ToolsIndexRoute209 '/tools/': typeof ToolsIndexRoute
203 '/api/auth/anonymous-token': typeof ApiAuthAnonymousTokenRoute210 '/api/auth/anonymous-token': typeof ApiAuthAnonymousTokenRoute
204 '/api/auth/callback': typeof ApiAuthCallbackRoute211 '/api/auth/callback': typeof ApiAuthCallbackRoute
@@ -229,6 +236,7 @@ export interface FileRoutesByTo { · 24 unmodified lines
229 '/tools/connections': typeof ToolsConnectionsRoute236 '/tools/connections': typeof ToolsConnectionsRoute
230 '/tools/editor': typeof ToolsEditorRoute237 '/tools/editor': typeof ToolsEditorRoute
231 '/tools/policies': typeof ToolsPoliciesRoute238 '/tools/policies': typeof ToolsPoliciesRoute
239 '/tools/storage': typeof ToolsStorageRoute
232 '/tools': typeof ToolsIndexRoute240 '/tools': typeof ToolsIndexRoute
233 '/api/auth/anonymous-token': typeof ApiAuthAnonymousTokenRoute241 '/api/auth/anonymous-token': typeof ApiAuthAnonymousTokenRoute
234 '/api/auth/callback': typeof ApiAuthCallbackRoute242 '/api/auth/callback': typeof ApiAuthCallbackRoute
@@ -260,6 +268,7 @@ export interface FileRoutesById { · 25 unmodified lines
260 '/tools/connections': typeof ToolsConnectionsRoute268 '/tools/connections': typeof ToolsConnectionsRoute
261 '/tools/editor': typeof ToolsEditorRoute269 '/tools/editor': typeof ToolsEditorRoute
262 '/tools/policies': typeof ToolsPoliciesRoute270 '/tools/policies': typeof ToolsPoliciesRoute
271 '/tools/storage': typeof ToolsStorageRoute
263 '/tools/': typeof ToolsIndexRoute272 '/tools/': typeof ToolsIndexRoute
264 '/api/auth/anonymous-token': typeof ApiAuthAnonymousTokenRoute273 '/api/auth/anonymous-token': typeof ApiAuthAnonymousTokenRoute
265 '/api/auth/callback': typeof ApiAuthCallbackRoute274 '/api/auth/callback': typeof ApiAuthCallbackRoute
@@ -292,6 +301,7 @@ export interface FileRouteTypes { · 26 unmodified lines
292 | '/tools/connections'301 | '/tools/connections'
293 | '/tools/editor'302 | '/tools/editor'
294 | '/tools/policies'303 | '/tools/policies'
304 | '/tools/storage'
295 | '/tools/'305 | '/tools/'
296 | '/api/auth/anonymous-token'306 | '/api/auth/anonymous-token'
297 | '/api/auth/callback'307 | '/api/auth/callback'
@@ -322,6 +332,7 @@ export interface FileRouteTypes { · 24 unmodified lines
322 | '/tools/connections'332 | '/tools/connections'
323 | '/tools/editor'333 | '/tools/editor'
324 | '/tools/policies'334 | '/tools/policies'
335 | '/tools/storage'
325 | '/tools'336 | '/tools'
326 | '/api/auth/anonymous-token'337 | '/api/auth/anonymous-token'
327 | '/api/auth/callback'338 | '/api/auth/callback'
@@ -352,6 +363,7 @@ export interface FileRouteTypes { · 24 unmodified lines
352 | '/tools/connections'363 | '/tools/connections'
353 | '/tools/editor'364 | '/tools/editor'
354 | '/tools/policies'365 | '/tools/policies'
366 | '/tools/storage'
355 | '/tools/'367 | '/tools/'
356 | '/api/auth/anonymous-token'368 | '/api/auth/anonymous-token'
357 | '/api/auth/callback'369 | '/api/auth/callback'
@@ -383,6 +395,7 @@ export interface RootRouteChildren { · 25 unmodified lines
383 ToolsConnectionsRoute: typeof ToolsConnectionsRoute395 ToolsConnectionsRoute: typeof ToolsConnectionsRoute
384 ToolsEditorRoute: typeof ToolsEditorRoute396 ToolsEditorRoute: typeof ToolsEditorRoute
385 ToolsPoliciesRoute: typeof ToolsPoliciesRoute397 ToolsPoliciesRoute: typeof ToolsPoliciesRoute
398 ToolsStorageRoute: typeof ToolsStorageRoute
386 ToolsIndexRoute: typeof ToolsIndexRoute399 ToolsIndexRoute: typeof ToolsIndexRoute
387 ApiAuthAnonymousTokenRoute: typeof ApiAuthAnonymousTokenRoute400 ApiAuthAnonymousTokenRoute: typeof ApiAuthAnonymousTokenRoute
388 ApiAuthCallbackRoute: typeof ApiAuthCallbackRoute401 ApiAuthCallbackRoute: typeof ApiAuthCallbackRoute
@@ -508,6 +521,13 @@ declare module '@tanstack/react-router' { · 119 unmodified lines
508 preLoaderRoute: typeof ToolsIndexRouteImport521 preLoaderRoute: typeof ToolsIndexRouteImport
509 parentRoute: typeof rootRouteImport522 parentRoute: typeof rootRouteImport
510 }523 }
524 '/tools/storage': {
525 id: '/tools/storage'
526 path: '/tools/storage'
527 fullPath: '/tools/storage'
528 preLoaderRoute: typeof ToolsStorageRouteImport
529 parentRoute: typeof rootRouteImport
530 }
511 '/tools/policies': {531 '/tools/policies': {
512 id: '/tools/policies'532 id: '/tools/policies'
513 path: '/tools/policies'533 path: '/tools/policies'
@@ -615,6 +635,7 @@ const rootRouteChildren: RootRouteChildren = { · 101 unmodified lines
615 ToolsConnectionsRoute: ToolsConnectionsRoute,635 ToolsConnectionsRoute: ToolsConnectionsRoute,
616 ToolsEditorRoute: ToolsEditorRoute,636 ToolsEditorRoute: ToolsEditorRoute,
617 ToolsPoliciesRoute: ToolsPoliciesRoute,637 ToolsPoliciesRoute: ToolsPoliciesRoute,
638 ToolsStorageRoute: ToolsStorageRoute,
618 ToolsIndexRoute: ToolsIndexRoute,639 ToolsIndexRoute: ToolsIndexRoute,
619 ApiAuthAnonymousTokenRoute: ApiAuthAnonymousTokenRoute,640 ApiAuthAnonymousTokenRoute: ApiAuthAnonymousTokenRoute,
620 ApiAuthCallbackRoute: ApiAuthCallbackRoute,641 ApiAuthCallbackRoute: ApiAuthCallbackRoute,
executor/apps/web/src/routes/tools/storage.tsx+17−0
@@ -0,0 +1,17 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { AppShell } from "@/components/app-shell";
3import { ToolsView } from "@/components/tools/view";
4import { normalizeToolsSearch } from "@/lib/url-state/tools";
5
6export const Route = createFileRoute("/tools/storage")({
7 validateSearch: (search: Record<string, unknown>) => normalizeToolsSearch(search),
8 component: ToolsStoragePage,
9});
10
11function ToolsStoragePage() {
12 return (
13 <AppShell>
14 <ToolsView />
15 </AppShell>
16 );
17}
executor/package.json+3−0
@@ -29,6 +29,9 @@ · 28 unmodified lines
29 "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts",29 "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts",
30 "test:e2e:install:sandbox": "bun test scripts/e2e/install.sandbox.e2e.test.ts",30 "test:e2e:install:sandbox": "bun test scripts/e2e/install.sandbox.e2e.test.ts",
31 "sandbox:install:session": "bun run scripts/e2e/install.sandbox.session.ts",31 "sandbox:install:session": "bun run scripts/e2e/install.sandbox.session.ts",
32 "smoke:storage:execute-flow:local": "bun run scripts/dev/storage/smoke.execute-flow.ts --runtime local-bun --deploy-convex",
33 "smoke:storage:execute-flow": "bun run scripts/dev/storage/smoke.execute-flow.ts --runtime cloudflare-worker-loader --deploy-convex --deploy-worker",
34 "smoke:storage:execute-flow:cloudflare": "bun run scripts/dev/storage/smoke.execute-flow.ts --runtime cloudflare-worker-loader --deploy-convex --deploy-worker",
32 "typecheck": "bunx --package @typescript/native-preview tsgo --noEmit -p tsconfig.json",35 "typecheck": "bunx --package @typescript/native-preview tsgo --noEmit -p tsconfig.json",
33 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json",36 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json",
34 "build:binary": "bun build executor.ts --compile --outfile dist/executor",37 "build:binary": "bun build executor.ts --compile --outfile dist/executor",
executor/packages/core/src/types.ts+25−0
@@ -17,6 +17,10 @@ export type CredentialScope = CredentialScopeType; · 16 unmodified lines
17export type CredentialProvider = "local-convex" | "workos-vault";17export type CredentialProvider = "local-convex" | "workos-vault";
18export type ToolApprovalMode = "auto" | "required";18export type ToolApprovalMode = "auto" | "required";
19export type ToolSourceType = "mcp" | "openapi" | "graphql";19export type ToolSourceType = "mcp" | "openapi" | "graphql";
20export type StorageScopeType = "scratch" | "account" | "workspace" | "organization";
21export type StorageDurability = "ephemeral" | "durable";
22export type StorageInstanceStatus = "active" | "closed" | "deleted";
23export type StorageProvider = "agentfs-local" | "agentfs-cloudflare";
20 24
21export type JsonSchema = Record<string, unknown>;25export type JsonSchema = Record<string, unknown>;
22 26
@@ -195,6 +199,27 @@ export interface ToolSourceRecord { · 172 unmodified lines
195 updatedAt: number;199 updatedAt: number;
196}200}
197 201
202export interface StorageInstanceRecord {
203 id: string;
204 scopeType: StorageScopeType;
205 durability: StorageDurability;
206 status: StorageInstanceStatus;
207 provider: StorageProvider;
208 backendKey: string;
209 organizationId: Id<"organizations">;
210 workspaceId?: Id<"workspaces">;
211 accountId?: Id<"accounts">;
212 createdByAccountId?: Id<"accounts">;
213 purpose?: string;
214 sizeBytes?: number;
215 fileCount?: number;
216 createdAt: number;
217 updatedAt: number;
218 lastSeenAt: number;
219 closedAt?: number;
220 expiresAt?: number;
221}
222
198export interface ToolDescriptor {223export interface ToolDescriptor {
199 path: string;224 path: string;
200 description: string;225 description: string;
executor/packages/database/convex/_generated/api.d.ts+177−0
@@ -306,6 +306,18 @@ export declare const api: { · 305 unmodified lines
306 { accountId?: string; sessionId?: string },306 { accountId?: string; sessionId?: string },
307 any307 any
308 >;308 >;
309 closeStorageInstance: FunctionReference<
310 "mutation",
311 "public",
312 { instanceId: string; sessionId?: string; workspaceId: Id<"workspaces"> },
313 any
314 >;
315 deleteStorageInstance: FunctionReference<
316 "mutation",
317 "public",
318 { instanceId: string; sessionId?: string; workspaceId: Id<"workspaces"> },
319 any
320 >;
309 deleteToolPolicyAssignment: FunctionReference<321 deleteToolPolicyAssignment: FunctionReference<
310 "mutation",322 "mutation",
311 "public",323 "public",
@@ -369,6 +381,17 @@ export declare const api: { · 57 unmodified lines
369 { sessionId?: string; workspaceId: Id<"workspaces"> },381 { sessionId?: string; workspaceId: Id<"workspaces"> },
370 any382 any
371 >;383 >;
384 listStorageInstances: FunctionReference<
385 "query",
386 "public",
387 {
388 includeDeleted?: boolean;
389 scopeType?: "scratch" | "account" | "workspace" | "organization";
390 sessionId?: string;
391 workspaceId: Id<"workspaces">;
392 },
393 any
394 >;
372 listTasks: FunctionReference<395 listTasks: FunctionReference<
373 "query",396 "query",
374 "public",397 "public",
@@ -405,6 +428,20 @@ export declare const api: { · 30 unmodified lines
405 { sessionId?: string; workspaceId: Id<"workspaces"> },428 { sessionId?: string; workspaceId: Id<"workspaces"> },
406 any429 any
407 >;430 >;
431 openStorageInstance: FunctionReference<
432 "mutation",
433 "public",
434 {
435 durability?: "ephemeral" | "durable";
436 instanceId?: string;
437 purpose?: string;
438 scopeType?: "scratch" | "account" | "workspace" | "organization";
439 sessionId?: string;
440 ttlHours?: number;
441 workspaceId: Id<"workspaces">;
442 },
443 any
444 >;
408 regenerateToolInventory: FunctionReference<445 regenerateToolInventory: FunctionReference<
409 "mutation",446 "mutation",
410 "public",447 "public",
@@ -670,6 +707,16 @@ export declare const internal: { · 259 unmodified lines
670 { accountId?: string; clientId?: string; sessionId?: string },707 { accountId?: string; clientId?: string; sessionId?: string },
671 any708 any
672 >;709 >;
710 closeStorageInstance: FunctionReference<
711 "mutation",
712 "internal",
713 {
714 accountId?: Id<"accounts">;
715 instanceId: string;
716 workspaceId: Id<"workspaces">;
717 },
718 any
719 >;
673 createApproval: FunctionReference<720 createApproval: FunctionReference<
674 "mutation",721 "mutation",
675 "internal",722 "internal",
@@ -742,6 +789,16 @@ export declare const internal: { · 66 unmodified lines
742 any789 any
743 >;790 >;
744 };791 };
792 deleteStorageInstance: FunctionReference<
793 "mutation",
794 "internal",
795 {
796 accountId?: Id<"accounts">;
797 instanceId: string;
798 workspaceId: Id<"workspaces">;
799 },
800 any
801 >;
745 deleteToolPolicyAssignment: FunctionReference<802 deleteToolPolicyAssignment: FunctionReference<
746 "mutation",803 "mutation",
747 "internal",804 "internal",
@@ -789,6 +846,16 @@ export declare const internal: { · 41 unmodified lines
789 { approvalId: string; workspaceId: Id<"workspaces"> },846 { approvalId: string; workspaceId: Id<"workspaces"> },
790 any847 any
791 >;848 >;
849 getStorageInstance: FunctionReference<
850 "query",
851 "internal",
852 {
853 accountId?: Id<"accounts">;
854 instanceId: string;
855 workspaceId: Id<"workspaces">;
856 },
857 any
858 >;
792 getTask: FunctionReference<"query", "internal", { taskId: string }, any>;859 getTask: FunctionReference<"query", "internal", { taskId: string }, any>;
793 getTaskInWorkspace: FunctionReference<860 getTaskInWorkspace: FunctionReference<
794 "query",861 "query",
@@ -831,6 +898,17 @@ export declare const internal: { · 36 unmodified lines
831 any898 any
832 >;899 >;
833 listRuntimeTargets: FunctionReference<"query", "internal", {}, any>;900 listRuntimeTargets: FunctionReference<"query", "internal", {}, any>;
901 listStorageInstances: FunctionReference<
902 "query",
903 "internal",
904 {
905 accountId?: Id<"accounts">;
906 includeDeleted?: boolean;
907 scopeType?: "scratch" | "account" | "workspace" | "organization";
908 workspaceId: Id<"workspaces">;
909 },
910 any
911 >;
834 listTaskEvents: FunctionReference<912 listTaskEvents: FunctionReference<
835 "query",913 "query",
836 "internal",914 "internal",
@@ -896,6 +974,20 @@ export declare const internal: { · 59 unmodified lines
896 { taskId: string },974 { taskId: string },
897 any975 any
898 >;976 >;
977 openStorageInstance: FunctionReference<
978 "mutation",
979 "internal",
980 {
981 accountId?: Id<"accounts">;
982 durability?: "ephemeral" | "durable";
983 instanceId?: string;
984 purpose?: string;
985 scopeType?: "scratch" | "account" | "workspace" | "organization";
986 ttlHours?: number;
987 workspaceId: Id<"workspaces">;
988 },
989 any
990 >;
899 policies: {991 policies: {
900 deleteToolPolicyAssignment: FunctionReference<992 deleteToolPolicyAssignment: FunctionReference<
901 "mutation",993 "mutation",
@@ -1018,6 +1110,77 @@ export declare const internal: { · 116 unmodified lines
1018 { approvalId: string; callId: string; taskId: string },1110 { approvalId: string; callId: string; taskId: string },
1019 any1111 any
1020 >;1112 >;
1113 storage_instances: {
1114 closeStorageInstance: FunctionReference<
1115 "mutation",
1116 "internal",
1117 {
1118 accountId?: Id<"accounts">;
1119 instanceId: string;
1120 workspaceId: Id<"workspaces">;
1121 },
1122 any
1123 >;
1124 deleteStorageInstance: FunctionReference<
1125 "mutation",
1126 "internal",
1127 {
1128 accountId?: Id<"accounts">;
1129 instanceId: string;
1130 workspaceId: Id<"workspaces">;
1131 },
1132 any
1133 >;
1134 getStorageInstance: FunctionReference<
1135 "query",
1136 "internal",
1137 {
1138 accountId?: Id<"accounts">;
1139 instanceId: string;
1140 workspaceId: Id<"workspaces">;
1141 },
1142 any
1143 >;
1144 listStorageInstances: FunctionReference<
1145 "query",
1146 "internal",
1147 {
1148 accountId?: Id<"accounts">;
1149 includeDeleted?: boolean;
1150 scopeType?: "scratch" | "account" | "workspace" | "organization";
1151 workspaceId: Id<"workspaces">;
1152 },
1153 any
1154 >;
1155 openStorageInstance: FunctionReference<
1156 "mutation",
1157 "internal",
1158 {
1159 accountId?: Id<"accounts">;
1160 durability?: "ephemeral" | "durable";
1161 instanceId?: string;
1162 purpose?: string;
1163 scopeType?: "scratch" | "account" | "workspace" | "organization";
1164 ttlHours?: number;
1165 workspaceId: Id<"workspaces">;
1166 },
1167 any
1168 >;
1169 touchStorageInstance: FunctionReference<
1170 "mutation",
1171 "internal",
1172 {
1173 accountId?: Id<"accounts">;
1174 fileCount?: number;
1175 instanceId: string;
1176 provider?: "agentfs-local" | "agentfs-cloudflare";
1177 sizeBytes?: number;
1178 status?: "active" | "closed" | "deleted";
1179 workspaceId: Id<"workspaces">;
1180 },
1181 any
1182 >;
1183 };
1021 task_events: {1184 task_events: {
1022 createTaskEvent: FunctionReference<1185 createTaskEvent: FunctionReference<
1023 "mutation",1186 "mutation",
@@ -1160,6 +1323,20 @@ export declare const internal: { · 136 unmodified lines
1160 any1323 any
1161 >;1324 >;
1162 };1325 };
1326 touchStorageInstance: FunctionReference<
1327 "mutation",
1328 "internal",
1329 {
1330 accountId?: Id<"accounts">;
1331 fileCount?: number;
1332 instanceId: string;
1333 provider?: "agentfs-local" | "agentfs-cloudflare";
1334 sizeBytes?: number;
1335 status?: "active" | "closed" | "deleted";
1336 workspaceId: Id<"workspaces">;
1337 },
1338 any
1339 >;
1163 upsertCredential: FunctionReference<1340 upsertCredential: FunctionReference<
1164 "mutation",1341 "mutation",
1165 "internal",1342 "internal",
executor/packages/database/convex/database.ts+8−0
@@ -47,4 +47,12 @@ export { · 46 unmodified lines
47 listToolSources,47 listToolSources,
48 deleteToolSource,48 deleteToolSource,
49} from "./database/tool_sources";49} from "./database/tool_sources";
50export {
51 openStorageInstance,
52 getStorageInstance,
53 listStorageInstances,
54 closeStorageInstance,
55 deleteStorageInstance,
56 touchStorageInstance,
57} from "./database/storage_instances";
50export { createTaskEvent, listTaskEvents } from "./database/task_events";58export { createTaskEvent, listTaskEvents } from "./database/task_events";
executor/packages/database/convex/database/storage_instances.ts+414−0
@@ -0,0 +1,414 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "../_generated/server";
3import type { Id } from "../_generated/dataModel";
4import { mapStorageInstance } from "../../src/database/mappers";
5import {
6 storageDurabilityValidator,
7 storageInstanceStatusValidator,
8 storageScopeTypeValidator,
9 storageProviderValidator,
10} from "../../src/database/validators";
11
12const DEFAULT_EPHEMERAL_TTL_HOURS = 24;
13const MAX_EPHEMERAL_TTL_HOURS = 24 * 30;
14
15function resolveStorageProvider(): "agentfs-local" | "agentfs-cloudflare" {
16 const raw = (process.env.AGENT_STORAGE_PROVIDER ?? "").trim().toLowerCase();
17 if (raw === "cloudflare" || raw === "agentfs-cloudflare") {
18 return "agentfs-cloudflare";
19 }
20 return "agentfs-local";
21}
22
23function clampEphemeralTtlHours(value?: number): number {
24 if (typeof value !== "number" || !Number.isFinite(value)) {
25 return DEFAULT_EPHEMERAL_TTL_HOURS;
26 }
27
28 return Math.max(1, Math.min(MAX_EPHEMERAL_TTL_HOURS, Math.floor(value)));
29}
30
31function canAccessInstance(args: {
32 workspaceId: Id<"workspaces">;
33 organizationId: Id<"organizations">;
34 accountId?: Id<"accounts">;
35 doc: {
36 scopeType: "scratch" | "account" | "workspace" | "organization";
37 organizationId: Id<"organizations">;
38 workspaceId?: Id<"workspaces">;
39 accountId?: Id<"accounts">;
40 };
41}): boolean {
42 if (args.doc.organizationId !== args.organizationId) {
43 return false;
44 }
45
46 if (args.doc.scopeType === "workspace" || args.doc.scopeType === "scratch") {
47 return args.doc.workspaceId === args.workspaceId;
48 }
49
50 if (args.doc.scopeType === "account") {
51 return Boolean(args.accountId && args.doc.accountId && args.accountId === args.doc.accountId);
52 }
53
54 return true;
55}
56
57function isExpired(doc: { expiresAt?: number }, now = Date.now()): boolean {
58 return typeof doc.expiresAt === "number" && Number.isFinite(doc.expiresAt) && doc.expiresAt <= now;
59}
60
61export const openStorageInstance = internalMutation({
62 args: {
63 workspaceId: v.id("workspaces"),
64 accountId: v.optional(v.id("accounts")),
65 instanceId: v.optional(v.string()),
66 scopeType: v.optional(storageScopeTypeValidator),
67 durability: v.optional(storageDurabilityValidator),
68 purpose: v.optional(v.string()),
69 ttlHours: v.optional(v.number()),
70 },
71 handler: async (ctx, args) => {
72 const workspace = await ctx.db.get(args.workspaceId);
73 if (!workspace) {
74 throw new Error(`Workspace not found: ${args.workspaceId}`);
75 }
76
77 const now = Date.now();
78 const organizationId = workspace.organizationId;
79 const requestedInstanceId = args.instanceId?.trim() ?? "";
80 if (requestedInstanceId.length > 0) {
81 const existing = await ctx.db
82 .query("storageInstances")
83 .withIndex("by_instance_id", (q) => q.eq("instanceId", requestedInstanceId))
84 .unique();
85 if (!existing) {
86 throw new Error(`Storage instance not found: ${requestedInstanceId}`);
87 }
88
89 if (!canAccessInstance({
90 workspaceId: args.workspaceId,
91 organizationId,
92 accountId: args.accountId,
93 doc: existing,
94 })) {
95 throw new Error("Storage instance is not accessible in this workspace context");
96 }
97
98 if (existing.status === "deleted") {
99 throw new Error(`Storage instance has been deleted: ${requestedInstanceId}`);
100 }
101
102 if (isExpired(existing, now)) {
103 await ctx.db.patch(existing._id, {
104 status: "closed",
105 closedAt: now,
106 updatedAt: now,
107 });
108 throw new Error(`Storage instance has expired: ${requestedInstanceId}`);
109 }
110
111 const nextStatus = existing.status === "closed" ? "active" : existing.status;
112 await ctx.db.patch(existing._id, {
113 status: nextStatus,
114 updatedAt: now,
115 lastSeenAt: now,
116 });
117
118 const refreshed = await ctx.db
119 .query("storageInstances")
120 .withIndex("by_instance_id", (q) => q.eq("instanceId", requestedInstanceId))
121 .unique();
122 if (!refreshed) {
123 throw new Error(`Failed to reopen storage instance: ${requestedInstanceId}`);
124 }
125
126 return mapStorageInstance(refreshed);
127 }
128
129 const scopeType = args.scopeType ?? "scratch";
130 if (scopeType === "account" && !args.accountId) {
131 throw new Error("accountId is required for account-scoped storage instances");
132 }
133
134 const durability = args.durability
135 ?? (scopeType === "scratch" ? "ephemeral" : "durable");
136 const ttlHours = clampEphemeralTtlHours(args.ttlHours);
137 const expiresAt = durability === "ephemeral"
138 ? now + ttlHours * 60 * 60 * 1000
139 : undefined;
140 const instanceId = `inst_${crypto.randomUUID()}`;
141 const provider = resolveStorageProvider();
142 const backendKey = `${organizationId}:${instanceId}`;
143 const purpose = args.purpose?.trim();
144
145 await ctx.db.insert("storageInstances", {
146 instanceId,
147 scopeType,
148 durability,
149 status: "active",
150 provider,
151 backendKey,
152 organizationId,
153 workspaceId: scopeType === "workspace" || scopeType === "scratch" ? args.workspaceId : undefined,
154 accountId: scopeType === "account" || scopeType === "scratch" ? args.accountId : undefined,
155 createdByAccountId: args.accountId,
156 purpose: purpose && purpose.length > 0 ? purpose : undefined,
157 createdAt: now,
158 updatedAt: now,
159 lastSeenAt: now,
160 expiresAt,
161 });
162
163 const created = await ctx.db
164 .query("storageInstances")
165 .withIndex("by_instance_id", (q) => q.eq("instanceId", instanceId))
166 .unique();
167 if (!created) {
168 throw new Error("Failed to create storage instance");
169 }
170
171 return mapStorageInstance(created);
172 },
173});
174
175export const getStorageInstance = internalQuery({
176 args: {
177 workspaceId: v.id("workspaces"),
178 accountId: v.optional(v.id("accounts")),
179 instanceId: v.string(),
180 },
181 handler: async (ctx, args) => {
182 const workspace = await ctx.db.get(args.workspaceId);
183 if (!workspace) {
184 return null;
185 }
186
187 const doc = await ctx.db
188 .query("storageInstances")
189 .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
190 .unique();
191 if (!doc) {
192 return null;
193 }
194
195 if (!canAccessInstance({
196 workspaceId: args.workspaceId,
197 organizationId: workspace.organizationId,
198 accountId: args.accountId,
199 doc,
200 })) {
201 return null;
202 }
203
204 if (doc.status === "deleted" || isExpired(doc)) {
205 return null;
206 }
207
208 return mapStorageInstance(doc);
209 },
210});
211
212export const listStorageInstances = internalQuery({
213 args: {
214 workspaceId: v.id("workspaces"),
215 accountId: v.optional(v.id("accounts")),
216 scopeType: v.optional(storageScopeTypeValidator),
217 includeDeleted: v.optional(v.boolean()),
218 },
219 handler: async (ctx, args) => {
220 const workspace = await ctx.db.get(args.workspaceId);
221 if (!workspace) {
222 return [];
223 }
224
225 const organizationId = workspace.organizationId;
226 const [workspaceDocs, organizationDocs, accountDocs] = await Promise.all([
227 ctx.db
228 .query("storageInstances")
229 .withIndex("by_workspace_updated", (q) => q.eq("workspaceId", args.workspaceId))
230 .order("desc")
231 .collect(),
232 ctx.db
233 .query("storageInstances")
234 .withIndex("by_org_scope_updated", (q) => q.eq("organizationId", organizationId).eq("scopeType", "organization"))
235 .order("desc")
236 .collect(),
237 args.accountId
238 ? ctx.db
239 .query("storageInstances")
240 .withIndex("by_org_account_updated", (q) => q.eq("organizationId", organizationId).eq("accountId", args.accountId))
241 .order("desc")
242 .collect()
243 : Promise.resolve([]),
244 ]);
245
246 const deduped = [...workspaceDocs, ...organizationDocs, ...accountDocs]
247 .filter((doc, index, docs) => docs.findIndex((candidate) => candidate.instanceId === doc.instanceId) === index)
248 .filter((doc) => canAccessInstance({
249 workspaceId: args.workspaceId,
250 organizationId,
251 accountId: args.accountId,
252 doc,
253 }))
254 .filter((doc) => args.scopeType ? doc.scopeType === args.scopeType : true)
255 .filter((doc) => args.includeDeleted ? true : (doc.status !== "deleted" && !isExpired(doc)))
256 .sort((a, b) => b.updatedAt - a.updatedAt);
257
258 return deduped.map(mapStorageInstance);
259 },
260});
261
262export const closeStorageInstance = internalMutation({
263 args: {
264 workspaceId: v.id("workspaces"),
265 accountId: v.optional(v.id("accounts")),
266 instanceId: v.string(),
267 },
268 handler: async (ctx, args) => {
269 const workspace = await ctx.db.get(args.workspaceId);
270 if (!workspace) {
271 return null;
272 }
273
274 const doc = await ctx.db
275 .query("storageInstances")
276 .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
277 .unique();
278 if (!doc) {
279 return null;
280 }
281
282 if (!canAccessInstance({
283 workspaceId: args.workspaceId,
284 organizationId: workspace.organizationId,
285 accountId: args.accountId,
286 doc,
287 })) {
288 throw new Error("Storage instance is not accessible in this workspace context");
289 }
290
291 const now = Date.now();
292 await ctx.db.patch(doc._id, {
293 status: "closed",
294 closedAt: now,
295 updatedAt: now,
296 lastSeenAt: now,
297 });
298
299 const refreshed = await ctx.db
300 .query("storageInstances")
301 .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
302 .unique();
303 return refreshed ? mapStorageInstance(refreshed) : null;
304 },
305});
306
307export const deleteStorageInstance = internalMutation({
308 args: {
309 workspaceId: v.id("workspaces"),
310 accountId: v.optional(v.id("accounts")),
311 instanceId: v.string(),
312 },
313 handler: async (ctx, args) => {
314 const workspace = await ctx.db.get(args.workspaceId);
315 if (!workspace) {
316 return null;
317 }
318
319 const doc = await ctx.db
320 .query("storageInstances")
321 .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
322 .unique();
323 if (!doc) {
324 return null;
325 }
326
327 if (!canAccessInstance({
328 workspaceId: args.workspaceId,
329 organizationId: workspace.organizationId,
330 accountId: args.accountId,
331 doc,
332 })) {
333 throw new Error("Storage instance is not accessible in this workspace context");
334 }
335
336 if (
337 (doc.scopeType === "organization" || doc.scopeType === "workspace")
338 && doc.createdByAccountId
339 && args.accountId
340 && doc.createdByAccountId !== args.accountId
341 ) {
342 throw new Error("Only the instance creator can delete shared storage instances");
343 }
344
345 const now = Date.now();
346 await ctx.db.patch(doc._id, {
347 status: "deleted",
348 closedAt: now,
349 updatedAt: now,
350 lastSeenAt: now,
351 });
352
353 const refreshed = await ctx.db
354 .query("storageInstances")
355 .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
356 .unique();
357 return refreshed ? mapStorageInstance(refreshed) : null;
358 },
359});
360
361export const touchStorageInstance = internalMutation({
362 args: {
363 workspaceId: v.id("workspaces"),
364 accountId: v.optional(v.id("accounts")),
365 instanceId: v.string(),
366 status: v.optional(storageInstanceStatusValidator),
367 sizeBytes: v.optional(v.number()),
368 fileCount: v.optional(v.number()),
369 provider: v.optional(storageProviderValidator),
370 },
371 handler: async (ctx, args) => {
372 const workspace = await ctx.db.get(args.workspaceId);
373 if (!workspace) {
374 return null;
375 }
376
377 const doc = await ctx.db
378 .query("storageInstances")
379 .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
380 .unique();
381 if (!doc) {
382 return null;
383 }
384
385 if (!canAccessInstance({
386 workspaceId: args.workspaceId,
387 organizationId: workspace.organizationId,
388 accountId: args.accountId,
389 doc,
390 })) {
391 return null;
392 }
393
394 if (isExpired(doc)) {
395 return null;
396 }
397
398 const now = Date.now();
399 await ctx.db.patch(doc._id, {
400 updatedAt: now,
401 lastSeenAt: now,
402 ...(args.status ? { status: args.status } : {}),
403 ...(typeof args.sizeBytes === "number" ? { sizeBytes: args.sizeBytes } : {}),
404 ...(typeof args.fileCount === "number" ? { fileCount: args.fileCount } : {}),
405 ...(args.provider ? { provider: args.provider } : {}),
406 });
407
408 const refreshed = await ctx.db
409 .query("storageInstances")
410 .withIndex("by_instance_id", (q) => q.eq("instanceId", args.instanceId))
411 .unique();
412 return refreshed ? mapStorageInstance(refreshed) : null;
413 },
414});
executor/packages/database/convex/schema.ts+33−0
@@ -66,6 +66,10 @@ const toolRoleSelectorType = v.union( · 65 unmodified lines
66const toolRoleBindingStatus = v.union(v.literal("active"), v.literal("disabled"));66const toolRoleBindingStatus = v.union(v.literal("active"), v.literal("disabled"));
67const toolSourceScopeType = v.union(v.literal("organization"), v.literal("workspace"));67const toolSourceScopeType = v.union(v.literal("organization"), v.literal("workspace"));
68const credentialScopeType = v.union(v.literal("account"), v.literal("organization"), v.literal("workspace"));68const credentialScopeType = v.union(v.literal("account"), v.literal("organization"), v.literal("workspace"));
69const storageScopeType = v.union(v.literal("scratch"), v.literal("account"), v.literal("workspace"), v.literal("organization"));
70const storageDurability = v.union(v.literal("ephemeral"), v.literal("durable"));
71const storageInstanceStatus = v.union(v.literal("active"), v.literal("closed"), v.literal("deleted"));
72const storageProvider = v.union(v.literal("agentfs-local"), v.literal("agentfs-cloudflare"));
69const credentialProvider = v.union(73const credentialProvider = v.union(
70 v.literal("local-convex"),74 v.literal("local-convex"),
71 v.literal("workos-vault"),75 v.literal("workos-vault"),
@@ -565,6 +569,35 @@ export default defineSchema({ · 493 unmodified lines
565 })569 })
566 .index("by_workspace_build", ["workspaceId", "buildId"]),570 .index("by_workspace_build", ["workspaceId", "buildId"]),
567 571
572 // Durable and ephemeral storage instances used by filesystem/kv/sql tools.
573 // Rows are scope-aware and can be shared across account/workspace/org contexts.
574 storageInstances: defineTable({
575 instanceId: v.string(),
576 scopeType: storageScopeType,
577 durability: storageDurability,
578 status: storageInstanceStatus,
579 provider: storageProvider,
580 backendKey: v.string(),
581 organizationId: v.id("organizations"),
582 workspaceId: v.optional(v.id("workspaces")),
583 accountId: v.optional(v.id("accounts")),
584 createdByAccountId: v.optional(v.id("accounts")),
585 purpose: v.optional(v.string()),
586 sizeBytes: v.optional(v.number()),
587 fileCount: v.optional(v.number()),
588 createdAt: v.number(),
589 updatedAt: v.number(),
590 lastSeenAt: v.number(),
591 closedAt: v.optional(v.number()),
592 expiresAt: v.optional(v.number()),
593 })
594 .index("by_instance_id", ["instanceId"])
595 .index("by_workspace_updated", ["workspaceId", "updatedAt"])
596 .index("by_workspace_status_updated", ["workspaceId", "status", "updatedAt"])
597 .index("by_org_scope_updated", ["organizationId", "scopeType", "updatedAt"])
598 .index("by_org_account_updated", ["organizationId", "accountId", "updatedAt"])
599 .index("by_org_expires", ["organizationId", "expiresAt"]),
600
568 // Anonymous session linkage.601 // Anonymous session linkage.
569 // Used to map an unauthenticated/anonymous account to a backing `accounts` row and a602 // Used to map an unauthenticated/anonymous account to a backing `accounts` row and a
570 // workspace.603 // workspace.
executor/packages/database/convex/workspace.ts+71−0
@@ -10,6 +10,8 @@ import { · 9 unmodified lines
10 policyEffectValidator,10 policyEffectValidator,
11 policyMatchTypeValidator,11 policyMatchTypeValidator,
12 policyScopeTypeValidator,12 policyScopeTypeValidator,
13 storageDurabilityValidator,
14 storageScopeTypeValidator,
13 toolRoleBindingStatusValidator,15 toolRoleBindingStatusValidator,
14 toolRoleSelectorTypeValidator,16 toolRoleSelectorTypeValidator,
15 toolSourceScopeTypeValidator,17 toolSourceScopeTypeValidator,
@@ -402,6 +404,75 @@ export const listToolSources = workspaceQuery({ · 386 unmodified lines
402 },404 },
403});405});
404 406
407export const openStorageInstance = workspaceMutation({
408 method: "POST",
409 args: {
410 instanceId: v.optional(v.string()),
411 scopeType: v.optional(storageScopeTypeValidator),
412 durability: v.optional(storageDurabilityValidator),
413 purpose: v.optional(v.string()),
414 ttlHours: v.optional(v.number()),
415 },
416 handler: async (ctx, args) => {
417 const scopeType = args.scopeType ?? "scratch";
418 if (scopeType === "organization" || scopeType === "workspace") {
419 if (!isAdminRole(ctx.organizationMembership.role)) {
420 throw new Error("Only workspace admins can open workspace or organization storage instances");
421 }
422 }
423
424 return await ctx.runMutation(internal.database.openStorageInstance, {
425 workspaceId: ctx.workspaceId,
426 accountId: ctx.account._id,
427 ...args,
428 });
429 },
430});
431
432export const listStorageInstances = workspaceQuery({
433 method: "GET",
434 args: {
435 scopeType: v.optional(storageScopeTypeValidator),
436 includeDeleted: v.optional(v.boolean()),
437 },
438 handler: async (ctx, args) => {
439 return await ctx.runQuery(internal.database.listStorageInstances, {
440 workspaceId: ctx.workspaceId,
441 accountId: ctx.account._id,
442 ...args,
443 });
444 },
445});
446
447export const closeStorageInstance = workspaceMutation({
448 method: "POST",
449 args: {
450 instanceId: v.string(),
451 },
452 handler: async (ctx, args) => {
453 return await ctx.runMutation(internal.database.closeStorageInstance, {
454 workspaceId: ctx.workspaceId,
455 accountId: ctx.account._id,
456 instanceId: args.instanceId,
457 });
458 },
459});
460
461export const deleteStorageInstance = workspaceMutation({
462 method: "POST",
463 requireAdmin: true,
464 args: {
465 instanceId: v.string(),
466 },
467 handler: async (ctx, args) => {
468 return await ctx.runMutation(internal.database.deleteStorageInstance, {
469 workspaceId: ctx.workspaceId,
470 accountId: ctx.account._id,
471 instanceId: args.instanceId,
472 });
473 },
474});
475
405export const getToolInventoryProgress = workspaceQuery({476export const getToolInventoryProgress = workspaceQuery({
406 method: "GET",477 method: "GET",
407 args: {},478 args: {},
executor/packages/database/package.json+4−0
@@ -7,6 +7,10 @@ · 6 unmodified lines
7 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json",7 "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json",
8 "clean": "rm -rf node_modules .turbo"8 "clean": "rm -rf node_modules .turbo"
9 },9 },
10 "dependencies": {
11 "convex": "^1.31.7",
12 "sql.js": "^1.14.0"
13 },
10 "main": "./convex/_generated/api.js",14 "main": "./convex/_generated/api.js",
11 "types": "./convex/_generated/api.d.ts"15 "types": "./convex/_generated/api.d.ts"
12}16}
executor/packages/database/src/database/mappers.ts+23−0
@@ -137,3 +137,26 @@ export function mapSource(doc: Doc<"toolSources">) { · 136 unmodified lines
137 updatedAt: doc.updatedAt,137 updatedAt: doc.updatedAt,
138 };138 };
139}139}
140
141export function mapStorageInstance(doc: Doc<"storageInstances">) {
142 return {
143 id: doc.instanceId,
144 scopeType: doc.scopeType,
145 durability: doc.durability,
146 status: doc.status,
147 provider: doc.provider,
148 backendKey: doc.backendKey,
149 organizationId: doc.organizationId,
150 workspaceId: doc.workspaceId,
151 accountId: doc.accountId,
152 createdByAccountId: doc.createdByAccountId,
153 purpose: doc.purpose,
154 sizeBytes: doc.sizeBytes,
155 fileCount: doc.fileCount,
156 createdAt: doc.createdAt,
157 updatedAt: doc.updatedAt,
158 lastSeenAt: doc.lastSeenAt,
159 closedAt: doc.closedAt,
160 expiresAt: doc.expiresAt,
161 };
162}
executor/packages/database/src/database/validators.ts+13−0
@@ -49,3 +49,16 @@ export const credentialProviderValidator = v.union( · 48 unmodified lines
49);49);
50 50
51export const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));51export const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
52
53export const storageScopeTypeValidator = v.union(
54 v.literal("scratch"),
55 v.literal("account"),
56 v.literal("workspace"),
57 v.literal("organization"),
58);
59
60export const storageDurabilityValidator = v.union(v.literal("ephemeral"), v.literal("durable"));
61
62export const storageInstanceStatusValidator = v.union(v.literal("active"), v.literal("closed"), v.literal("deleted"));
63
64export const storageProviderValidator = v.union(v.literal("agentfs-local"), v.literal("agentfs-cloudflare"));
executor/packages/database/src/runtime/base_tools.ts+202−30
@@ -7,50 +7,222 @@ import { · 6 unmodified lines
7 discoverInputJsonSchema,7 discoverInputJsonSchema,
8 discoverOutputJsonSchema,8 discoverOutputJsonSchema,
9} from "./discovery_tool_contracts";9} from "./discovery_tool_contracts";
10import {
11 fsMkdirInputJsonSchema,
12 fsMkdirOutputJsonSchema,
13 fsReadInputJsonSchema,
14 fsReadOutputJsonSchema,
15 fsReaddirInputJsonSchema,
16 fsReaddirOutputJsonSchema,
17 fsRemoveInputJsonSchema,
18 fsRemoveOutputJsonSchema,
19 fsStatInputJsonSchema,
20 fsStatOutputJsonSchema,
21 fsWriteInputJsonSchema,
22 fsWriteOutputJsonSchema,
23 kvDeleteInputJsonSchema,
24 kvDeleteOutputJsonSchema,
25 kvGetInputJsonSchema,
26 kvGetOutputJsonSchema,
27 kvListInputJsonSchema,
28 kvListOutputJsonSchema,
29 kvSetInputJsonSchema,
30 kvSetOutputJsonSchema,
31 sqliteQueryInputJsonSchema,
32 sqliteQueryOutputJsonSchema,
33 storageCloseInputJsonSchema,
34 storageCloseOutputJsonSchema,
35 storageDeleteInputJsonSchema,
36 storageDeleteOutputJsonSchema,
37 storageListInputJsonSchema,
38 storageListOutputJsonSchema,
39 storageOpenInputJsonSchema,
40 storageOpenOutputJsonSchema,
41} from "./storage_tool_contracts";
10 42
11export const baseTools = new Map<string, ToolDefinition>();43export const baseTools = new Map<string, ToolDefinition>();
12 44
45function registerSystemTool(
46 path: string,
47 description: string,
48 typing: {
49 inputSchema?: Record<string, unknown>;
50 outputSchema?: Record<string, unknown>;
51 },
52 approval: "auto" | "required" = "auto",
53) {
54 baseTools.set(path, {
55 path,
56 source: "system",
57 approval,
58 description,
59 typing,
60 run: async () => {
61 throw new Error(`${path} is handled by the server tool invocation pipeline`);
62 },
63 });
64}
65
13// Built-in system tools are resolved server-side.66// Built-in system tools are resolved server-side.
14// Their execution is handled in the Convex tool invocation pipeline.67// Their execution is handled in the Convex tool invocation pipeline.
15baseTools.set("discover", {68registerSystemTool(
16 path: "discover",69 "discover",
17 source: "system",70 "Search available tools by keyword. Returns compact input/output hints by default; set includeSchemas=true for exact JSON Schemas.",
18 approval: "auto",71 {
19 description:
20 "Search available tools by keyword. Returns compact input/output hints by default; set includeSchemas=true for exact JSON Schemas.",
21 typing: {
22 inputSchema: discoverInputJsonSchema,72 inputSchema: discoverInputJsonSchema,
23 outputSchema: discoverOutputJsonSchema,73 outputSchema: discoverOutputJsonSchema,
24 },74 },
25 run: async () => {75);
26 throw new Error("discover is handled by the server tool invocation pipeline");
27 },
28});
29 76
30baseTools.set("catalog.namespaces", {77registerSystemTool(
31 path: "catalog.namespaces",78 "catalog.namespaces",
32 source: "system",79 "List available tool namespaces with counts and sample callable paths.",
33 approval: "auto",80 {
34 description: "List available tool namespaces with counts and sample callable paths.",
35 typing: {
36 inputSchema: catalogNamespacesInputJsonSchema,81 inputSchema: catalogNamespacesInputJsonSchema,
37 outputSchema: catalogNamespacesOutputJsonSchema,82 outputSchema: catalogNamespacesOutputJsonSchema,
38 },83 },
39 run: async () => {84);
40 throw new Error("catalog.namespaces is handled by the server tool invocation pipeline");
41 },
42});
43 85
44baseTools.set("catalog.tools", {86registerSystemTool(
45 path: "catalog.tools",87 "catalog.tools",
46 source: "system",88 "List tools with compact hints by default. Supports namespace/query filters and optional includeSchemas for exact JSON Schemas.",
47 approval: "auto",89 {
48 description: "List tools with compact hints by default. Supports namespace/query filters and optional includeSchemas for exact JSON Schemas.",
49 typing: {
50 inputSchema: catalogToolsInputJsonSchema,90 inputSchema: catalogToolsInputJsonSchema,
51 outputSchema: catalogToolsOutputJsonSchema,91 outputSchema: catalogToolsOutputJsonSchema,
52 },92 },
53 run: async () => {93);
54 throw new Error("catalog.tools is handled by the server tool invocation pipeline");94
95registerSystemTool(
96 "storage.open",
97 "Open an existing storage instance or create a new one when instanceId is omitted.",
98 {
99 inputSchema: storageOpenInputJsonSchema,
100 outputSchema: storageOpenOutputJsonSchema,
101 },
102);
103
104registerSystemTool(
105 "storage.list",
106 "List accessible storage instances for the current workspace context.",
107 {
108 inputSchema: storageListInputJsonSchema,
109 outputSchema: storageListOutputJsonSchema,
110 },
111);
112
113registerSystemTool(
114 "storage.close",
115 "Mark a storage instance as closed without deleting its contents.",
116 {
117 inputSchema: storageCloseInputJsonSchema,
118 outputSchema: storageCloseOutputJsonSchema,
119 },
120);
121
122registerSystemTool(
123 "storage.delete",
124 "Delete a storage instance and its backing data.",
125 {
126 inputSchema: storageDeleteInputJsonSchema,
127 outputSchema: storageDeleteOutputJsonSchema,
128 },
129);
130
131registerSystemTool(
132 "fs.read",
133 "Read a file from a storage instance.",
134 {
135 inputSchema: fsReadInputJsonSchema,
136 outputSchema: fsReadOutputJsonSchema,
137 },
138);
139
140registerSystemTool(
141 "fs.write",
142 "Write file contents into a storage instance.",
143 {
144 inputSchema: fsWriteInputJsonSchema,
145 outputSchema: fsWriteOutputJsonSchema,
146 },
147);
148
149registerSystemTool(
150 "fs.readdir",
151 "List directory entries in a storage instance.",
152 {
153 inputSchema: fsReaddirInputJsonSchema,
154 outputSchema: fsReaddirOutputJsonSchema,
155 },
156);
157
158registerSystemTool(
159 "fs.stat",
160 "Get metadata for a filesystem path in storage.",
161 {
162 inputSchema: fsStatInputJsonSchema,
163 outputSchema: fsStatOutputJsonSchema,
164 },
165);
166
167registerSystemTool(
168 "fs.mkdir",
169 "Create a directory in a storage instance.",
170 {
171 inputSchema: fsMkdirInputJsonSchema,
172 outputSchema: fsMkdirOutputJsonSchema,
173 },
174);
175
176registerSystemTool(
177 "fs.remove",
178 "Remove a file or directory from a storage instance.",
179 {
180 inputSchema: fsRemoveInputJsonSchema,
181 outputSchema: fsRemoveOutputJsonSchema,
182 },
183);
184
185registerSystemTool(
186 "kv.get",
187 "Read a key-value entry from a storage instance.",
188 {
189 inputSchema: kvGetInputJsonSchema,
190 outputSchema: kvGetOutputJsonSchema,
191 },
192);
193
194registerSystemTool(
195 "kv.set",
196 "Write a key-value entry into a storage instance.",
197 {
198 inputSchema: kvSetInputJsonSchema,
199 outputSchema: kvSetOutputJsonSchema,
200 },
201);
202
203registerSystemTool(
204 "kv.list",
205 "List key-value entries by prefix from a storage instance.",
206 {
207 inputSchema: kvListInputJsonSchema,
208 outputSchema: kvListOutputJsonSchema,
209 },
210);
211
212registerSystemTool(
213 "kv.delete",
214 "Delete a key-value entry from a storage instance.",
215 {
216 inputSchema: kvDeleteInputJsonSchema,
217 outputSchema: kvDeleteOutputJsonSchema,
218 },
219);
220
221registerSystemTool(
222 "sqlite.query",
223 "Run SQL queries against the storage instance database.",
224 {
225 inputSchema: sqliteQueryInputJsonSchema,
226 outputSchema: sqliteQueryOutputJsonSchema,
55 },227 },
56});228);
executor/packages/database/src/runtime/storage_provider.ts+641−0
@@ -0,0 +1,641 @@
1import {
2 lstat,
3 mkdir,
4 readFile,
5 readdir,
6 rm,
7 stat,
8 writeFile,
9} from "node:fs/promises";
10import { dirname, join, normalize, resolve, sep } from "node:path";
11import type { StorageInstanceRecord, StorageProvider as StorageProviderId } from "../../../core/src/types";
12
13export type StorageEncoding = "utf8" | "base64";
14
15export interface StorageDirectoryEntry {
16 name: string;
17 type: "file" | "directory" | "symlink" | "unknown";
18 size?: number;
19 mtime?: number;
20}
21
22export interface StorageStatResult {
23 exists: boolean;
24 type?: "file" | "directory" | "symlink" | "unknown";
25 size?: number;
26 mode?: number;
27 mtime?: number;
28 ctime?: number;
29}
30
31export interface StorageUsage {
32 sizeBytes?: number;
33 fileCount?: number;
34}
35
36export interface StorageSqlResult {
37 mode: "read" | "write";
38 rows?: Record<string, unknown>[];
39 rowCount: number;
40 changes?: number;
41}
42
43export interface StorageProvider {
44 readFile(instance: StorageInstanceRecord, path: string, encoding: StorageEncoding): Promise<{ content: string; bytes: number }>;
45 writeFile(
46 instance: StorageInstanceRecord,
47 path: string,
48 content: string,
49 encoding: StorageEncoding,
50 ): Promise<{ bytesWritten: number }>;
51 readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]>;
52 stat(instance: StorageInstanceRecord, path: string): Promise<StorageStatResult>;
53 mkdir(instance: StorageInstanceRecord, path: string): Promise<void>;
54 remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void>;
55 kvGet(instance: StorageInstanceRecord, key: string): Promise<unknown>;
56 kvSet(instance: StorageInstanceRecord, key: string, value: unknown): Promise<void>;
57 kvList(instance: StorageInstanceRecord, prefix: string, limit: number): Promise<Array<{ key: string; value: unknown }>>;
58 kvDelete(instance: StorageInstanceRecord, key: string): Promise<void>;
59 sqliteQuery(
60 instance: StorageInstanceRecord,
61 args: { sql: string; params: Array<string | number | boolean | null>; mode: "read" | "write"; maxRows: number },
62 ): Promise<StorageSqlResult>;
63 usage(instance: StorageInstanceRecord): Promise<StorageUsage>;
64 deleteInstance(instance: StorageInstanceRecord): Promise<void>;
65}
66
67type SqlJsDatabase = {
68 run: (sql: string, params?: Array<string | number | null>) => void;
69 exec: (sql: string, params?: Array<string | number | null>) => Array<{
70 columns: string[];
71 values: Array<Array<string | number | null>>;
72 }>;
73 export: () => Uint8Array;
74 getRowsModified: () => number;
75 close: () => void;
76};
77
78type SqlJsModule = {
79 Database: new (data?: Uint8Array) => SqlJsDatabase;
80};
81
82let sqlJsModulePromise: Promise<SqlJsModule> | null = null;
83
84async function loadSqlJsModule(): Promise<SqlJsModule> {
85 if (!sqlJsModulePromise) {
86 sqlJsModulePromise = (async () => {
87 const module = await import("sql.js/dist/sql-asm.js") as { default?: (options?: unknown) => Promise<SqlJsModule> };
88 const initialize = module.default;
89 if (typeof initialize !== "function") {
90 throw new Error("Failed to load sql.js asm module");
91 }
92
93 return await initialize();
94 })();
95 }
96
97 return await sqlJsModulePromise;
98}
99
100function normalizeBackendKey(backendKey: string): string {
101 const trimmed = backendKey.trim();
102 const fallback = `instance_${crypto.randomUUID()}`;
103 const safe = (trimmed.length > 0 ? trimmed : fallback).replace(/[^a-zA-Z0-9._-]+/g, "_");
104 return safe.length > 0 ? safe : fallback;
105}
106
107function normalizeStoragePath(baseDir: string, backendKey: string): string {
108 const key = normalizeBackendKey(backendKey);
109 return join(baseDir, key);
110}
111
112function normalizeRelativeFsPath(path: string): string {
113 const candidate = (path ?? "").trim();
114 const withoutLeadingSlash = candidate.replace(/^\/+/, "");
115 const normalized = normalize(withoutLeadingSlash);
116 if (!normalized || normalized === ".") {
117 return "";
118 }
119
120 if (normalized === ".." || normalized.startsWith(`..${sep}`)) {
121 throw new Error(`Path escapes storage root: ${path}`);
122 }
123
124 return normalized;
125}
126
127function toDbValue(value: string | number | boolean | null): string | number | null {
128 if (typeof value === "boolean") {
129 return value ? 1 : 0;
130 }
131 return value;
132}
133
134function rowsFromSqlResult(result: Array<{ columns: string[]; values: Array<Array<string | number | null>> }>): Record<string, unknown>[] {
135 const first = result[0];
136 if (!first) {
137 return [];
138 }
139
140 return first.values.map((row) => {
141 const entry: Record<string, unknown> = {};
142 for (let index = 0; index < first.columns.length; index += 1) {
143 entry[first.columns[index]] = row[index];
144 }
145 return entry;
146 });
147}
148
149function getErrorCode(error: unknown): string | undefined {
150 if (!error || typeof error !== "object") return undefined;
151 const value = (error as { code?: unknown }).code;
152 return typeof value === "string" ? value : undefined;
153}
154
155function isNotFoundError(error: unknown): boolean {
156 const code = getErrorCode(error);
157 return code === "ENOENT" || code === "NOT_FOUND";
158}
159
160function isReadOnlySql(sql: string): boolean {
161 const trimmed = sql.trim().toLowerCase();
162 return trimmed.startsWith("select")
163 || trimmed.startsWith("pragma")
164 || trimmed.startsWith("explain")
165 || trimmed.startsWith("with");
166}
167
168class AgentFsLocalStorageProvider implements StorageProvider {
169 private readonly baseDir: string;
170
171 constructor(baseDir: string) {
172 this.baseDir = baseDir;
173 }
174
175 private getInstancePaths(instance: StorageInstanceRecord) {
176 const rootPath = normalizeStoragePath(this.baseDir, instance.backendKey || instance.id);
177 const fsRoot = join(rootPath, "fs");
178 const sqlitePath = join(rootPath, "storage.sqlite");
179
180 return {
181 rootPath,
182 fsRoot,
183 sqlitePath,
184 };
185 }
186
187 private resolveFsPath(instance: StorageInstanceRecord, path: string): string {
188 const { fsRoot } = this.getInstancePaths(instance);
189 const relative = normalizeRelativeFsPath(path);
190 const fullPath = resolve(fsRoot, relative);
191 const resolvedRoot = resolve(fsRoot);
192 if (fullPath !== resolvedRoot && !fullPath.startsWith(`${resolvedRoot}${sep}`)) {
193 throw new Error(`Path escapes storage root: ${path}`);
194 }
195
196 return fullPath;
197 }
198
199 private async withDatabase<T>(
200 instance: StorageInstanceRecord,
201 writeMode: boolean,
202 callback: (db: SqlJsDatabase) => Promise<T> | T,
203 ): Promise<T> {
204 const { rootPath, sqlitePath } = this.getInstancePaths(instance);
205 await mkdir(rootPath, { recursive: true });
206
207 const SQL = await loadSqlJsModule();
208 let initialBytes: Uint8Array | undefined;
209 try {
210 const existing = await readFile(sqlitePath);
211 initialBytes = new Uint8Array(existing);
212 } catch (error) {
213 if (!isNotFoundError(error)) {
214 throw error;
215 }
216 }
217
218 const db = new SQL.Database(initialBytes);
219 try {
220 const result = await callback(db);
221 if (writeMode) {
222 const serialized = db.export();
223 await writeFile(sqlitePath, Buffer.from(serialized));
224 }
225 return result;
226 } finally {
227 db.close();
228 }
229 }
230
231 private async ensureKvTable(db: SqlJsDatabase) {
232 db.run(`
233 CREATE TABLE IF NOT EXISTS kv_store (
234 key TEXT PRIMARY KEY,
235 value TEXT NOT NULL,
236 updated_at INTEGER NOT NULL
237 )
238 `);
239 }
240
241 async readFile(instance: StorageInstanceRecord, path: string, encoding: StorageEncoding): Promise<{ content: string; bytes: number }> {
242 const fullPath = this.resolveFsPath(instance, path);
243 const file = await readFile(fullPath);
244 if (encoding === "base64") {
245 return {
246 content: file.toString("base64"),
247 bytes: file.length,
248 };
249 }
250
251 const text = file.toString("utf8");
252 return {
253 content: text,
254 bytes: Buffer.byteLength(text, "utf8"),
255 };
256 }
257
258 async writeFile(
259 instance: StorageInstanceRecord,
260 path: string,
261 content: string,
262 encoding: StorageEncoding,
263 ): Promise<{ bytesWritten: number }> {
264 const fullPath = this.resolveFsPath(instance, path);
265 await mkdir(dirname(fullPath), { recursive: true });
266 const payload = encoding === "base64"
267 ? Buffer.from(content, "base64")
268 : Buffer.from(content, "utf8");
269 await writeFile(fullPath, payload);
270 return {
271 bytesWritten: payload.length,
272 };
273 }
274
275 async readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]> {
276 const fullPath = this.resolveFsPath(instance, path);
277 const entries = await readdir(fullPath, { withFileTypes: true });
278 return entries.map((entry) => ({
279 name: entry.name,
280 type: entry.isDirectory()
281 ? "directory"
282 : entry.isFile()
283 ? "file"
284 : entry.isSymbolicLink()
285 ? "symlink"
286 : "unknown",
287 }));
288 }
289
290 async stat(instance: StorageInstanceRecord, path: string): Promise<StorageStatResult> {
291 const fullPath = this.resolveFsPath(instance, path);
292 try {
293 const stats = await lstat(fullPath);
294 return {
295 exists: true,
296 type: stats.isDirectory()
297 ? "directory"
298 : stats.isFile()
299 ? "file"
300 : stats.isSymbolicLink()
301 ? "symlink"
302 : "unknown",
303 size: stats.size,
304 mode: stats.mode,
305 mtime: stats.mtimeMs,
306 ctime: stats.ctimeMs,
307 };
308 } catch (error) {
309 if (isNotFoundError(error)) {
310 return { exists: false };
311 }
312 throw error;
313 }
314 }
315
316 async mkdir(instance: StorageInstanceRecord, path: string): Promise<void> {
317 const fullPath = this.resolveFsPath(instance, path);
318 await mkdir(fullPath, { recursive: true });
319 }
320
321 async remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void> {
322 const fullPath = this.resolveFsPath(instance, path);
323 await rm(fullPath, {
324 recursive: options.recursive ?? false,
325 force: options.force ?? false,
326 });
327 }
328
329 async kvGet(instance: StorageInstanceRecord, key: string): Promise<unknown> {
330 return await this.withDatabase(instance, false, async (db) => {
331 await this.ensureKvTable(db);
332 const rows = rowsFromSqlResult(db.exec("SELECT value FROM kv_store WHERE key = ?", [key]));
333 const raw = rows[0]?.value;
334 if (typeof raw !== "string") {
335 return undefined;
336 }
337 return JSON.parse(raw) as unknown;
338 });
339 }
340
341 async kvSet(instance: StorageInstanceRecord, key: string, value: unknown): Promise<void> {
342 await this.withDatabase(instance, true, async (db) => {
343 await this.ensureKvTable(db);
344 db.run(
345 "INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
346 [key, JSON.stringify(value), Math.floor(Date.now() / 1000)],
347 );
348 });
349 }
350
351 async kvList(instance: StorageInstanceRecord, prefix: string, limit: number): Promise<Array<{ key: string; value: unknown }>> {
352 return await this.withDatabase(instance, false, async (db) => {
353 await this.ensureKvTable(db);
354 const rows = rowsFromSqlResult(
355 db.exec(
356 "SELECT key, value FROM kv_store WHERE key LIKE ? ESCAPE '\\\\' ORDER BY key LIMIT ?",
357 [`${prefix.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`, limit],
358 ),
359 );
360 return rows.map((row) => ({
361 key: String(row.key ?? ""),
362 value: typeof row.value === "string" ? JSON.parse(row.value) : undefined,
363 }));
364 });
365 }
366
367 async kvDelete(instance: StorageInstanceRecord, key: string): Promise<void> {
368 await this.withDatabase(instance, true, async (db) => {
369 await this.ensureKvTable(db);
370 db.run("DELETE FROM kv_store WHERE key = ?", [key]);
371 });
372 }
373
374 async sqliteQuery(
375 instance: StorageInstanceRecord,
376 args: { sql: string; params: Array<string | number | boolean | null>; mode: "read" | "write"; maxRows: number },
377 ): Promise<StorageSqlResult> {
378 if (args.mode === "read" && !isReadOnlySql(args.sql)) {
379 throw new Error("sqlite.query rejected a non-read SQL statement in read mode");
380 }
381
382 const normalizedParams = args.params.map(toDbValue);
383 if (args.mode === "write") {
384 return await this.withDatabase(instance, true, async (db) => {
385 db.run(args.sql, normalizedParams);
386 const changes = Number(db.getRowsModified());
387 return {
388 mode: "write",
389 rowCount: 0,
390 changes: Number.isFinite(changes) ? changes : 0,
391 };
392 });
393 }
394
395 return await this.withDatabase(instance, false, async (db) => {
396 const rows = rowsFromSqlResult(db.exec(args.sql, normalizedParams));
397 const boundedRows = rows.slice(0, Math.max(1, args.maxRows));
398 return {
399 mode: "read",
400 rows: boundedRows,
401 rowCount: boundedRows.length,
402 };
403 });
404 }
405
406 async usage(instance: StorageInstanceRecord): Promise<StorageUsage> {
407 const { rootPath } = this.getInstancePaths(instance);
408
409 let sizeBytes = 0;
410 let fileCount = 0;
411
412 const walk = async (currentPath: string): Promise<void> => {
413 const entries = await readdir(currentPath, { withFileTypes: true });
414 await Promise.all(entries.map(async (entry) => {
415 const absolute = join(currentPath, entry.name);
416 if (entry.isDirectory()) {
417 await walk(absolute);
418 return;
419 }
420
421 if (entry.isFile()) {
422 const fileStats = await stat(absolute);
423 sizeBytes += fileStats.size;
424 fileCount += 1;
425 }
426 }));
427 };
428
429 try {
430 await walk(rootPath);
431 } catch (error) {
432 if (!isNotFoundError(error)) {
433 throw error;
434 }
435 }
436
437 return {
438 sizeBytes,
439 fileCount,
440 };
441 }
442
443 async deleteInstance(instance: StorageInstanceRecord): Promise<void> {
444 const { rootPath } = this.getInstancePaths(instance);
445 await rm(rootPath, { recursive: true, force: true });
446 }
447}
448
449class CloudflareRemoteStorageProvider implements StorageProvider {
450 private readonly endpoint: string;
451 private readonly token: string;
452
453 constructor(endpoint: string, token: string) {
454 this.endpoint = endpoint.replace(/\/$/, "");
455 this.token = token;
456 }
457
458 private async request<T>(instance: StorageInstanceRecord, operation: string, payload: Record<string, unknown>): Promise<T> {
459 const response = await fetch(`${this.endpoint}/v1/storage`, {
460 method: "POST",
461 headers: {
462 "content-type": "application/json",
463 authorization: `Bearer ${this.token}`,
464 },
465 body: JSON.stringify({
466 instanceId: instance.id,
467 operation,
468 payload,
469 }),
470 });
471
472 const body = await response.json().catch(() => null) as { ok?: boolean; data?: T; error?: string } | null;
473 if (!response.ok || !body?.ok) {
474 const message = body?.error ?? `Cloudflare storage request failed with status ${response.status}`;
475 throw new Error(message);
476 }
477
478 return body.data as T;
479 }
480
481 async readFile(instance: StorageInstanceRecord, path: string, encoding: StorageEncoding): Promise<{ content: string; bytes: number }> {
482 return await this.request(instance, "fs.read", { path, encoding });
483 }
484
485 async writeFile(
486 instance: StorageInstanceRecord,
487 path: string,
488 content: string,
489 encoding: StorageEncoding,
490 ): Promise<{ bytesWritten: number }> {
491 return await this.request(instance, "fs.write", { path, content, encoding });
492 }
493
494 async readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]> {
495 const response = await this.request<{ entries: StorageDirectoryEntry[] }>(instance, "fs.readdir", { path });
496 return response.entries;
497 }
498
499 async stat(instance: StorageInstanceRecord, path: string): Promise<StorageStatResult> {
500 return await this.request(instance, "fs.stat", { path });
501 }
502
503 async mkdir(instance: StorageInstanceRecord, path: string): Promise<void> {
504 await this.request(instance, "fs.mkdir", { path });
505 }
506
507 async remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void> {
508 await this.request(instance, "fs.remove", {
509 path,
510 recursive: options.recursive ?? false,
511 force: options.force ?? false,
512 });
513 }
514
515 async kvGet(instance: StorageInstanceRecord, key: string): Promise<unknown> {
516 const response = await this.request<{ value?: unknown }>(instance, "kv.get", { key });
517 return response.value;
518 }
519
520 async kvSet(instance: StorageInstanceRecord, key: string, value: unknown): Promise<void> {
521 await this.request(instance, "kv.set", { key, value });
522 }
523
524 async kvList(instance: StorageInstanceRecord, prefix: string, limit: number): Promise<Array<{ key: string; value: unknown }>> {
525 const response = await this.request<{ items: Array<{ key: string; value: unknown }> }>(instance, "kv.list", {
526 prefix,
527 limit,
528 });
529 return response.items;
530 }
531
532 async kvDelete(instance: StorageInstanceRecord, key: string): Promise<void> {
533 await this.request(instance, "kv.delete", { key });
534 }
535
536 async sqliteQuery(
537 instance: StorageInstanceRecord,
538 args: { sql: string; params: Array<string | number | boolean | null>; mode: "read" | "write"; maxRows: number },
539 ): Promise<StorageSqlResult> {
540 return await this.request(instance, "sqlite.query", args);
541 }
542
543 async usage(instance: StorageInstanceRecord): Promise<StorageUsage> {
544 return await this.request(instance, "instance.usage", {});
545 }
546
547 async deleteInstance(instance: StorageInstanceRecord): Promise<void> {
548 await this.request(instance, "instance.delete", {});
549 }
550}
551
552let localProviderSingleton: AgentFsLocalStorageProvider | null = null;
553let cloudflareProviderSingleton: CloudflareRemoteStorageProvider | null = null;
554let cloudflareStorageProbeStarted = false;
555
556function startCloudflareStorageProbe(endpoint: string, token: string) {
557 if (cloudflareStorageProbeStarted) {
558 return;
559 }
560 cloudflareStorageProbeStarted = true;
561
562 void (async () => {
563 const controller = new AbortController();
564 const timeout = setTimeout(() => controller.abort(), 2500);
565 try {
566 const response = await fetch(`${endpoint}/v1/storage`, {
567 method: "POST",
568 headers: {
569 "content-type": "application/json",
570 authorization: `Bearer ${token}`,
571 },
572 body: "{}",
573 signal: controller.signal,
574 });
575
576 if (response.status === 404) {
577 console.warn(
578 `[storage] Cloudflare storage endpoint ${endpoint}/v1/storage is unreachable (404). Ensure the runner-sandbox-host is deployed with the storage route.`,
579 );
580 } else if (response.status === 401 || response.status === 403) {
581 console.warn(
582 "[storage] Cloudflare storage endpoint rejected auth. Verify CLOUDFLARE_SANDBOX_AUTH_TOKEN matches runner-sandbox-host AUTH_TOKEN.",
583 );
584 }
585 } catch (error) {
586 const message = error instanceof Error ? error.message : String(error);
587 console.warn(
588 `[storage] Cloudflare storage endpoint ${endpoint}/v1/storage is unreachable: ${message}`,
589 );
590 } finally {
591 clearTimeout(timeout);
592 }
593 })();
594}
595
596function getLocalProvider(): AgentFsLocalStorageProvider {
597 if (!localProviderSingleton) {
598 const baseDir = (process.env.AGENT_STORAGE_ROOT ?? "").trim() || "/tmp/executor-agentfs";
599 localProviderSingleton = new AgentFsLocalStorageProvider(baseDir);
600 }
601
602 return localProviderSingleton;
603}
604
605function getCloudflareProvider(): CloudflareRemoteStorageProvider {
606 if (!cloudflareProviderSingleton) {
607 const sandboxRunUrl = (process.env.CLOUDFLARE_SANDBOX_RUN_URL ?? "").trim();
608 const token = (process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN ?? "").trim();
609 if (!sandboxRunUrl || !token) {
610 throw new Error(
611 "Cloudflare storage provider requires CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN",
612 );
613 }
614
615 let endpoint = "";
616 try {
617 const url = new URL(sandboxRunUrl);
618 url.pathname = "/";
619 url.search = "";
620 url.hash = "";
621 endpoint = url.toString().replace(/\/$/, "");
622 } catch {
623 endpoint = sandboxRunUrl.endsWith("/v1/runs")
624 ? sandboxRunUrl.slice(0, -"/v1/runs".length)
625 : sandboxRunUrl.replace(/\/$/, "");
626 }
627
628 cloudflareProviderSingleton = new CloudflareRemoteStorageProvider(endpoint, token);
629 startCloudflareStorageProbe(endpoint, token);
630 }
631
632 return cloudflareProviderSingleton;
633}
634
635export function getStorageProvider(provider: StorageProviderId): StorageProvider {
636 if (provider === "agentfs-cloudflare") {
637 return getCloudflareProvider();
638 }
639
640 return getLocalProvider();
641}
executor/packages/database/src/runtime/storage_tool_contracts.ts+540−0
@@ -0,0 +1,540 @@
1import { z } from "zod";
2
3type JsonSchema = Record<string, unknown>;
4
5function toJsonSchema(schema: z.ZodTypeAny, fallback: JsonSchema): JsonSchema {
6 const maybeToJsonSchema = (z as unknown as { toJSONSchema?: (value: z.ZodTypeAny) => unknown }).toJSONSchema;
7 if (typeof maybeToJsonSchema === "function") {
8 return maybeToJsonSchema(schema) as JsonSchema;
9 }
10
11 return fallback;
12}
13
14export const storageScopeSchema = z.enum(["scratch", "account", "workspace", "organization"]);
15export const storageDurabilitySchema = z.enum(["ephemeral", "durable"]);
16export const storageStatusSchema = z.enum(["active", "closed", "deleted"]);
17export const storageProviderSchema = z.enum(["agentfs-local", "agentfs-cloudflare"]);
18
19export const storageInstanceSchema = z.object({
20 id: z.string(),
21 scopeType: storageScopeSchema,
22 durability: storageDurabilitySchema,
23 status: storageStatusSchema,
24 provider: storageProviderSchema,
25 backendKey: z.string(),
26 organizationId: z.string(),
27 workspaceId: z.string().optional(),
28 accountId: z.string().optional(),
29 createdByAccountId: z.string().optional(),
30 purpose: z.string().optional(),
31 sizeBytes: z.number().optional(),
32 fileCount: z.number().optional(),
33 createdAt: z.number(),
34 updatedAt: z.number(),
35 lastSeenAt: z.number(),
36 closedAt: z.number().optional(),
37 expiresAt: z.number().optional(),
38});
39
40export const storageOpenInputSchema = z.object({
41 instanceId: z.string().optional(),
42 scopeType: storageScopeSchema.optional(),
43 durability: storageDurabilitySchema.optional(),
44 purpose: z.string().optional(),
45 ttlHours: z.coerce.number().optional(),
46});
47
48export const storageOpenOutputSchema = z.object({
49 instance: storageInstanceSchema,
50});
51
52export const storageListInputSchema = z.object({
53 scopeType: storageScopeSchema.optional(),
54 includeDeleted: z.boolean().optional(),
55});
56
57export const storageListOutputSchema = z.object({
58 instances: z.array(storageInstanceSchema),
59 total: z.number(),
60});
61
62export const storageCloseInputSchema = z.object({
63 instanceId: z.string(),
64});
65
66export const storageCloseOutputSchema = z.object({
67 instance: storageInstanceSchema.nullable(),
68});
69
70export const storageDeleteInputSchema = z.object({
71 instanceId: z.string(),
72});
73
74export const storageDeleteOutputSchema = z.object({
75 instance: storageInstanceSchema.nullable(),
76});
77
78const fsAccessSchema = z.object({
79 instanceId: z.string().optional(),
80 scopeType: storageScopeSchema.optional(),
81});
82
83export const fsReadInputSchema = fsAccessSchema.extend({
84 path: z.string(),
85 encoding: z.enum(["utf8", "base64"]).optional(),
86});
87
88export const fsReadOutputSchema = z.object({
89 instanceId: z.string(),
90 path: z.string(),
91 encoding: z.enum(["utf8", "base64"]),
92 content: z.string(),
93 bytes: z.number(),
94});
95
96export const fsWriteInputSchema = fsAccessSchema.extend({
97 path: z.string(),
98 content: z.string(),
99 encoding: z.enum(["utf8", "base64"]).optional(),
100});
101
102export const fsWriteOutputSchema = z.object({
103 instanceId: z.string(),
104 path: z.string(),
105 bytesWritten: z.number(),
106});
107
108export const fsReaddirInputSchema = fsAccessSchema.extend({
109 path: z.string().optional(),
110});
111
112export const fsReaddirOutputSchema = z.object({
113 instanceId: z.string(),
114 path: z.string(),
115 entries: z.array(z.object({
116 name: z.string(),
117 type: z.enum(["file", "directory", "symlink", "unknown"]),
118 size: z.number().optional(),
119 mtime: z.number().optional(),
120 })),
121});
122
123export const fsStatInputSchema = fsAccessSchema.extend({
124 path: z.string(),
125});
126
127export const fsStatOutputSchema = z.object({
128 instanceId: z.string(),
129 path: z.string(),
130 exists: z.boolean(),
131 type: z.enum(["file", "directory", "symlink", "unknown"]).optional(),
132 size: z.number().optional(),
133 mode: z.number().optional(),
134 mtime: z.number().optional(),
135 ctime: z.number().optional(),
136});
137
138export const fsMkdirInputSchema = fsAccessSchema.extend({
139 path: z.string(),
140});
141
142export const fsMkdirOutputSchema = z.object({
143 instanceId: z.string(),
144 path: z.string(),
145 ok: z.boolean(),
146});
147
148export const fsRemoveInputSchema = fsAccessSchema.extend({
149 path: z.string(),
150 recursive: z.boolean().optional(),
151 force: z.boolean().optional(),
152});
153
154export const fsRemoveOutputSchema = z.object({
155 instanceId: z.string(),
156 path: z.string(),
157 ok: z.boolean(),
158});
159
160export const kvGetInputSchema = fsAccessSchema.extend({
161 key: z.string(),
162});
163
164export const kvGetOutputSchema = z.object({
165 instanceId: z.string(),
166 key: z.string(),
167 found: z.boolean(),
168 value: z.unknown().optional(),
169});
170
171export const kvSetInputSchema = fsAccessSchema.extend({
172 key: z.string(),
173 value: z.unknown(),
174});
175
176export const kvSetOutputSchema = z.object({
177 instanceId: z.string(),
178 key: z.string(),
179 ok: z.boolean(),
180});
181
182export const kvListInputSchema = fsAccessSchema.extend({
183 prefix: z.string().optional(),
184 limit: z.coerce.number().optional(),
185});
186
187export const kvListOutputSchema = z.object({
188 instanceId: z.string(),
189 items: z.array(z.object({ key: z.string(), value: z.unknown() })),
190 total: z.number(),
191});
192
193export const kvDeleteInputSchema = fsAccessSchema.extend({
194 key: z.string(),
195});
196
197export const kvDeleteOutputSchema = z.object({
198 instanceId: z.string(),
199 key: z.string(),
200 ok: z.boolean(),
201});
202
203export const sqliteQueryInputSchema = fsAccessSchema.extend({
204 sql: z.string(),
205 params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
206 mode: z.enum(["read", "write"]).optional(),
207 maxRows: z.coerce.number().optional(),
208});
209
210export const sqliteQueryOutputSchema = z.object({
211 instanceId: z.string(),
212 mode: z.enum(["read", "write"]),
213 rows: z.array(z.record(z.unknown())).optional(),
214 rowCount: z.number(),
215 changes: z.number().optional(),
216});
217
218export const storageOpenInputJsonSchema = toJsonSchema(storageOpenInputSchema, {
219 type: "object",
220 properties: {
221 instanceId: { type: "string" },
222 scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
223 durability: { type: "string", enum: ["ephemeral", "durable"] },
224 purpose: { type: "string" },
225 ttlHours: { type: "number" },
226 },
227 additionalProperties: false,
228});
229
230export const storageOpenOutputJsonSchema = toJsonSchema(storageOpenOutputSchema, {
231 type: "object",
232 properties: {
233 instance: { type: "object" },
234 },
235 required: ["instance"],
236 additionalProperties: false,
237});
238
239export const storageListInputJsonSchema = toJsonSchema(storageListInputSchema, {
240 type: "object",
241 properties: {
242 scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
243 includeDeleted: { type: "boolean" },
244 },
245 additionalProperties: false,
246});
247
248export const storageListOutputJsonSchema = toJsonSchema(storageListOutputSchema, {
249 type: "object",
250 properties: {
251 instances: { type: "array", items: { type: "object" } },
252 total: { type: "number" },
253 },
254 required: ["instances", "total"],
255 additionalProperties: false,
256});
257
258export const storageCloseInputJsonSchema = toJsonSchema(storageCloseInputSchema, {
259 type: "object",
260 properties: {
261 instanceId: { type: "string" },
262 },
263 required: ["instanceId"],
264 additionalProperties: false,
265});
266
267export const storageCloseOutputJsonSchema = toJsonSchema(storageCloseOutputSchema, {
268 type: "object",
269 properties: {
270 instance: {
271 oneOf: [{ type: "object" }, { type: "null" }],
272 },
273 },
274 required: ["instance"],
275 additionalProperties: false,
276});
277
278export const storageDeleteInputJsonSchema = storageCloseInputJsonSchema;
279export const storageDeleteOutputJsonSchema = storageCloseOutputJsonSchema;
280
281function fsAccessJsonProperties() {
282 return {
283 instanceId: { type: "string" },
284 scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
285 };
286}
287
288export const fsReadInputJsonSchema = toJsonSchema(fsReadInputSchema, {
289 type: "object",
290 properties: {
291 ...fsAccessJsonProperties(),
292 path: { type: "string" },
293 encoding: { type: "string", enum: ["utf8", "base64"] },
294 },
295 required: ["path"],
296 additionalProperties: false,
297});
298
299export const fsReadOutputJsonSchema = toJsonSchema(fsReadOutputSchema, {
300 type: "object",
301 properties: {
302 instanceId: { type: "string" },
303 path: { type: "string" },
304 encoding: { type: "string", enum: ["utf8", "base64"] },
305 content: { type: "string" },
306 bytes: { type: "number" },
307 },
308 required: ["instanceId", "path", "encoding", "content", "bytes"],
309 additionalProperties: false,
310});
311
312export const fsWriteInputJsonSchema = toJsonSchema(fsWriteInputSchema, {
313 type: "object",
314 properties: {
315 ...fsAccessJsonProperties(),
316 path: { type: "string" },
317 content: { type: "string" },
318 encoding: { type: "string", enum: ["utf8", "base64"] },
319 },
320 required: ["path", "content"],
321 additionalProperties: false,
322});
323
324export const fsWriteOutputJsonSchema = toJsonSchema(fsWriteOutputSchema, {
325 type: "object",
326 properties: {
327 instanceId: { type: "string" },
328 path: { type: "string" },
329 bytesWritten: { type: "number" },
330 },
331 required: ["instanceId", "path", "bytesWritten"],
332 additionalProperties: false,
333});
334
335export const fsReaddirInputJsonSchema = toJsonSchema(fsReaddirInputSchema, {
336 type: "object",
337 properties: {
338 ...fsAccessJsonProperties(),
339 path: { type: "string" },
340 },
341 additionalProperties: false,
342});
343
344export const fsReaddirOutputJsonSchema = toJsonSchema(fsReaddirOutputSchema, {
345 type: "object",
346 properties: {
347 instanceId: { type: "string" },
348 path: { type: "string" },
349 entries: { type: "array", items: { type: "object" } },
350 },
351 required: ["instanceId", "path", "entries"],
352 additionalProperties: false,
353});
354
355export const fsStatInputJsonSchema = toJsonSchema(fsStatInputSchema, {
356 type: "object",
357 properties: {
358 ...fsAccessJsonProperties(),
359 path: { type: "string" },
360 },
361 required: ["path"],
362 additionalProperties: false,
363});
364
365export const fsStatOutputJsonSchema = toJsonSchema(fsStatOutputSchema, {
366 type: "object",
367 properties: {
368 instanceId: { type: "string" },
369 path: { type: "string" },
370 exists: { type: "boolean" },
371 type: { type: "string", enum: ["file", "directory", "symlink", "unknown"] },
372 size: { type: "number" },
373 mode: { type: "number" },
374 mtime: { type: "number" },
375 ctime: { type: "number" },
376 },
377 required: ["instanceId", "path", "exists"],
378 additionalProperties: false,
379});
380
381export const fsMkdirInputJsonSchema = toJsonSchema(fsMkdirInputSchema, {
382 type: "object",
383 properties: {
384 ...fsAccessJsonProperties(),
385 path: { type: "string" },
386 },
387 required: ["path"],
388 additionalProperties: false,
389});
390
391export const fsMkdirOutputJsonSchema = toJsonSchema(fsMkdirOutputSchema, {
392 type: "object",
393 properties: {
394 instanceId: { type: "string" },
395 path: { type: "string" },
396 ok: { type: "boolean" },
397 },
398 required: ["instanceId", "path", "ok"],
399 additionalProperties: false,
400});
401
402export const fsRemoveInputJsonSchema = toJsonSchema(fsRemoveInputSchema, {
403 type: "object",
404 properties: {
405 ...fsAccessJsonProperties(),
406 path: { type: "string" },
407 recursive: { type: "boolean" },
408 force: { type: "boolean" },
409 },
410 required: ["path"],
411 additionalProperties: false,
412});
413
414export const fsRemoveOutputJsonSchema = toJsonSchema(fsRemoveOutputSchema, {
415 type: "object",
416 properties: {
417 instanceId: { type: "string" },
418 path: { type: "string" },
419 ok: { type: "boolean" },
420 },
421 required: ["instanceId", "path", "ok"],
422 additionalProperties: false,
423});
424
425export const kvGetInputJsonSchema = toJsonSchema(kvGetInputSchema, {
426 type: "object",
427 properties: {
428 ...fsAccessJsonProperties(),
429 key: { type: "string" },
430 },
431 required: ["key"],
432 additionalProperties: false,
433});
434
435export const kvGetOutputJsonSchema = toJsonSchema(kvGetOutputSchema, {
436 type: "object",
437 properties: {
438 instanceId: { type: "string" },
439 key: { type: "string" },
440 found: { type: "boolean" },
441 value: {},
442 },
443 required: ["instanceId", "key", "found"],
444 additionalProperties: false,
445});
446
447export const kvSetInputJsonSchema = toJsonSchema(kvSetInputSchema, {
448 type: "object",
449 properties: {
450 ...fsAccessJsonProperties(),
451 key: { type: "string" },
452 value: {},
453 },
454 required: ["key", "value"],
455 additionalProperties: false,
456});
457
458export const kvSetOutputJsonSchema = toJsonSchema(kvSetOutputSchema, {
459 type: "object",
460 properties: {
461 instanceId: { type: "string" },
462 key: { type: "string" },
463 ok: { type: "boolean" },
464 },
465 required: ["instanceId", "key", "ok"],
466 additionalProperties: false,
467});
468
469export const kvListInputJsonSchema = toJsonSchema(kvListInputSchema, {
470 type: "object",
471 properties: {
472 ...fsAccessJsonProperties(),
473 prefix: { type: "string" },
474 limit: { type: "number" },
475 },
476 additionalProperties: false,
477});
478
479export const kvListOutputJsonSchema = toJsonSchema(kvListOutputSchema, {
480 type: "object",
481 properties: {
482 instanceId: { type: "string" },
483 items: { type: "array", items: { type: "object" } },
484 total: { type: "number" },
485 },
486 required: ["instanceId", "items", "total"],
487 additionalProperties: false,
488});
489
490export const kvDeleteInputJsonSchema = toJsonSchema(kvDeleteInputSchema, {
491 type: "object",
492 properties: {
493 ...fsAccessJsonProperties(),
494 key: { type: "string" },
495 },
496 required: ["key"],
497 additionalProperties: false,
498});
499
500export const kvDeleteOutputJsonSchema = toJsonSchema(kvDeleteOutputSchema, {
501 type: "object",
502 properties: {
503 instanceId: { type: "string" },
504 key: { type: "string" },
505 ok: { type: "boolean" },
506 },
507 required: ["instanceId", "key", "ok"],
508 additionalProperties: false,
509});
510
511export const sqliteQueryInputJsonSchema = toJsonSchema(sqliteQueryInputSchema, {
512 type: "object",
513 properties: {
514 ...fsAccessJsonProperties(),
515 sql: { type: "string" },
516 params: {
517 type: "array",
518 items: {
519 oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }],
520 },
521 },
522 mode: { type: "string", enum: ["read", "write"] },
523 maxRows: { type: "number" },
524 },
525 required: ["sql"],
526 additionalProperties: false,
527});
528
529export const sqliteQueryOutputJsonSchema = toJsonSchema(sqliteQueryOutputSchema, {
530 type: "object",
531 properties: {
532 instanceId: { type: "string" },
533 mode: { type: "string", enum: ["read", "write"] },
534 rows: { type: "array", items: { type: "object" } },
535 rowCount: { type: "number" },
536 changes: { type: "number" },
537 },
538 required: ["instanceId", "mode", "rowCount"],
539 additionalProperties: false,
540});
executor/packages/database/src/runtime/storage_tools.ts+387−0
@@ -0,0 +1,387 @@
1import type { ActionCtx } from "../../convex/_generated/server";
2import { internal } from "../../convex/_generated/api";
3import type { TaskRecord, StorageInstanceRecord } from "../../../core/src/types";
4import { getStorageProvider, type StorageEncoding, type StorageProvider } from "./storage_provider";
5import {
6 fsMkdirInputSchema,
7 fsMkdirOutputSchema,
8 fsReadInputSchema,
9 fsReadOutputSchema,
10 fsReaddirInputSchema,
11 fsReaddirOutputSchema,
12 fsRemoveInputSchema,
13 fsRemoveOutputSchema,
14 fsStatInputSchema,
15 fsStatOutputSchema,
16 fsWriteInputSchema,
17 fsWriteOutputSchema,
18 kvDeleteInputSchema,
19 kvDeleteOutputSchema,
20 kvGetInputSchema,
21 kvGetOutputSchema,
22 kvListInputSchema,
23 kvListOutputSchema,
24 kvSetInputSchema,
25 kvSetOutputSchema,
26 sqliteQueryInputSchema,
27 sqliteQueryOutputSchema,
28 storageCloseInputSchema,
29 storageCloseOutputSchema,
30 storageDeleteInputSchema,
31 storageDeleteOutputSchema,
32 storageListInputSchema,
33 storageListOutputSchema,
34 storageOpenInputSchema,
35 storageOpenOutputSchema,
36} from "./storage_tool_contracts";
37
38const STORAGE_SYSTEM_TOOLS = new Set([
39 "storage.open",
40 "storage.list",
41 "storage.close",
42 "storage.delete",
43 "fs.read",
44 "fs.write",
45 "fs.readdir",
46 "fs.stat",
47 "fs.mkdir",
48 "fs.remove",
49 "kv.get",
50 "kv.set",
51 "kv.list",
52 "kv.delete",
53 "sqlite.query",
54]);
55
56function toInputRecord(input: unknown): Record<string, unknown> {
57 if (!input || typeof input !== "object" || Array.isArray(input)) {
58 return {};
59 }
60
61 return input as Record<string, unknown>;
62}
63
64function normalizeScopeType(value: unknown): undefined | "scratch" | "account" | "workspace" | "organization" {
65 if (typeof value !== "string") return undefined;
66 const normalized = value.trim().toLowerCase();
67 if (normalized === "scratch" || normalized === "account" || normalized === "workspace" || normalized === "organization") {
68 return normalized;
69 }
70 return undefined;
71}
72
73function normalizeInstanceId(value: unknown): string | undefined {
74 if (typeof value !== "string") return undefined;
75 const normalized = value.trim();
76 return normalized.length > 0 ? normalized : undefined;
77}
78
79function normalizeInputPayload(input: unknown): Record<string, unknown> {
80 const payload = toInputRecord(input);
81 return {
82 ...payload,
83 ...(normalizeScopeType(payload.scopeType) ? { scopeType: normalizeScopeType(payload.scopeType) } : {}),
84 ...(normalizeInstanceId(payload.instanceId) ? { instanceId: normalizeInstanceId(payload.instanceId) } : {}),
85 };
86}
87
88async function openStorageInstanceForTask(
89 ctx: ActionCtx,
90 task: TaskRecord,
91 args: {
92 instanceId?: string;
93 scopeType?: "scratch" | "account" | "workspace" | "organization";
94 durability?: "ephemeral" | "durable";
95 purpose?: string;
96 ttlHours?: number;
97 },
98): Promise<StorageInstanceRecord> {
99 const opened = await ctx.runMutation(internal.database.openStorageInstance, {
100 workspaceId: task.workspaceId,
101 accountId: task.accountId,
102 instanceId: args.instanceId,
103 scopeType: args.scopeType,
104 durability: args.durability,
105 purpose: args.purpose,
106 ttlHours: args.ttlHours,
107 });
108 return opened as StorageInstanceRecord;
109}
110
111async function resolveStorageInstance(
112 ctx: ActionCtx,
113 task: TaskRecord,
114 payload: Record<string, unknown>,
115): Promise<StorageInstanceRecord> {
116 const requestedInstanceId = normalizeInstanceId(payload.instanceId);
117 const scopeType = normalizeScopeType(payload.scopeType);
118
119 if (requestedInstanceId) {
120 const existing = await ctx.runQuery(internal.database.getStorageInstance, {
121 workspaceId: task.workspaceId,
122 accountId: task.accountId,
123 instanceId: requestedInstanceId,
124 });
125 if (!existing) {
126 throw new Error(`Storage instance not found: ${requestedInstanceId}`);
127 }
128
129 const reopened = await openStorageInstanceForTask(ctx, task, {
130 instanceId: requestedInstanceId,
131 });
132 return reopened;
133 }
134
135 return await openStorageInstanceForTask(ctx, task, {
136 scopeType,
137 purpose: "auto",
138 });
139}
140
141async function touchInstance(
142 ctx: ActionCtx,
143 task: TaskRecord,
144 instance: StorageInstanceRecord,
145 provider: StorageProvider,
146 withUsage: boolean,
147) {
148 const usage = withUsage ? await provider.usage(instance) : undefined;
149 await ctx.runMutation(internal.database.touchStorageInstance, {
150 workspaceId: task.workspaceId,
151 accountId: task.accountId,
152 instanceId: instance.id,
153 provider: instance.provider,
154 ...(usage?.sizeBytes !== undefined ? { sizeBytes: usage.sizeBytes } : {}),
155 ...(usage?.fileCount !== undefined ? { fileCount: usage.fileCount } : {}),
156 });
157}
158
159export function isStorageSystemToolPath(path: string): boolean {
160 return STORAGE_SYSTEM_TOOLS.has(path);
161}
162
163export async function runStorageSystemTool(
164 ctx: ActionCtx,
165 task: TaskRecord,
166 toolPath: string,
167 input: unknown,
168): Promise<unknown> {
169 const payload = normalizeInputPayload(input);
170
171 if (toolPath === "storage.open") {
172 const parsed = storageOpenInputSchema.parse(payload);
173 const instance = await openStorageInstanceForTask(ctx, task, parsed);
174 return storageOpenOutputSchema.parse({ instance });
175 }
176
177 if (toolPath === "storage.list") {
178 const parsed = storageListInputSchema.parse(payload);
179 const instances = await ctx.runQuery(internal.database.listStorageInstances, {
180 workspaceId: task.workspaceId,
181 accountId: task.accountId,
182 scopeType: parsed.scopeType,
183 includeDeleted: parsed.includeDeleted,
184 });
185
186 return storageListOutputSchema.parse({
187 instances,
188 total: instances.length,
189 });
190 }
191
192 if (toolPath === "storage.close") {
193 const parsed = storageCloseInputSchema.parse(payload);
194 const instance = await ctx.runMutation(internal.database.closeStorageInstance, {
195 workspaceId: task.workspaceId,
196 accountId: task.accountId,
197 instanceId: parsed.instanceId,
198 });
199 return storageCloseOutputSchema.parse({ instance });
200 }
201
202 if (toolPath === "storage.delete") {
203 const parsed = storageDeleteInputSchema.parse(payload);
204 const existing = await ctx.runQuery(internal.database.getStorageInstance, {
205 workspaceId: task.workspaceId,
206 accountId: task.accountId,
207 instanceId: parsed.instanceId,
208 }) as StorageInstanceRecord | null;
209
210 if (existing) {
211 const provider = getStorageProvider(existing.provider);
212 try {
213 await provider.deleteInstance(existing);
214 } catch {
215 // Continue marking the instance deleted even if backend cleanup fails.
216 }
217 }
218
219 const instance = await ctx.runMutation(internal.database.deleteStorageInstance, {
220 workspaceId: task.workspaceId,
221 accountId: task.accountId,
222 instanceId: parsed.instanceId,
223 });
224 return storageDeleteOutputSchema.parse({ instance });
225 }
226
227 if (toolPath === "fs.read") {
228 const parsed = fsReadInputSchema.parse(payload);
229 const instance = await resolveStorageInstance(ctx, task, payload);
230 const provider = getStorageProvider(instance.provider);
231 const encoding = parsed.encoding ?? "utf8";
232 const file = await provider.readFile(instance, parsed.path, encoding as StorageEncoding);
233 await touchInstance(ctx, task, instance, provider, false);
234 return fsReadOutputSchema.parse({
235 instanceId: instance.id,
236 path: parsed.path,
237 encoding,
238 content: file.content,
239 bytes: file.bytes,
240 });
241 }
242
243 if (toolPath === "fs.write") {
244 const parsed = fsWriteInputSchema.parse(payload);
245 const instance = await resolveStorageInstance(ctx, task, payload);
246 const provider = getStorageProvider(instance.provider);
247 const encoding = parsed.encoding ?? "utf8";
248 const result = await provider.writeFile(instance, parsed.path, parsed.content, encoding as StorageEncoding);
249 await touchInstance(ctx, task, instance, provider, true);
250 return fsWriteOutputSchema.parse({
251 instanceId: instance.id,
252 path: parsed.path,
253 bytesWritten: result.bytesWritten,
254 });
255 }
256
257 if (toolPath === "fs.readdir") {
258 const parsed = fsReaddirInputSchema.parse(payload);
259 const instance = await resolveStorageInstance(ctx, task, payload);
260 const provider = getStorageProvider(instance.provider);
261 const path = parsed.path ?? "/";
262 const entries = await provider.readdir(instance, path);
263 await touchInstance(ctx, task, instance, provider, false);
264 return fsReaddirOutputSchema.parse({
265 instanceId: instance.id,
266 path,
267 entries,
268 });
269 }
270
271 if (toolPath === "fs.stat") {
272 const parsed = fsStatInputSchema.parse(payload);
273 const instance = await resolveStorageInstance(ctx, task, payload);
274 const provider = getStorageProvider(instance.provider);
275 const stat = await provider.stat(instance, parsed.path);
276 await touchInstance(ctx, task, instance, provider, false);
277 return fsStatOutputSchema.parse({
278 instanceId: instance.id,
279 path: parsed.path,
280 ...stat,
281 });
282 }
283
284 if (toolPath === "fs.mkdir") {
285 const parsed = fsMkdirInputSchema.parse(payload);
286 const instance = await resolveStorageInstance(ctx, task, payload);
287 const provider = getStorageProvider(instance.provider);
288 await provider.mkdir(instance, parsed.path);
289 await touchInstance(ctx, task, instance, provider, true);
290 return fsMkdirOutputSchema.parse({
291 instanceId: instance.id,
292 path: parsed.path,
293 ok: true,
294 });
295 }
296
297 if (toolPath === "fs.remove") {
298 const parsed = fsRemoveInputSchema.parse(payload);
299 const instance = await resolveStorageInstance(ctx, task, payload);
300 const provider = getStorageProvider(instance.provider);
301 await provider.remove(instance, parsed.path, {
302 recursive: parsed.recursive,
303 force: parsed.force,
304 });
305 await touchInstance(ctx, task, instance, provider, true);
306 return fsRemoveOutputSchema.parse({
307 instanceId: instance.id,
308 path: parsed.path,
309 ok: true,
310 });
311 }
312
313 if (toolPath === "kv.get") {
314 const parsed = kvGetInputSchema.parse(payload);
315 const instance = await resolveStorageInstance(ctx, task, payload);
316 const provider = getStorageProvider(instance.provider);
317 const value = await provider.kvGet(instance, parsed.key);
318 await touchInstance(ctx, task, instance, provider, false);
319 return kvGetOutputSchema.parse({
320 instanceId: instance.id,
321 key: parsed.key,
322 found: value !== undefined,
323 ...(value !== undefined ? { value } : {}),
324 });
325 }
326
327 if (toolPath === "kv.set") {
328 const parsed = kvSetInputSchema.parse(payload);
329 const instance = await resolveStorageInstance(ctx, task, payload);
330 const provider = getStorageProvider(instance.provider);
331 await provider.kvSet(instance, parsed.key, parsed.value);
332 await touchInstance(ctx, task, instance, provider, true);
333 return kvSetOutputSchema.parse({
334 instanceId: instance.id,
335 key: parsed.key,
336 ok: true,
337 });
338 }
339
340 if (toolPath === "kv.list") {
341 const parsed = kvListInputSchema.parse(payload);
342 const instance = await resolveStorageInstance(ctx, task, payload);
343 const provider = getStorageProvider(instance.provider);
344 const limit = Math.max(1, Math.min(500, Math.floor(parsed.limit ?? 100)));
345 const items = await provider.kvList(instance, parsed.prefix ?? "", limit);
346 await touchInstance(ctx, task, instance, provider, false);
347 return kvListOutputSchema.parse({
348 instanceId: instance.id,
349 items,
350 total: items.length,
351 });
352 }
353
354 if (toolPath === "kv.delete") {
355 const parsed = kvDeleteInputSchema.parse(payload);
356 const instance = await resolveStorageInstance(ctx, task, payload);
357 const provider = getStorageProvider(instance.provider);
358 await provider.kvDelete(instance, parsed.key);
359 await touchInstance(ctx, task, instance, provider, true);
360 return kvDeleteOutputSchema.parse({
361 instanceId: instance.id,
362 key: parsed.key,
363 ok: true,
364 });
365 }
366
367 if (toolPath === "sqlite.query") {
368 const parsed = sqliteQueryInputSchema.parse(payload);
369 const instance = await resolveStorageInstance(ctx, task, payload);
370 const provider = getStorageProvider(instance.provider);
371 const mode = parsed.mode ?? "read";
372 const maxRows = Math.max(1, Math.min(1_000, Math.floor(parsed.maxRows ?? 200)));
373 const result = await provider.sqliteQuery(instance, {
374 sql: parsed.sql,
375 params: parsed.params ?? [],
376 mode,
377 maxRows,
378 });
379 await touchInstance(ctx, task, instance, provider, mode === "write");
380 return sqliteQueryOutputSchema.parse({
381 instanceId: instance.id,
382 ...result,
383 });
384 }
385
386 throw new Error(`Unsupported storage system tool: ${toolPath}`);
387}
executor/packages/database/src/runtime/tool_invocation.ts+9−2
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
32 discoverInputSchema,32 discoverInputSchema,
33 discoverOutputSchema,33 discoverOutputSchema,
34} from "./discovery_tool_contracts";34} from "./discovery_tool_contracts";
35import { isStorageSystemToolPath, runStorageSystemTool } from "./storage_tools";
35 36
36const payloadRecordSchema = z.record(z.unknown());37const payloadRecordSchema = z.record(z.unknown());
37 38
@@ -367,11 +368,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 329 unmodified lines
367 };368 };
368 369
369 // Fast system tools are handled server-side from the registry.370 // Fast system tools are handled server-side from the registry.
370 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {371 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools" || isStorageSystemToolPath(toolPath)) {
372 const baseSystemTool = baseTools.get(toolPath);
371 const systemToolDecision = getDecisionForContext(373 const systemToolDecision = getDecisionForContext(
372 {374 {
373 path: toolPath,375 path: toolPath,
374 approval: "auto",376 approval: baseSystemTool?.approval ?? "auto",
375 },377 },
376 {378 {
377 workspaceId: task.workspaceId,379 workspaceId: task.workspaceId,
@@ -391,6 +393,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 13 unmodified lines
391 });393 });
392 }394 }
393 395
396 if (isStorageSystemToolPath(toolPath)) {
397 const output = await runStorageSystemTool(ctx, task, toolPath, input);
398 return await finalizeImmediateTool(output);
399 }
400
394 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {401 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
395 workspaceId: task.workspaceId,402 workspaceId: task.workspaceId,
396 accountId: task.accountId,403 accountId: task.accountId,
executor/packages/database/src/runtime/workspace_tools.ts+4−1
@@ -1399,7 +1399,10 @@ async function loadWorkspaceToolInventoryForContext( · 1398 unmodified lines
1399 });1399 });
1400 const toolsByPath = new Map<string, ToolDescriptor>();1400 const toolsByPath = new Map<string, ToolDescriptor>();
1401 for (const tool of baseDescriptors) toolsByPath.set(tool.path, tool);1401 for (const tool of baseDescriptors) toolsByPath.set(tool.path, tool);
1402 for (const tool of registryDescriptors) toolsByPath.set(tool.path, tool);1402 for (const tool of registryDescriptors) {
1403 if (baseTools.has(tool.path)) continue;
1404 toolsByPath.set(tool.path, tool);
1405 }
1403 const tools = [...toolsByPath.values()];1406 const tools = [...toolsByPath.values()];
1404 1407
1405 const sourceQuality = includeSourceMeta ? result.sourceQuality : {};1408 const sourceQuality = includeSourceMeta ? result.sourceQuality : {};
executor/packages/database/src/types/sqljs-asm.d.ts+23−0
@@ -0,0 +1,23 @@
1declare module "sql.js/dist/sql-asm.js" {
2 type SqlJsRow = Array<string | number | null>;
3
4 export interface SqlJsQueryResult {
5 columns: string[];
6 values: SqlJsRow[];
7 }
8
9 export interface SqlJsDatabase {
10 run(sql: string, params?: Array<string | number | null>): void;
11 exec(sql: string, params?: Array<string | number | null>): SqlJsQueryResult[];
12 export(): Uint8Array;
13 getRowsModified(): number;
14 close(): void;
15 }
16
17 export interface SqlJsModule {
18 Database: new (data?: Uint8Array) => SqlJsDatabase;
19 }
20
21 const initialize: (options?: unknown) => Promise<SqlJsModule>;
22 export default initialize;
23}
executor/packages/runner-sandbox-host/package.json+1−0
@@ -17,6 +17,7 @@ · 16 unmodified lines
17 },17 },
18 "dependencies": {18 "dependencies": {
19 "@executor/database": "workspace:*",19 "@executor/database": "workspace:*",
20 "agentfs-sdk": "^0.6.0",
20 "better-result": "^2.7.0",21 "better-result": "^2.7.0",
21 "convex": "^1.31.7"22 "convex": "^1.31.7"
22 }23 }
executor/packages/runner-sandbox-host/src/index.ts+380−2
@@ -34,7 +34,8 @@ · 33 unmodified lines
34 */34 */
35 35
36import { Result } from "better-result";36import { Result } from "better-result";
37import { WorkerEntrypoint } from "cloudflare:workers";37import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
38import { AgentFS, type CloudflareStorage } from "agentfs-sdk/cloudflare";
38import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport";39import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport";
39import GLOBALS_MODULE from "./isolate/globals.isolate.js";40import GLOBALS_MODULE from "./isolate/globals.isolate.js";
40import HARNESS_CODE from "./isolate/harness.isolate.js";41import HARNESS_CODE from "./isolate/harness.isolate.js";
@@ -42,13 +43,363 @@ import { authorizeRunRequest } from "./auth"; · 1 unmodified line
42import { callToolWithBridge, getBridgePropsFromContext } from "./bridge";43import { callToolWithBridge, getBridgePropsFromContext } from "./bridge";
43import { parseRunRequest } from "./request";44import { parseRunRequest } from "./request";
44import { executeSandboxRun } from "./sandbox";45import { executeSandboxRun } from "./sandbox";
45import type { Env, RunResult, ToolCallResult } from "./types";46import type { Env, RunResult, StorageProxyRequest, ToolCallResult } from "./types";
46 47
47const failedResult = (error: string): RunResult => ({48const failedResult = (error: string): RunResult => ({
48 status: "failed",49 status: "failed",
49 error,50 error,
50});51});
51 52
53function jsonResponse(body: unknown, status = 200): Response {
54 return Response.json(body, { status });
55}
56
57function toStorageError(error: unknown): string {
58 return error instanceof Error ? error.message : String(error);
59}
60
61function isReadOnlySql(sql: string): boolean {
62 const trimmed = sql.trim().toLowerCase();
63 return trimmed.startsWith("select")
64 || trimmed.startsWith("pragma")
65 || trimmed.startsWith("explain")
66 || trimmed.startsWith("with");
67}
68
69function isNotFoundError(error: unknown): boolean {
70 if (!error || typeof error !== "object") {
71 return false;
72 }
73 const code = (error as { code?: unknown }).code;
74 return code === "ENOENT" || code === "NOT_FOUND";
75}
76
77function bytesToBase64(bytes: Uint8Array): string {
78 let binary = "";
79 const chunk = 0x8000;
80 for (let i = 0; i < bytes.length; i += chunk) {
81 const slice = bytes.subarray(i, i + chunk);
82 binary += String.fromCharCode(...slice);
83 }
84 return btoa(binary);
85}
86
87function base64ToBytes(base64: string): Uint8Array {
88 const binary = atob(base64);
89 const bytes = new Uint8Array(binary.length);
90 for (let i = 0; i < binary.length; i += 1) {
91 bytes[i] = binary.charCodeAt(i);
92 }
93 return bytes;
94}
95
96type StorageRequestPayload = {
97 operation?: string;
98 payload?: Record<string, unknown>;
99};
100
101export class AgentFSStorageObject extends DurableObject<Env> {
102 private readonly fs: AgentFS;
103
104 constructor(ctx: DurableObjectState, env: Env) {
105 super(ctx, env);
106 this.fs = AgentFS.create(ctx.storage as unknown as CloudflareStorage);
107 this.ensureKvTable();
108 }
109
110 private ensureKvTable() {
111 this.ctx.storage.sql.exec(`
112 CREATE TABLE IF NOT EXISTS kv_store (
113 key TEXT PRIMARY KEY,
114 value TEXT NOT NULL,
115 created_at INTEGER DEFAULT (unixepoch()),
116 updated_at INTEGER DEFAULT (unixepoch())
117 )
118 `);
119 }
120
121 private parseRequestPayload(body: unknown): StorageRequestPayload {
122 if (!body || typeof body !== "object" || Array.isArray(body)) {
123 return {};
124 }
125 const record = body as Record<string, unknown>;
126 return {
127 operation: typeof record.operation === "string" ? record.operation : undefined,
128 payload: record.payload && typeof record.payload === "object" && !Array.isArray(record.payload)
129 ? record.payload as Record<string, unknown>
130 : {},
131 };
132 }
133
134 private sqliteQuery(sql: string, params: unknown[], mode: "read" | "write", maxRows: number) {
135 if (mode === "read" && !isReadOnlySql(sql)) {
136 throw new Error("sqlite.query rejected a non-read SQL statement in read mode");
137 }
138
139 const cursor = this.ctx.storage.sql.exec(sql, ...params);
140 if (mode === "write") {
141 return {
142 mode,
143 rowCount: 0,
144 changes: cursor.rowsWritten,
145 };
146 }
147
148 const rows = cursor.toArray().slice(0, Math.max(1, Math.floor(maxRows))) as Record<string, unknown>[];
149 return {
150 mode,
151 rows,
152 rowCount: rows.length,
153 };
154 }
155
156 async fetch(request: Request): Promise<Response> {
157 if (request.method !== "POST") {
158 return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
159 }
160
161 const body = await request.json().catch(() => null);
162 const parsed = this.parseRequestPayload(body);
163 const operation = parsed.operation?.trim();
164 const payload = parsed.payload ?? {};
165
166 if (!operation) {
167 return jsonResponse({ ok: false, error: "Missing operation" }, 400);
168 }
169
170 try {
171 if (operation === "fs.read") {
172 const path = typeof payload.path === "string" ? payload.path : "";
173 if (!path) {
174 return jsonResponse({ ok: false, error: "fs.read requires path" }, 400);
175 }
176 const encoding = payload.encoding === "base64" ? "base64" : "utf8";
177 if (encoding === "base64") {
178 const buffer = await this.fs.readFile(path);
179 return jsonResponse({ ok: true, data: { content: bytesToBase64(buffer), bytes: buffer.length } });
180 }
181
182 const content = await this.fs.readFile(path, "utf8");
183 return jsonResponse({ ok: true, data: { content, bytes: new TextEncoder().encode(content).length } });
184 }
185
186 if (operation === "fs.write") {
187 const path = typeof payload.path === "string" ? payload.path : "";
188 const content = typeof payload.content === "string" ? payload.content : "";
189 if (!path) {
190 return jsonResponse({ ok: false, error: "fs.write requires path" }, 400);
191 }
192 const encoding = payload.encoding === "base64" ? "base64" : "utf8";
193 const toWrite = encoding === "base64" ? base64ToBytes(content) : content;
194 await this.fs.writeFile(path, toWrite);
195 const bytesWritten = encoding === "base64"
196 ? base64ToBytes(content).length
197 : new TextEncoder().encode(content).length;
198 return jsonResponse({
199 ok: true,
200 data: {
201 bytesWritten,
202 },
203 });
204 }
205
206 if (operation === "fs.readdir") {
207 const path = typeof payload.path === "string" && payload.path.trim().length > 0 ? payload.path : "/";
208 const entries = await this.fs.readdirPlus(path);
209 return jsonResponse({
210 ok: true,
211 data: {
212 entries: entries.map((entry) => ({
213 name: entry.name,
214 type: entry.stats.isDirectory()
215 ? "directory"
216 : entry.stats.isFile()
217 ? "file"
218 : entry.stats.isSymbolicLink()
219 ? "symlink"
220 : "unknown",
221 size: entry.stats.size,
222 mtime: entry.stats.mtime,
223 })),
224 },
225 });
226 }
227
228 if (operation === "fs.stat") {
229 const path = typeof payload.path === "string" ? payload.path : "";
230 if (!path) {
231 return jsonResponse({ ok: false, error: "fs.stat requires path" }, 400);
232 }
233
234 try {
235 const stat = await this.fs.stat(path);
236 return jsonResponse({
237 ok: true,
238 data: {
239 exists: true,
240 type: stat.isDirectory()
241 ? "directory"
242 : stat.isFile()
243 ? "file"
244 : stat.isSymbolicLink()
245 ? "symlink"
246 : "unknown",
247 size: stat.size,
248 mode: stat.mode,
249 mtime: stat.mtime,
250 ctime: stat.ctime,
251 },
252 });
253 } catch (error) {
254 if (isNotFoundError(error)) {
255 return jsonResponse({ ok: true, data: { exists: false } });
256 }
257 throw error;
258 }
259 }
260
261 if (operation === "fs.mkdir") {
262 const path = typeof payload.path === "string" ? payload.path : "";
263 if (!path) {
264 return jsonResponse({ ok: false, error: "fs.mkdir requires path" }, 400);
265 }
266
267 await this.fs.mkdir(path);
268 return jsonResponse({ ok: true, data: { ok: true } });
269 }
270
271 if (operation === "fs.remove") {
272 const path = typeof payload.path === "string" ? payload.path : "";
273 if (!path) {
274 return jsonResponse({ ok: false, error: "fs.remove requires path" }, 400);
275 }
276
277 await this.fs.rm(path, {
278 recursive: payload.recursive === true,
279 force: payload.force === true,
280 });
281 return jsonResponse({ ok: true, data: { ok: true } });
282 }
283
284 if (operation === "kv.get") {
285 const key = typeof payload.key === "string" ? payload.key : "";
286 if (!key) {
287 return jsonResponse({ ok: false, error: "kv.get requires key" }, 400);
288 }
289
290 this.ensureKvTable();
291 const row = this.ctx.storage.sql.exec<{ value: string }>("SELECT value FROM kv_store WHERE key = ?", key).one();
292 if (!row || typeof row.value !== "string") {
293 return jsonResponse({ ok: true, data: { value: undefined } });
294 }
295 return jsonResponse({ ok: true, data: { value: JSON.parse(row.value) } });
296 }
297
298 if (operation === "kv.set") {
299 const key = typeof payload.key === "string" ? payload.key : "";
300 if (!key) {
301 return jsonResponse({ ok: false, error: "kv.set requires key" }, 400);
302 }
303
304 this.ensureKvTable();
305 this.ctx.storage.sql.exec(
306 `
307 INSERT INTO kv_store (key, value, updated_at)
308 VALUES (?, ?, unixepoch())
309 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
310 `,
311 key,
312 JSON.stringify(payload.value),
313 );
314 return jsonResponse({ ok: true, data: { ok: true } });
315 }
316
317 if (operation === "kv.list") {
318 this.ensureKvTable();
319 const prefix = typeof payload.prefix === "string" ? payload.prefix : "";
320 const limit = typeof payload.limit === "number" && Number.isFinite(payload.limit)
321 ? Math.max(1, Math.min(500, Math.floor(payload.limit)))
322 : 100;
323 const escaped = prefix.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
324 const rows = this.ctx.storage.sql
325 .exec<{ key: string; value: string }>(
326 "SELECT key, value FROM kv_store WHERE key LIKE ? ESCAPE '\\\\' ORDER BY key LIMIT ?",
327 `${escaped}%`,
328 limit,
329 )
330 .toArray();
331
332 return jsonResponse({
333 ok: true,
334 data: {
335 items: rows.map((row) => ({
336 key: row.key,
337 value: JSON.parse(row.value),
338 })),
339 },
340 });
341 }
342
343 if (operation === "kv.delete") {
344 const key = typeof payload.key === "string" ? payload.key : "";
345 if (!key) {
346 return jsonResponse({ ok: false, error: "kv.delete requires key" }, 400);
347 }
348
349 this.ensureKvTable();
350 this.ctx.storage.sql.exec("DELETE FROM kv_store WHERE key = ?", key);
351 return jsonResponse({ ok: true, data: { ok: true } });
352 }
353
354 if (operation === "sqlite.query") {
355 const sql = typeof payload.sql === "string" ? payload.sql : "";
356 if (!sql) {
357 return jsonResponse({ ok: false, error: "sqlite.query requires sql" }, 400);
358 }
359
360 const mode = payload.mode === "write" ? "write" : "read";
361 const params = Array.isArray(payload.params)
362 ? payload.params.filter((value) =>
363 value === null
364 || typeof value === "string"
365 || typeof value === "number"
366 || typeof value === "boolean"
367 ).map((value) => (typeof value === "boolean" ? (value ? 1 : 0) : value))
368 : [];
369 const maxRows = typeof payload.maxRows === "number" && Number.isFinite(payload.maxRows)
370 ? Math.max(1, Math.min(1000, Math.floor(payload.maxRows)))
371 : 200;
372
373 const result = this.sqliteQuery(sql, params, mode, maxRows);
374 return jsonResponse({ ok: true, data: result });
375 }
376
377 if (operation === "instance.usage") {
378 const stats = await this.fs.statfs();
379 return jsonResponse({
380 ok: true,
381 data: {
382 sizeBytes: stats.bytesUsed,
383 fileCount: stats.inodes,
384 },
385 });
386 }
387
388 if (operation === "instance.delete") {
389 const storageWithDelete = this.ctx.storage as unknown as { deleteAll?: () => Promise<void> | void };
390 if (typeof storageWithDelete.deleteAll === "function") {
391 await storageWithDelete.deleteAll();
392 }
393 return jsonResponse({ ok: true, data: { ok: true } });
394 }
395
396 return jsonResponse({ ok: false, error: `Unsupported storage operation: ${operation}` }, 400);
397 } catch (error) {
398 return jsonResponse({ ok: false, error: toStorageError(error) }, 500);
399 }
400 }
401}
402
52// ── Tool Bridge Entrypoint ───────────────────────────────────────────────────403// ── Tool Bridge Entrypoint ───────────────────────────────────────────────────
53//404//
54// This class is exposed as a named entrypoint on the host Worker. A loopback405// This class is exposed as a named entrypoint on the host Worker. A loopback
@@ -80,6 +431,33 @@ export default { · 25 unmodified lines
80 return Response.json({ ok: true });431 return Response.json({ ok: true });
81 }432 }
82 433
434 if (request.method === "POST" && url.pathname === "/v1/storage") {
435 const authError = authorizeRunRequest(request, env.AUTH_TOKEN);
436 if (authError) {
437 return authError;
438 }
439
440 const parsed = await request.json().catch(() => null) as StorageProxyRequest | null;
441 const instanceId = parsed?.instanceId?.trim() ?? "";
442 const operation = parsed?.operation?.trim() ?? "";
443 if (!instanceId || !operation) {
444 return jsonResponse({ ok: false, error: "instanceId and operation are required" }, 400);
445 }
446
447 const objectId = env.AGENTFS.idFromName(instanceId);
448 const stub = env.AGENTFS.get(objectId);
449 return await stub.fetch("https://agentfs.internal/ops", {
450 method: "POST",
451 headers: {
452 "content-type": "application/json",
453 },
454 body: JSON.stringify({
455 operation,
456 payload: parsed?.payload ?? {},
457 }),
458 });
459 }
460
83 if (request.method !== "POST" || url.pathname !== "/v1/runs") {461 if (request.method !== "POST" || url.pathname !== "/v1/runs") {
84 return Response.json({ error: "Not found" }, { status: 404 });462 return Response.json({ error: "Not found" }, { status: 404 });
85 }463 }
executor/packages/runner-sandbox-host/src/types.ts+20−0
@@ -1,6 +1,20 @@
1export interface Env {1export interface Env {
2 LOADER: WorkerLoader;2 LOADER: WorkerLoader;
3 AUTH_TOKEN: string;3 AUTH_TOKEN: string;
4 AGENTFS: DurableObjectNamespace;
5}
6
7export interface DurableObjectNamespace {
8 idFromName(name: string): DurableObjectId;
9 get(id: DurableObjectId): DurableObjectStub;
10}
11
12export interface DurableObjectId {
13 toString(): string;
14}
15
16export interface DurableObjectStub {
17 fetch(input: string | Request, init?: RequestInit): Promise<Response>;
4}18}
5 19
6/** Dynamic Worker Loader binding — provided by the `worker_loaders` config. */20/** Dynamic Worker Loader binding — provided by the `worker_loaders` config. */
@@ -72,6 +86,12 @@ export interface BridgeEntrypointContext { · 65 unmodified lines
72 props: BridgeProps;86 props: BridgeProps;
73}87}
74 88
89export interface StorageProxyRequest {
90 instanceId: string;
91 operation: string;
92 payload?: Record<string, unknown>;
93}
94
75export interface WorkerEntrypointExports {95export interface WorkerEntrypointExports {
76 ToolBridge(opts: { props: BridgeProps }): unknown;96 ToolBridge(opts: { props: BridgeProps }): unknown;
77}97}
executor/packages/runner-sandbox-host/wrangler.jsonc+19−1
@@ -2,7 +2,7 @@ · 1 unmodified line
2 "name": "executor-sandbox-host",2 "name": "executor-sandbox-host",
3 "main": "src/index.ts",3 "main": "src/index.ts",
4 "compatibility_date": "2025-06-01",4 "compatibility_date": "2025-06-01",
5 "compatibility_flags": ["enable_ctx_exports"],5 "compatibility_flags": ["enable_ctx_exports", "nodejs_compat"],
6 6
7 // Treat *.isolate.js files as raw text strings so they can be passed to the7 // Treat *.isolate.js files as raw text strings so they can be passed to the
8 // Dynamic Worker Loader API as module source code. This lets us author the8 // Dynamic Worker Loader API as module source code. This lets us author the
@@ -18,6 +18,24 @@ · 9 unmodified lines
18 {18 {
19 "binding": "LOADER"19 "binding": "LOADER"
20 }20 }
21 ],
22
23 "durable_objects": {
24 "bindings": [
25 {
26 "name": "AGENTFS",
27 "class_name": "AgentFSStorageObject"
28 }
29 ]
30 },
31
32 "migrations": [
33 {
34 "tag": "v1",
35 "new_sqlite_classes": [
36 "AgentFSStorageObject"
37 ]
38 }
21 ]39 ]
22 40
23 // Environment secrets (set via `wrangler secret put AUTH_TOKEN`):41 // Environment secrets (set via `wrangler secret put AUTH_TOKEN`):
executor/scripts/dev/storage/smoke.execute-flow.ts+375−0
@@ -0,0 +1,375 @@
1#!/usr/bin/env bun
2
3import assert from "node:assert/strict";
4import { ConvexHttpClient } from "convex/browser";
5import { $ } from "bun";
6import { api } from "../../../packages/database/convex/_generated/api";
7
8type RuntimeId = "local-bun" | "cloudflare-worker-loader";
9const EXECUTOR_ROOT = new URL("../../../", import.meta.url).pathname;
10const DATABASE_ROOT = new URL("../../../packages/database/", import.meta.url).pathname;
11
12function parseArgs(argv: string[]): {
13 runtimeId?: RuntimeId;
14 timeoutMs: number;
15 deployWorker: boolean;
16 deployConvex: boolean;
17} {
18 let runtimeId: RuntimeId | undefined;
19 let timeoutMs = 120_000;
20 let deployWorker = false;
21 let deployConvex = false;
22
23 for (let index = 0; index < argv.length; index += 1) {
24 const arg = argv[index];
25 if (arg === "--runtime") {
26 const value = argv[index + 1];
27 if (value !== "local-bun" && value !== "cloudflare-worker-loader") {
28 throw new Error("--runtime must be one of: local-bun, cloudflare-worker-loader");
29 }
30 runtimeId = value;
31 index += 1;
32 continue;
33 }
34
35 if (arg === "--timeout-ms") {
36 const value = Number.parseInt(argv[index + 1] ?? "", 10);
37 if (!Number.isFinite(value) || value <= 0) {
38 throw new Error("--timeout-ms must be a positive integer");
39 }
40 timeoutMs = value;
41 index += 1;
42 continue;
43 }
44
45 if (arg === "--deploy-worker") {
46 deployWorker = true;
47 continue;
48 }
49
50 if (arg === "--deploy-convex") {
51 deployConvex = true;
52 continue;
53 }
54
55 throw new Error(`Unknown argument: ${arg}`);
56 }
57
58 return { runtimeId, timeoutMs, deployWorker, deployConvex };
59}
60
61function requireEnv(name: string): string {
62 const value = process.env[name]?.trim();
63 if (!value) {
64 throw new Error(`Missing required environment variable: ${name}`);
65 }
66 return value;
67}
68
69function makeClient(convexUrl: string): ConvexHttpClient {
70 return new ConvexHttpClient(convexUrl, {
71 skipConvexDeploymentUrlCheck: true,
72 });
73}
74
75function sleep(ms: number): Promise<void> {
76 return new Promise((resolve) => setTimeout(resolve, ms));
77}
78
79function parseWorkersDevUrl(output: string): string | null {
80 const match = output.match(/https:\/\/[\w.-]+\.workers\.dev/);
81 return match ? match[0] : null;
82}
83
84async function deployCloudflareWorkerAndGetUrl(): Promise<string> {
85 console.log("[storage:smoke:execute-flow] deploying runner-sandbox-host...");
86 const output = await $`bun run --cwd packages/runner-sandbox-host deploy`
87 .cwd(EXECUTOR_ROOT)
88 .env(process.env)
89 .text();
90 console.log(output.trim());
91
92 const url = parseWorkersDevUrl(output);
93 if (!url) {
94 throw new Error("Could not parse workers.dev URL from runner-sandbox-host deploy output");
95 }
96
97 return url;
98}
99
100async function setCloudflareWorkerAuthSecret(token: string): Promise<void> {
101 console.log("[storage:smoke:execute-flow] updating runner-sandbox-host AUTH_TOKEN secret...");
102 await $`printf "%s" ${token} | bun run --cwd packages/runner-sandbox-host wrangler secret put AUTH_TOKEN`
103 .cwd(EXECUTOR_ROOT)
104 .env(process.env)
105 .quiet(false);
106}
107
108async function maybeDeployConvex(enabled: boolean): Promise<void> {
109 if (!enabled) {
110 return;
111 }
112
113 console.log("[storage:smoke:execute-flow] deploying Convex functions...");
114 await $`bunx --package convex convex dev --once --typecheck disable`
115 .cwd(DATABASE_ROOT)
116 .env(process.env)
117 .quiet(false);
118}
119
120async function setConvexEnvVar(key: string, value: string): Promise<void> {
121 await $`bunx --package convex convex env set ${key} ${value}`
122 .cwd(DATABASE_ROOT)
123 .env(process.env)
124 .quiet(false);
125}
126
127async function waitForSourceReady(args: {
128 client: ConvexHttpClient;
129 workspaceId: string;
130 sessionId: string;
131 sourceName: string;
132 timeoutMs: number;
133}): Promise<void> {
134 const started = Date.now();
135
136 while (Date.now() - started < args.timeoutMs) {
137 const progress = await args.client.query(api.workspace.getToolInventoryProgress, {
138 workspaceId: args.workspaceId as never,
139 sessionId: args.sessionId,
140 }) as {
141 inventoryStatus?: { state?: string; error?: string };
142 sourceStates?: Record<string, { state?: string; toolCount?: number }>;
143 warnings?: string[];
144 };
145
146 const source = progress.sourceStates?.[args.sourceName];
147 const inventoryState = progress.inventoryStatus?.state ?? "unknown";
148 const sourceState = source?.state ?? "unknown";
149 const sourceCount = source?.toolCount ?? 0;
150
151 if (inventoryState === "failed") {
152 throw new Error(
153 `Tool inventory failed for source '${args.sourceName}': ${progress.inventoryStatus?.error ?? "unknown error"}`,
154 );
155 }
156
157 if (sourceState === "ready" && sourceCount > 0) {
158 return;
159 }
160
161 await sleep(1_000);
162 }
163
164 throw new Error(`Timed out waiting for source '${args.sourceName}' to become ready`);
165}
166
167async function run(): Promise<void> {
168 const options = parseArgs(process.argv.slice(2));
169 const convexUrl = requireEnv("CONVEX_URL");
170 const client = makeClient(convexUrl);
171
172 const requestedRuntime: RuntimeId = options.runtimeId
173 ?? (process.env.CLOUDFLARE_SANDBOX_RUN_URL?.trim() ? "cloudflare-worker-loader" : "local-bun");
174
175 await maybeDeployConvex(options.deployConvex);
176
177 if (requestedRuntime === "cloudflare-worker-loader") {
178 let sandboxRunUrl = process.env.CLOUDFLARE_SANDBOX_RUN_URL?.trim();
179 let sandboxAuthToken = process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN?.trim();
180
181 if (!sandboxAuthToken || options.deployWorker) {
182 sandboxAuthToken = `smoke_${crypto.randomUUID().replace(/-/g, "")}${crypto.randomUUID().replace(/-/g, "")}`;
183 await setCloudflareWorkerAuthSecret(sandboxAuthToken);
184 }
185
186 if (!sandboxRunUrl || options.deployWorker) {
187 const workerUrl = await deployCloudflareWorkerAndGetUrl();
188 sandboxRunUrl = `${workerUrl}/v1/runs`;
189 }
190
191 if (!sandboxRunUrl || !sandboxAuthToken) {
192 throw new Error("Unable to resolve Cloudflare sandbox URL/token");
193 }
194
195 process.env.CLOUDFLARE_SANDBOX_RUN_URL = sandboxRunUrl;
196 process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = sandboxAuthToken;
197 process.env.AGENT_STORAGE_PROVIDER = "agentfs-cloudflare";
198
199 console.log("[storage:smoke:execute-flow] syncing Convex dev env vars for Cloudflare runtime/storage...");
200 await setConvexEnvVar("CLOUDFLARE_SANDBOX_RUN_URL", sandboxRunUrl);
201 await setConvexEnvVar("CLOUDFLARE_SANDBOX_AUTH_TOKEN", sandboxAuthToken);
202 await setConvexEnvVar("AGENT_STORAGE_PROVIDER", "agentfs-cloudflare");
203 } else {
204 process.env.AGENT_STORAGE_PROVIDER = "agentfs-local";
205 console.log("[storage:smoke:execute-flow] syncing Convex dev env vars for local storage backend...");
206 await setConvexEnvVar("AGENT_STORAGE_PROVIDER", "agentfs-local");
207 }
208
209 const runId = crypto.randomUUID().slice(0, 8);
210 const sessionId = `mcp_storage_e2e_${runId}`;
211 const organizationName = `Storage Smoke Org ${runId}`;
212 const sourceName = `storage-smoke-source-${runId}`;
213
214 const bootstrap = await client.mutation(api.workspace.bootstrapAnonymousSession, {
215 sessionId,
216 }) as {
217 sessionId: string;
218 accountId: string;
219 workspaceId: string;
220 };
221
222 assert.ok(bootstrap.sessionId, "bootstrap must return a sessionId");
223
224 const createdOrg = await client.mutation(api.organizations.create, {
225 sessionId: bootstrap.sessionId,
226 name: organizationName,
227 }) as {
228 organization: { id: string; name: string };
229 workspace: { id: string; name: string };
230 };
231
232 const workspaceId = createdOrg.workspace.id;
233 assert.ok(workspaceId, "organization.create must return a workspace id");
234
235 const openapiSpec = {
236 openapi: "3.1.0",
237 info: {
238 title: `Storage Smoke API ${runId}`,
239 version: "1.0.0",
240 },
241 servers: [
242 { url: "https://example.com" },
243 ],
244 paths: {
245 "/ping": {
246 get: {
247 operationId: "smoke_ping",
248 summary: "Ping endpoint",
249 responses: {
250 "200": {
251 description: "ok",
252 content: {
253 "application/json": {
254 schema: {
255 type: "object",
256 properties: {
257 ok: { type: "boolean" },
258 },
259 },
260 },
261 },
262 },
263 },
264 },
265 },
266 },
267 };
268
269 const source = await client.mutation(api.workspace.upsertToolSource, {
270 workspaceId: workspaceId as never,
271 sessionId: bootstrap.sessionId,
272 name: sourceName,
273 type: "openapi",
274 scopeType: "workspace",
275 config: {
276 spec: openapiSpec,
277 },
278 }) as { id: string; name: string };
279
280 assert.equal(source.name, sourceName, "upsertToolSource should return created source");
281
282 await client.mutation(api.workspace.regenerateToolInventory, {
283 workspaceId: workspaceId as never,
284 sessionId: bootstrap.sessionId,
285 });
286
287 await waitForSourceReady({
288 client,
289 workspaceId,
290 sessionId: bootstrap.sessionId,
291 sourceName,
292 timeoutMs: options.timeoutMs,
293 });
294
295 const sourceNameLiteral = JSON.stringify(sourceName);
296 const taskCode = [
297 `const sourceName = ${sourceNameLiteral};`,
298 "const storage = await tools.storage.open({ scopeType: 'scratch', purpose: 'execute-flow-smoke' });",
299 "const instanceId = storage.instance.id;",
300 "await tools.fs.mkdir({ instanceId, path: '/workspace' });",
301 "await tools.fs.write({ instanceId, path: '/workspace/hello.txt', content: 'hello storage execute flow' });",
302 "const file = await tools.fs.read({ instanceId, path: '/workspace/hello.txt' });",
303 "await tools.kv.set({ instanceId, key: 'discord.userId', value: 'uuid-smoke' });",
304 "const kv = await tools.kv.get({ instanceId, key: 'discord.userId' });",
305 "await tools.sqlite.query({ instanceId, mode: 'write', sql: 'CREATE TABLE IF NOT EXISTS smoke (k TEXT PRIMARY KEY, v TEXT)' });",
306 "await tools.sqlite.query({ instanceId, mode: 'write', sql: 'INSERT INTO smoke (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', params: ['discord', 'uuid-smoke'] });",
307 "const sql = await tools.sqlite.query({ instanceId, mode: 'read', sql: 'SELECT v FROM smoke WHERE k = ?', params: ['discord'], maxRows: 1 });",
308 "return {",
309 " instanceId,",
310 " provider: storage.instance.provider,",
311 " fileContent: file.content,",
312 " kvValue: kv.value,",
313 " sqlValue: Array.isArray(sql.rows) && sql.rows.length > 0 ? sql.rows[0].v : null,",
314 "};",
315 ].join("\n");
316
317 const taskOutcome = await client.action(api.executor.createTask, {
318 workspaceId: workspaceId as never,
319 sessionId: bootstrap.sessionId,
320 runtimeId: requestedRuntime,
321 waitForResult: true,
322 timeoutMs: Math.max(90_000, options.timeoutMs),
323 code: taskCode,
324 metadata: {
325 purpose: "storage-execute-flow-smoke",
326 runId,
327 },
328 }) as {
329 task: { id: string; status: string; error?: string };
330 result?: {
331 instanceId?: string;
332 provider?: string;
333 fileContent?: string;
334 kvValue?: string;
335 sqlValue?: string | null;
336 };
337 };
338
339 if (taskOutcome.task.status !== "completed") {
340 throw new Error(`Task did not complete successfully: ${taskOutcome.task.status} ${taskOutcome.task.error ?? ""}`);
341 }
342
343 const result = taskOutcome.result ?? {};
344 assert.equal(result.fileContent, "hello storage execute flow");
345 assert.equal(result.kvValue, "uuid-smoke");
346 assert.equal(result.sqlValue, "uuid-smoke");
347 assert.ok(result.instanceId, "task result must include storage instanceId");
348
349 const storageInstances = await client.query(api.workspace.listStorageInstances, {
350 workspaceId: workspaceId as never,
351 sessionId: bootstrap.sessionId,
352 scopeType: "scratch",
353 }) as Array<{ id: string; provider: string; status: string }>;
354
355 const createdInstance = storageInstances.find((item) => item.id === result.instanceId);
356 assert.ok(createdInstance, "workspace.listStorageInstances should include created scratch instance");
357
358 if (result.instanceId) {
359 await client.mutation(api.workspace.deleteStorageInstance, {
360 workspaceId: workspaceId as never,
361 sessionId: bootstrap.sessionId,
362 instanceId: result.instanceId,
363 });
364 }
365
366 console.log("[storage:smoke:execute-flow] PASS");
367 console.log(`runtime=${requestedRuntime}`);
368 console.log(`organization=${createdOrg.organization.id}`);
369 console.log(`workspace=${workspaceId}`);
370 console.log(`source=${source.id}`);
371 console.log(`instance=${result.instanceId}`);
372 console.log(`provider=${createdInstance?.provider ?? result.provider ?? "unknown"}`);
373}
374
375await run();
package.json+5−1
@@ -38,7 +38,10 @@ · 37 unmodified lines
38 "clean": "bun run --filter '*' --if-present clean && rm -rf node_modules .turbo",38 "clean": "bun run --filter '*' --if-present clean && rm -rf node_modules .turbo",
39 "knip": "bunx knip --config knip.json",39 "knip": "bunx knip --config knip.json",
40 "db:clear:executor": "bun with-env bun run executor/scripts/dev/wipe/convex-db.ts",40 "db:clear:executor": "bun with-env bun run executor/scripts/dev/wipe/convex-db.ts",
41 "convex:codegen": "bunx --cwd executor convex codegen"41 "convex:codegen": "bunx --cwd executor convex codegen",
42 "smoke:storage:execute-flow:local": "bun with-env bun run --cwd executor smoke:storage:execute-flow:local",
43 "smoke:storage:execute-flow": "bun with-env bun run --cwd executor smoke:storage:execute-flow",
44 "smoke:storage:execute-flow:cloudflare": "bun with-env bun run --cwd executor smoke:storage:execute-flow:cloudflare"
42 },45 },
43 "dependencies": {46 "dependencies": {
44 "@apidevtools/swagger-parser": "^12.1.0",47 "@apidevtools/swagger-parser": "^12.1.0",
@@ -46,6 +49,7 @@ · 1 unmodified line
46 "@convex-dev/stripe": "^0.1.3",49 "@convex-dev/stripe": "^0.1.3",
47 "@convex-dev/workos-authkit": "^0.1.6",50 "@convex-dev/workos-authkit": "^0.1.6",
48 "@workos-inc/node": "^8.2.0",51 "@workos-inc/node": "^8.2.0",
52 "agentfs-sdk": "^0.6.0",
49 "convex": "^1.31.7",53 "convex": "^1.31.7",
50 "convex-helpers": "^0.1.111",54 "convex-helpers": "^0.1.111",
51 "jose": "^6.1.0",55 "jose": "^6.1.0",