History

Enhance storage access controls and improve storage instance management

- Updated `.env.example` to include a new optional variable for disabling metadata writes on read-only storage. - Improved the `StoragePanel` component to utilize a parsed file preview for better JSON handling. - Added comprehensive tests for storage lifecycle access controls, ensuring non-admin members cannot open, close, or delete shared workspace storage. - Enhanced storage instance management by implementing organization admin checks for opening, closing, and deleting storage instances. - Introduced debouncing for storage touch mutations to optimize performance during hot read updates.

Rhys Sullivan committed 8 months ago

Commit 57907fb1 parent 8f1ee8f31 files · +2761 −1654Browse files

Unified · Split

31 files
  1. .env.example +2 −0
  2. executor/apps/web/src/components/tools/storage.tsx +6 −2
  3. executor/packages/database/convex/access-controls.test.ts +133 −0
  4. executor/packages/database/convex/database.test.ts +64 −0
  5. executor/packages/database/convex/database/storage_instances.ts +79 −8
  6. executor/packages/database/convex/executorNode.ts +9 −1
  7. executor/packages/database/convex/workspace.ts +1 −2
  8. executor/packages/database/src/runtime/base_tools.ts +9 −3
  9. executor/packages/database/src/runtime/policy.test.ts +58 −0
  10. executor/packages/database/src/runtime/storage_provider.local.test.ts +101 −0
  11. executor/packages/database/src/runtime/storage_provider.ts +63 −16
  12. executor/packages/database/src/runtime/storage_tool_contracts.test.ts +30 −0
  13. executor/packages/database/src/runtime/storage_tool_contracts.ts +5 −791
  14. executor/packages/database/src/runtime/storage_tool_contracts/fs.ts +216 −0
  15. executor/packages/database/src/runtime/storage_tool_contracts/kv.ts +187 −0
  16. executor/packages/database/src/runtime/storage_tool_contracts/shared.ts +109 −0
  17. executor/packages/database/src/runtime/storage_tool_contracts/sqlite.ts +176 −0
  18. executor/packages/database/src/runtime/storage_tool_contracts/storage.ts +130 −0
  19. executor/packages/database/src/runtime/storage_tooling/context.ts +366 −0
  20. executor/packages/database/src/runtime/storage_tooling/handlers_fs.ts +111 −0
  21. executor/packages/database/src/runtime/storage_tooling/handlers_kv.ts +105 −0
  22. executor/packages/database/src/runtime/storage_tooling/handlers_sqlite.ts +151 −0
  23. executor/packages/database/src/runtime/storage_tooling/handlers_storage.ts +108 −0
  24. executor/packages/database/src/runtime/storage_tools.test.ts +23 −0
  25. executor/packages/database/src/runtime/storage_tools.ts +40 −756
  26. executor/packages/database/src/runtime/storage_touch_policy.test.ts +24 −0
  27. executor/packages/database/src/runtime/storage_touch_policy.ts +16 −0
  28. executor/packages/database/src/runtime/storage_usage_refresh.test.ts +83 −0
  29. executor/packages/database/src/runtime/storage_usage_refresh.ts +82 −0
  30. executor/packages/database/src/runtime/tool_invocation.approval.test.ts +153 −0
  31. executor/packages/database/src/runtime/tool_invocation.ts +121 −75
.env.example+2−0
@@ -19,6 +19,8 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 18 unmodified lines
1919 # Hosted Convex deployments must use AGENT_STORAGE_PROVIDER=agentfs-cloudflare.
2020 # AGENT_STORAGE_PROVIDER=agentfs-local
2121 # AGENT_STORAGE_ROOT=/tmp/executor-agentfs
22+# Optional: disable metadata writes for read-only storage inspector/tool reads.
23+# AGENT_STORAGE_TOUCH_ON_READ=false
2224 # For Cloudflare Durable Objects storage proxy (served by runner-sandbox-host):
2325 # AGENT_STORAGE_PROVIDER=agentfs-cloudflare
2426 # Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config.
executor/apps/web/src/components/tools/storage.tsx+6−2
@@ -354,6 +354,10 @@ export function StoragePanel({ · 353 unmodified lines
354354 const canInspect = Boolean(workspaceId && selectedInstance);
355355 const sqlRows = useMemo(() => (sqlResult?.rows ?? []) as Array<Record<string, unknown>>, [sqlResult]);
356356 const sqlColumns = useMemo(() => collectSqlColumns(sqlRows), [sqlRows]);
357+ const parsedFilePreviewJson = useMemo(
358+ () => (isJsonLike(filePreviewContent) ? tryParseJson(filePreviewContent) : null),
359+ [filePreviewContent],
360+ );
357361 const visibleSqlObjects = useMemo(
358362 () => sqlObjects.filter((entry) => sqlShowInternalObjects || !isInternalSqlObject(entry.name)),
359363 [sqlObjects, sqlShowInternalObjects],
@@ -905,8 +909,8 @@ export function StoragePanel({ · 545 unmodified lines
905909 <Skeleton key={i} className="h-4 rounded" style={{ width: `${40 + Math.random() * 50}%` }} />
906910 ))}
907911 </div>
908− ) : isJsonLike(filePreviewContent) && tryParseJson(filePreviewContent) !== null ? (
909− <JsonPreview data={tryParseJson(filePreviewContent)} />
912+ ) : parsedFilePreviewJson !== null ? (
913+ <JsonPreview data={parsedFilePreviewJson} />
910914 ) : (
911915 <pre className="font-mono text-xs leading-relaxed text-foreground/80 whitespace-pre-wrap break-words">
912916 {filePreviewContent}
executor/packages/database/convex/access-controls.test.ts+133−0
@@ -1608,6 +1608,139 @@ describe("credential security", () => { · 1607 unmodified lines
16081608 });
16091609 });
16101610
1611+describe("storage lifecycle access controls", () => {
1612+ test("non-admin member cannot open workspace-scoped storage", async () => {
1613+ const previousStorageProvider = process.env.AGENT_STORAGE_PROVIDER;
1614+ process.env.AGENT_STORAGE_PROVIDER = "agentfs-cloudflare";
1615+ try {
1616+ const t = setup();
1617+ const owner = await seedUser(t, { subject: "storage-open-owner" });
1618+ const member = await seedUser(t, { subject: "storage-open-member" });
1619+
1620+ await addOrgMember(t, {
1621+ organizationId: owner.organizationId,
1622+ accountId: member.accountId,
1623+ role: "member",
1624+ });
1625+ await addWorkspaceMember(t, {
1626+ workspaceId: owner.workspaceId,
1627+ accountId: member.accountId,
1628+ role: "member",
1629+ });
1630+
1631+ const authedMember = t.withIdentity({ subject: "storage-open-member" });
1632+
1633+ await expect(
1634+ authedMember.mutation(api.workspace.openStorageInstance, {
1635+ workspaceId: owner.workspaceId,
1636+ scopeType: "workspace",
1637+ durability: "durable",
1638+ }),
1639+ ).rejects.toThrow("Only organization admins can open workspace or organization storage instances");
1640+ } finally {
1641+ if (previousStorageProvider === undefined) {
1642+ delete process.env.AGENT_STORAGE_PROVIDER;
1643+ } else {
1644+ process.env.AGENT_STORAGE_PROVIDER = previousStorageProvider;
1645+ }
1646+ }
1647+ });
1648+
1649+ test("non-admin member cannot close or delete shared workspace storage", async () => {
1650+ const previousStorageProvider = process.env.AGENT_STORAGE_PROVIDER;
1651+ process.env.AGENT_STORAGE_PROVIDER = "agentfs-cloudflare";
1652+ try {
1653+ const t = setup();
1654+ const owner = await seedUser(t, { subject: "storage-close-owner" });
1655+ const member = await seedUser(t, { subject: "storage-close-member" });
1656+
1657+ await addOrgMember(t, {
1658+ organizationId: owner.organizationId,
1659+ accountId: member.accountId,
1660+ role: "member",
1661+ });
1662+ await addWorkspaceMember(t, {
1663+ workspaceId: owner.workspaceId,
1664+ accountId: member.accountId,
1665+ role: "member",
1666+ });
1667+
1668+ const authedOwner = t.withIdentity({ subject: "storage-close-owner" });
1669+ const instance = await authedOwner.mutation(api.workspace.openStorageInstance, {
1670+ workspaceId: owner.workspaceId,
1671+ scopeType: "workspace",
1672+ durability: "durable",
1673+ purpose: "shared cache",
1674+ });
1675+
1676+ const authedMember = t.withIdentity({ subject: "storage-close-member" });
1677+
1678+ await expect(
1679+ authedMember.mutation(api.workspace.closeStorageInstance, {
1680+ workspaceId: owner.workspaceId,
1681+ instanceId: instance.id,
1682+ }),
1683+ ).rejects.toThrow("Only organization admins can close workspace storage instances");
1684+
1685+ await expect(
1686+ authedMember.mutation(api.workspace.deleteStorageInstance, {
1687+ workspaceId: owner.workspaceId,
1688+ instanceId: instance.id,
1689+ }),
1690+ ).rejects.toThrow("Only organization admins can delete workspace storage instances");
1691+ } finally {
1692+ if (previousStorageProvider === undefined) {
1693+ delete process.env.AGENT_STORAGE_PROVIDER;
1694+ } else {
1695+ process.env.AGENT_STORAGE_PROVIDER = previousStorageProvider;
1696+ }
1697+ }
1698+ });
1699+
1700+ test("non-admin member can delete their own scratch storage", async () => {
1701+ const previousStorageProvider = process.env.AGENT_STORAGE_PROVIDER;
1702+ process.env.AGENT_STORAGE_PROVIDER = "agentfs-cloudflare";
1703+ try {
1704+ const t = setup();
1705+ const owner = await seedUser(t, { subject: "storage-scratch-owner" });
1706+ const member = await seedUser(t, { subject: "storage-scratch-member" });
1707+
1708+ await addOrgMember(t, {
1709+ organizationId: owner.organizationId,
1710+ accountId: member.accountId,
1711+ role: "member",
1712+ });
1713+ await addWorkspaceMember(t, {
1714+ workspaceId: owner.workspaceId,
1715+ accountId: member.accountId,
1716+ role: "member",
1717+ });
1718+
1719+ const authedMember = t.withIdentity({ subject: "storage-scratch-member" });
1720+ const instance = await authedMember.mutation(api.workspace.openStorageInstance, {
1721+ workspaceId: owner.workspaceId,
1722+ scopeType: "scratch",
1723+ durability: "ephemeral",
1724+ purpose: "member scratch",
1725+ });
1726+
1727+ const deleted = await authedMember.mutation(api.workspace.deleteStorageInstance, {
1728+ workspaceId: owner.workspaceId,
1729+ instanceId: instance.id,
1730+ });
1731+
1732+ expect(deleted?.status).toBe("deleted");
1733+ } finally {
1734+ if (previousStorageProvider === undefined) {
1735+ delete process.env.AGENT_STORAGE_PROVIDER;
1736+ } else {
1737+ process.env.AGENT_STORAGE_PROVIDER = previousStorageProvider;
1738+ }
1739+ }
1740+ });
1741+
1742+});
1743+
16111744 describe("role hierarchy validation", () => {
16121745 test("admin role grants access to admin-only operations", async () => {
16131746 const t = setup();
executor/packages/database/convex/database.test.ts+64−0
@@ -290,3 +290,67 @@ test("credentials can link one connection to multiple sources", async () => { · 289 unmodified lines
290290 });
291291 expect(relinked?.secretJson).toEqual({ token: "token_v2" });
292292 });
293+
294+test("storage touch mutation debounces hot read updates", async () => {
295+ const previousConvexUrl = process.env.CONVEX_URL;
296+ const previousConvexSiteUrl = process.env.CONVEX_SITE_URL;
297+ process.env.CONVEX_URL = "http://127.0.0.1:3210";
298+ process.env.CONVEX_SITE_URL = "http://127.0.0.1:3211";
299+
300+ const t = setup();
301+ const wsId = await seedWorkspace(t, "ws_touch");
302+ const accountId = await seedAccount(t, "touch-account");
303+ try {
304+ const opened = await t.mutation(internal.database.openStorageInstance, {
305+ workspaceId: wsId,
306+ accountId,
307+ scopeType: "scratch",
308+ durability: "ephemeral",
309+ purpose: "touch debounce",
310+ });
311+
312+ const firstTouch = await t.mutation(internal.database.touchStorageInstance, {
313+ workspaceId: wsId,
314+ accountId,
315+ instanceId: opened.id,
316+ });
317+ expect(firstTouch).not.toBeNull();
318+
319+ await Bun.sleep(25);
320+
321+ const secondTouch = await t.mutation(internal.database.touchStorageInstance, {
322+ workspaceId: wsId,
323+ accountId,
324+ instanceId: opened.id,
325+ });
326+
327+ expect(secondTouch?.updatedAt).toBe(firstTouch?.updatedAt);
328+ expect(secondTouch?.lastSeenAt).toBe(firstTouch?.lastSeenAt);
329+
330+ await Bun.sleep(25);
331+
332+ const touchWithUsage = await t.mutation(internal.database.touchStorageInstance, {
333+ workspaceId: wsId,
334+ accountId,
335+ instanceId: opened.id,
336+ sizeBytes: 123,
337+ fileCount: 2,
338+ });
339+
340+ expect(touchWithUsage?.sizeBytes).toBe(123);
341+ expect(touchWithUsage?.fileCount).toBe(2);
342+ expect((touchWithUsage?.updatedAt ?? 0) >= (secondTouch?.updatedAt ?? 0)).toBe(true);
343+ } finally {
344+ if (previousConvexUrl === undefined) {
345+ delete process.env.CONVEX_URL;
346+ } else {
347+ process.env.CONVEX_URL = previousConvexUrl;
348+ }
349+
350+ if (previousConvexSiteUrl === undefined) {
351+ delete process.env.CONVEX_SITE_URL;
352+ } else {
353+ process.env.CONVEX_SITE_URL = previousConvexSiteUrl;
354+ }
355+ }
356+});
executor/packages/database/convex/database/storage_instances.ts+79−8
@@ -1,5 +1,5 @@
11 import { v } from "convex/values";
2−import { internalMutation, internalQuery } from "../_generated/server";
2+import { internalMutation, internalQuery, type MutationCtx } from "../_generated/server";
33 import type { Id } from "../_generated/dataModel";
44 import { mapStorageInstance } from "../../src/database/mappers";
55 import {
@@ -11,6 +11,7 @@ import { · 5 unmodified lines
1111
1212 const DEFAULT_EPHEMERAL_TTL_HOURS = 24;
1313 const MAX_EPHEMERAL_TTL_HOURS = 24 * 30;
14+const STORAGE_TOUCH_DEBOUNCE_MS = 5_000;
1415
1516 function resolveStorageProvider(): "agentfs-local" | "agentfs-cloudflare" {
1617 const raw = (process.env.AGENT_STORAGE_PROVIDER ?? "").trim().toLowerCase();
@@ -78,6 +79,49 @@ function isExpired(doc: { expiresAt?: number }, now = Date.now()): boolean { · 61 unmodified lines
7879 return typeof doc.expiresAt === "number" && Number.isFinite(doc.expiresAt) && doc.expiresAt <= now;
7980 }
8081
82+async function isOrganizationAdmin(
83+ ctx: MutationCtx,
84+ args: {
85+ organizationId: Id<"organizations">;
86+ accountId?: Id<"accounts">;
87+ },
88+): Promise<boolean> {
89+ if (!args.accountId) {
90+ return false;
91+ }
92+
93+ const membership = await ctx.db
94+ .query("organizationMembers")
95+ .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))
96+ .unique();
97+
98+ if (!membership || membership.status !== "active") {
99+ return false;
100+ }
101+
102+ return membership.role === "owner" || membership.role === "admin";
103+}
104+
105+async function assertCanManageSharedScope(
106+ ctx: MutationCtx,
107+ args: {
108+ organizationId: Id<"organizations">;
109+ accountId?: Id<"accounts">;
110+ scopeType: "workspace" | "organization";
111+ action: "open" | "close" | "delete";
112+ },
113+) {
114+ const isAdmin = await isOrganizationAdmin(ctx, {
115+ organizationId: args.organizationId,
116+ accountId: args.accountId,
117+ });
118+ if (!isAdmin) {
119+ throw new Error(
120+ `Only organization admins can ${args.action} ${args.scopeType} storage instances`,
121+ );
122+ }
123+}
124+
81125 export const openStorageInstance = internalMutation({
82126 args: {
83127 workspaceId: v.id("workspaces"),
@@ -150,6 +194,14 @@ export const openStorageInstance = internalMutation({ · 66 unmodified lines
150194 if (scopeType === "account" && !args.accountId) {
151195 throw new Error("accountId is required for account-scoped storage instances");
152196 }
197+ if (scopeType === "workspace" || scopeType === "organization") {
198+ await assertCanManageSharedScope(ctx, {
199+ organizationId,
200+ accountId: args.accountId,
201+ scopeType,
202+ action: "open",
203+ });
204+ }
153205
154206 const durability = args.durability
155207 ?? (scopeType === "scratch" ? "ephemeral" : "durable");
@@ -308,6 +360,15 @@ export const closeStorageInstance = internalMutation({ · 152 unmodified lines
308360 throw new Error("Storage instance is not accessible in this workspace context");
309361 }
310362
363+ if (doc.scopeType === "workspace" || doc.scopeType === "organization") {
364+ await assertCanManageSharedScope(ctx, {
365+ organizationId: workspace.organizationId,
366+ accountId: args.accountId,
367+ scopeType: doc.scopeType,
368+ action: "close",
369+ });
370+ }
371+
311372 const now = Date.now();
312373 await ctx.db.patch(doc._id, {
313374 status: "closed",
@@ -353,13 +414,13 @@ export const deleteStorageInstance = internalMutation({ · 39 unmodified lines
353414 throw new Error("Storage instance is not accessible in this workspace context");
354415 }
355416
356− if (
357− (doc.scopeType === "organization" || doc.scopeType === "workspace")
358− && doc.createdByAccountId
359− && args.accountId
360− && doc.createdByAccountId !== args.accountId
361− ) {
362− throw new Error("Only the instance creator can delete shared storage instances");
417+ if (doc.scopeType === "workspace" || doc.scopeType === "organization") {
418+ await assertCanManageSharedScope(ctx, {
419+ organizationId: workspace.organizationId,
420+ accountId: args.accountId,
421+ scopeType: doc.scopeType,
422+ action: "delete",
423+ });
363424 }
364425
365426 const now = Date.now();
@@ -416,6 +477,16 @@ export const touchStorageInstance = internalMutation({ · 50 unmodified lines
416477 }
417478
418479 const now = Date.now();
480+ const hasStatusUpdate = Boolean(args.status || args.provider);
481+ const hasUsageUpdate = typeof args.sizeBytes === "number" || typeof args.fileCount === "number";
482+ const shouldPatch = hasStatusUpdate
483+ || hasUsageUpdate
484+ || now - doc.lastSeenAt >= STORAGE_TOUCH_DEBOUNCE_MS;
485+
486+ if (!shouldPatch) {
487+ return mapStorageInstance(doc);
488+ }
489+
419490 await ctx.db.patch(doc._id, {
420491 updatedAt: now,
421492 lastSeenAt: now,
executor/packages/database/convex/executorNode.ts+9−1
@@ -24,6 +24,8 @@ import { customAction } from "../../core/src/function-builders"; · 23 unmodified lines
2424 import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport";
2525 import { previewOpenApiSourceUpgradeForContext, type OpenApiUpgradeDiffPreview } from "../src/runtime/tool_upgrade";
2626 import { getStorageProvider, type StorageEncoding, type StorageProvider } from "../src/runtime/storage_provider";
27+import { shouldTouchStorageOnRead } from "../src/runtime/storage_touch_policy";
28+import { shouldRefreshStorageUsage } from "../src/runtime/storage_usage_refresh";
2729
2830 function isReadOnlySql(sql: string): boolean {
2931 const trimmed = sql.trim().toLowerCase();
@@ -79,7 +81,13 @@ async function touchStorageInstance( · 49 unmodified lines
7981 withUsage: boolean;
8082 },
8183 ) {
82− const usage = args.withUsage ? await args.provider.usage(args.instance) : undefined;
84+ if (!args.withUsage && !shouldTouchStorageOnRead()) {
85+ return;
86+ }
87+
88+ const usage = args.withUsage && shouldRefreshStorageUsage(args.instance.id)
89+ ? await args.provider.usage(args.instance)
90+ : undefined;
8391 await ctx.runMutation(internal.database.touchStorageInstance, {
8492 workspaceId: args.workspaceId,
8593 accountId: args.accountId,
executor/packages/database/convex/workspace.ts+1−2
@@ -417,7 +417,7 @@ export const openStorageInstance = workspaceMutation({ · 416 unmodified lines
417417 const scopeType = args.scopeType ?? "scratch";
418418 if (scopeType === "organization" || scopeType === "workspace") {
419419 if (!isAdminRole(ctx.organizationMembership.role)) {
420− throw new Error("Only workspace admins can open workspace or organization storage instances");
420+ throw new Error("Only organization admins can open workspace or organization storage instances");
421421 }
422422 }
423423
@@ -460,7 +460,6 @@ export const closeStorageInstance = workspaceMutation({ · 36 unmodified lines
460460
461461 export const deleteStorageInstance = workspaceMutation({
462462 method: "POST",
463− requireAdmin: true,
464463 args: {
465464 instanceId: v.string(),
466465 },
executor/packages/database/src/runtime/base_tools.ts+9−3
@@ -20,6 +20,8 @@ import { · 19 unmodified lines
2020 fsStatOutputJsonSchema,
2121 fsWriteInputJsonSchema,
2222 fsWriteOutputJsonSchema,
23+} from "./storage_tool_contracts/fs";
24+import {
2325 kvDeleteInputJsonSchema,
2426 kvDeleteOutputJsonSchema,
2527 kvGetInputJsonSchema,
@@ -30,12 +32,16 @@ import { · 4 unmodified lines
3032 kvListOutputJsonSchema,
3133 kvSetInputJsonSchema,
3234 kvSetOutputJsonSchema,
33− sqliteQueryInputJsonSchema,
34− sqliteQueryOutputJsonSchema,
35+} from "./storage_tool_contracts/kv";
36+import {
3537 sqliteCapabilitiesInputJsonSchema,
3638 sqliteCapabilitiesOutputJsonSchema,
3739 sqliteInsertRowsInputJsonSchema,
3840 sqliteInsertRowsOutputJsonSchema,
41+ sqliteQueryInputJsonSchema,
42+ sqliteQueryOutputJsonSchema,
43+} from "./storage_tool_contracts/sqlite";
44+import {
3945 storageCloseInputJsonSchema,
4046 storageCloseOutputJsonSchema,
4147 storageDeleteInputJsonSchema,
@@ -44,7 +50,7 @@ import { · 2 unmodified lines
4450 storageListOutputJsonSchema,
4551 storageOpenInputJsonSchema,
4652 storageOpenOutputJsonSchema,
47−} from "./storage_tool_contracts";
53+} from "./storage_tool_contracts/storage";
4854
4955 export const baseTools = new Map<string, ToolDefinition>();
5056
executor/packages/database/src/runtime/policy.test.ts+58−0
@@ -134,3 +134,61 @@ test("discover can be denied by policy", () => { · 133 unmodified lines
134134
135135 expect(decision).toBe("deny");
136136 });
137+
138+test("storage.open can be forced to require approval by policy", () => {
139+ const policies: ToolPolicyRecord[] = [
140+ {
141+ id: "p-storage-open-required",
142+ ...basePolicy,
143+ resourceType: "tool_path",
144+ resourcePattern: "storage.open",
145+ matchType: "exact",
146+ effect: "allow",
147+ approvalMode: "required",
148+ },
149+ ];
150+
151+ const decision = getDecisionForContext(
152+ {
153+ path: "storage.open",
154+ source: "system",
155+ approval: "auto",
156+ },
157+ {
158+ workspaceId: "ws_1",
159+ accountId: "acct_1",
160+ },
161+ policies,
162+ );
163+
164+ expect(decision).toBe("require_approval");
165+});
166+
167+test("storage.delete can be denied by policy", () => {
168+ const policies: ToolPolicyRecord[] = [
169+ {
170+ id: "p-storage-delete-deny",
171+ ...basePolicy,
172+ resourceType: "tool_path",
173+ resourcePattern: "storage.delete",
174+ matchType: "exact",
175+ effect: "deny",
176+ approvalMode: "required",
177+ },
178+ ];
179+
180+ const decision = getDecisionForContext(
181+ {
182+ path: "storage.delete",
183+ source: "system",
184+ approval: "auto",
185+ },
186+ {
187+ workspaceId: "ws_1",
188+ accountId: "acct_1",
189+ },
190+ policies,
191+ );
192+
193+ expect(decision).toBe("deny");
194+});
executor/packages/database/src/runtime/storage_provider.local.test.ts+101−0
@@ -0,0 +1,101 @@
1+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+import { mkdtemp, rm } from "node:fs/promises";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
5+import type { StorageInstanceRecord } from "../../../core/src/types";
6+import { getStorageProvider, resetStorageProviderSingletonsForTests } from "./storage_provider";
7+
8+function makeInstance(id: string): StorageInstanceRecord {
9+ const now = Date.now();
10+ return {
11+ id,
12+ scopeType: "scratch",
13+ durability: "durable",
14+ status: "active",
15+ provider: "agentfs-local",
16+ backendKey: id,
17+ organizationId: `org_${id}` as StorageInstanceRecord["organizationId"],
18+ workspaceId: `ws_${id}` as StorageInstanceRecord["workspaceId"],
19+ accountId: `acct_${id}` as StorageInstanceRecord["accountId"],
20+ createdAt: now,
21+ updatedAt: now,
22+ lastSeenAt: now,
23+ };
24+}
25+
26+describe("agentfs-local storage provider", () => {
27+ let previousConvexUrl: string | undefined;
28+ let previousConvexSiteUrl: string | undefined;
29+ let previousStorageRoot: string | undefined;
30+ let tempRoot = "";
31+
32+ beforeEach(async () => {
33+ previousConvexUrl = process.env.CONVEX_URL;
34+ previousConvexSiteUrl = process.env.CONVEX_SITE_URL;
35+ previousStorageRoot = process.env.AGENT_STORAGE_ROOT;
36+ process.env.CONVEX_URL = "http://127.0.0.1:3210";
37+ process.env.CONVEX_SITE_URL = "http://127.0.0.1:3211";
38+ tempRoot = await mkdtemp(join(tmpdir(), "storage-provider-local-"));
39+ process.env.AGENT_STORAGE_ROOT = tempRoot;
40+ resetStorageProviderSingletonsForTests();
41+ });
42+
43+ afterEach(async () => {
44+ resetStorageProviderSingletonsForTests();
45+ if (previousConvexUrl === undefined) {
46+ delete process.env.CONVEX_URL;
47+ } else {
48+ process.env.CONVEX_URL = previousConvexUrl;
49+ }
50+
51+ if (previousConvexSiteUrl === undefined) {
52+ delete process.env.CONVEX_SITE_URL;
53+ } else {
54+ process.env.CONVEX_SITE_URL = previousConvexSiteUrl;
55+ }
56+
57+ if (previousStorageRoot === undefined) {
58+ delete process.env.AGENT_STORAGE_ROOT;
59+ } else {
60+ process.env.AGENT_STORAGE_ROOT = previousStorageRoot;
61+ }
62+
63+ if (tempRoot) {
64+ await rm(tempRoot, { recursive: true, force: true });
65+ }
66+ });
67+
68+ test("serializes concurrent SQLite writes for a single instance", async () => {
69+ const provider = getStorageProvider("agentfs-local");
70+ const instance = makeInstance("inst_concurrent_sqlite");
71+
72+ await provider.sqliteQuery(instance, {
73+ sql: "CREATE TABLE IF NOT EXISTS smoke (id TEXT PRIMARY KEY, value INTEGER)",
74+ params: [],
75+ mode: "write",
76+ maxRows: 1,
77+ });
78+
79+ const writeCount = 40;
80+ await Promise.all(
81+ Array.from({ length: writeCount }).map((_, index) =>
82+ provider.sqliteQuery(instance, {
83+ sql: "INSERT INTO smoke (id, value) VALUES (?, ?)",
84+ params: [`id_${index}`, index],
85+ mode: "write",
86+ maxRows: 1,
87+ })
88+ ),
89+ );
90+
91+ const result = await provider.sqliteQuery(instance, {
92+ sql: "SELECT COUNT(*) AS count FROM smoke",
93+ params: [],
94+ mode: "read",
95+ maxRows: 1,
96+ });
97+
98+ const row = result.rows?.[0] as { count?: number } | undefined;
99+ expect(row?.count).toBe(writeCount);
100+ });
101+});
executor/packages/database/src/runtime/storage_provider.ts+63−16
@@ -167,6 +167,7 @@ function isReadOnlySql(sql: string): boolean { · 166 unmodified lines
167167
168168 class AgentFsLocalStorageProvider implements StorageProvider {
169169 private readonly baseDir: string;
170+ private readonly instanceWriteLocks = new Map<string, Promise<void>>();
170171
171172 constructor(baseDir: string) {
172173 this.baseDir = baseDir;
@@ -196,7 +197,27 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 23 unmodified lines
196197 return fullPath;
197198 }
198199
199− private async withDatabase<T>(
200+ private async withInstanceWriteLock<T>(instance: StorageInstanceRecord, callback: () => Promise<T>): Promise<T> {
201+ const key = instance.id;
202+ const previous = this.instanceWriteLocks.get(key) ?? Promise.resolve();
203+ let releaseLock!: () => void;
204+ const current = new Promise<void>((resolve) => {
205+ releaseLock = resolve;
206+ });
207+ this.instanceWriteLocks.set(key, current);
208+
209+ await previous;
210+ try {
211+ return await callback();
212+ } finally {
213+ releaseLock();
214+ if (this.instanceWriteLocks.get(key) === current) {
215+ this.instanceWriteLocks.delete(key);
216+ }
217+ }
218+ }
219+
220+ private async withDatabaseUnlocked<T>(
200221 instance: StorageInstanceRecord,
201222 writeMode: boolean,
202223 callback: (db: SqlJsDatabase) => Promise<T> | T,
@@ -228,6 +249,20 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 25 unmodified lines
228249 }
229250 }
230251
252+ private async withDatabase<T>(
253+ instance: StorageInstanceRecord,
254+ writeMode: boolean,
255+ callback: (db: SqlJsDatabase) => Promise<T> | T,
256+ ): Promise<T> {
257+ if (!writeMode) {
258+ return await this.withDatabaseUnlocked(instance, false, callback);
259+ }
260+
261+ return await this.withInstanceWriteLock(instance, async () => {
262+ return await this.withDatabaseUnlocked(instance, true, callback);
263+ });
264+ }
265+
231266 private async ensureKvTable(db: SqlJsDatabase) {
232267 db.run(`
233268 CREATE TABLE IF NOT EXISTS kv_store (
@@ -261,15 +296,17 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 27 unmodified lines
261296 content: string,
262297 encoding: StorageEncoding,
263298 ): 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− };
299+ return await this.withInstanceWriteLock(instance, async () => {
300+ const fullPath = this.resolveFsPath(instance, path);
301+ await mkdir(dirname(fullPath), { recursive: true });
302+ const payload = encoding === "base64"
303+ ? Buffer.from(content, "base64")
304+ : Buffer.from(content, "utf8");
305+ await writeFile(fullPath, payload);
306+ return {
307+ bytesWritten: payload.length,
308+ };
309+ });
273310 }
274311
275312 async readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]> {
@@ -314,15 +351,19 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 38 unmodified lines
314351 }
315352
316353 async mkdir(instance: StorageInstanceRecord, path: string): Promise<void> {
317− const fullPath = this.resolveFsPath(instance, path);
318− await mkdir(fullPath, { recursive: true });
354+ await this.withInstanceWriteLock(instance, async () => {
355+ const fullPath = this.resolveFsPath(instance, path);
356+ await mkdir(fullPath, { recursive: true });
357+ });
319358 }
320359
321360 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,
361+ await this.withInstanceWriteLock(instance, async () => {
362+ const fullPath = this.resolveFsPath(instance, path);
363+ await rm(fullPath, {
364+ recursive: options.recursive ?? false,
365+ force: options.force ?? false,
366+ });
326367 });
327368 }
328369
@@ -679,3 +720,9 @@ export function getStorageProvider(provider: StorageProviderId): StorageProvider · 350 unmodified lines
679720
680721 return getLocalProvider();
681722 }
723+
724+export function resetStorageProviderSingletonsForTests() {
725+ localProviderSingleton = null;
726+ cloudflareProviderSingleton = null;
727+ cloudflareStorageProbeStarted = false;
728+}
executor/packages/database/src/runtime/storage_tool_contracts.test.ts+30−0
@@ -0,0 +1,30 @@
1+import { describe, expect, test } from "bun:test";
2+import {
3+ fsReadInputSchema,
4+ kvSetInputSchema,
5+ sqliteQueryInputSchema,
6+ storageOpenInputSchema,
7+ storageOpenInputJsonSchema,
8+ sqliteInsertRowsInputJsonSchema,
9+} from "./storage_tool_contracts";
10+
11+describe("storage tool contracts", () => {
12+ test("parses canonical tool inputs", () => {
13+ expect(storageOpenInputSchema.parse({})).toEqual({});
14+ expect(fsReadInputSchema.parse({ path: "/notes.txt" }).path).toBe("/notes.txt");
15+ expect(kvSetInputSchema.parse({ key: "answer", value: 42 }).value).toBe(42);
16+ expect(sqliteQueryInputSchema.parse({ sql: "SELECT 1" }).sql).toBe("SELECT 1");
17+ });
18+
19+ test("exports json schemas for discovery typing", () => {
20+ expect(storageOpenInputJsonSchema).toMatchObject({
21+ type: "object",
22+ additionalProperties: false,
23+ });
24+
25+ expect(sqliteInsertRowsInputJsonSchema).toMatchObject({
26+ type: "object",
27+ required: ["table", "columns", "rows"],
28+ });
29+ });
30+});
executor/packages/database/src/runtime/storage_tool_contracts.ts+5−791
@@ -1,791 +1,5 @@
1−import { z } from "zod";
2−
3−type JsonSchema = Record<string, unknown>;
4−type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
5−
6−function isRecord(value: unknown): value is Record<string, unknown> {
7− return typeof value === "object" && value !== null && !Array.isArray(value);
8−}
9−
10−function mergeSchemaWithFallback(schema: unknown, fallback: JsonSchema): JsonSchema {
11− if (!isRecord(schema)) {
12− return fallback;
13− }
14−
15− const merged: JsonSchema = { ...schema };
16−
17− if (typeof fallback.description === "string" && typeof merged.description !== "string") {
18− merged.description = fallback.description;
19− }
20−
21− const schemaProps = isRecord(merged.properties) ? { ...merged.properties } : {};
22− const fallbackProps = isRecord(fallback.properties) ? fallback.properties : {};
23− for (const [key, fallbackProp] of Object.entries(fallbackProps)) {
24− const currentProp = schemaProps[key];
25− if (!isRecord(currentProp)) {
26− schemaProps[key] = fallbackProp;
27− continue;
28− }
29−
30− if (isRecord(fallbackProp)) {
31− schemaProps[key] = {
32− ...fallbackProp,
33− ...currentProp,
34− ...(typeof fallbackProp.description === "string" && typeof currentProp.description !== "string"
35− ? { description: fallbackProp.description }
36− : {}),
37− };
38− }
39− }
40− if (Object.keys(schemaProps).length > 0) {
41− merged.properties = schemaProps;
42− }
43−
44− if (!Array.isArray(merged.required) && Array.isArray(fallback.required)) {
45− merged.required = fallback.required;
46− }
47− if (!("additionalProperties" in merged) && "additionalProperties" in fallback) {
48− merged.additionalProperties = fallback.additionalProperties;
49− }
50−
51− return merged;
52−}
53−
54−function toJsonSchema(schema: z.ZodTypeAny, fallback: JsonSchema): JsonSchema {
55− const maybeToJsonSchema = (z as unknown as { toJSONSchema?: (value: z.ZodTypeAny) => unknown }).toJSONSchema;
56− if (typeof maybeToJsonSchema === "function") {
57− try {
58− return mergeSchemaWithFallback(maybeToJsonSchema(schema), fallback);
59− } catch {
60− return fallback;
61− }
62− }
63−
64− return fallback;
65−}
66−
67−export const storageScopeSchema = z.enum(["scratch", "account", "workspace", "organization"]);
68−export const storageDurabilitySchema = z.enum(["ephemeral", "durable"]);
69−export const storageStatusSchema = z.enum(["active", "closed", "deleted"]);
70−export const storageProviderSchema = z.enum(["agentfs-local", "agentfs-cloudflare"]);
71−
72−const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() => z.union([
73− z.string(),
74− z.number(),
75− z.boolean(),
76− z.null(),
77− z.array(jsonValueSchema),
78− z.record(jsonValueSchema),
79−]));
80−
81−export const storageInstanceSchema = z.object({
82− id: z.string(),
83− scopeType: storageScopeSchema,
84− durability: storageDurabilitySchema,
85− status: storageStatusSchema,
86− provider: storageProviderSchema,
87− backendKey: z.string(),
88− organizationId: z.string(),
89− workspaceId: z.string().optional(),
90− accountId: z.string().optional(),
91− createdByAccountId: z.string().optional(),
92− purpose: z.string().optional(),
93− sizeBytes: z.number().optional(),
94− fileCount: z.number().optional(),
95− createdAt: z.number(),
96− updatedAt: z.number(),
97− lastSeenAt: z.number(),
98− closedAt: z.number().optional(),
99− expiresAt: z.number().optional(),
100−});
101−
102−export const storageOpenInputSchema = z.object({
103− instanceId: z.string().optional(),
104− scopeType: storageScopeSchema.optional(),
105− durability: storageDurabilitySchema.optional(),
106− purpose: z.string().optional(),
107− ttlHours: z.coerce.number().optional(),
108−});
109−
110−export const storageOpenOutputSchema = z.object({
111− instance: storageInstanceSchema,
112−});
113−
114−export const storageListInputSchema = z.object({
115− scopeType: storageScopeSchema.optional(),
116− includeDeleted: z.boolean().optional(),
117−});
118−
119−export const storageListOutputSchema = z.object({
120− instances: z.array(storageInstanceSchema),
121− total: z.number(),
122−});
123−
124−export const storageCloseInputSchema = z.object({
125− instanceId: z.string(),
126−});
127−
128−export const storageCloseOutputSchema = z.object({
129− instance: storageInstanceSchema.nullable(),
130−});
131−
132−export const storageDeleteInputSchema = z.object({
133− instanceId: z.string(),
134−});
135−
136−export const storageDeleteOutputSchema = z.object({
137− instance: storageInstanceSchema.nullable(),
138−});
139−
140−const fsAccessSchema = z.object({
141− instanceId: z.string().optional(),
142− scopeType: storageScopeSchema.optional(),
143−});
144−
145−export const fsReadInputSchema = fsAccessSchema.extend({
146− path: z.string(),
147− encoding: z.enum(["utf8", "base64"]).optional(),
148−});
149−
150−export const fsReadOutputSchema = z.object({
151− instanceId: z.string(),
152− path: z.string(),
153− encoding: z.enum(["utf8", "base64"]),
154− content: z.string(),
155− bytes: z.number(),
156−});
157−
158−export const fsWriteInputSchema = fsAccessSchema.extend({
159− path: z.string(),
160− content: z.string(),
161− encoding: z.enum(["utf8", "base64"]).optional(),
162−});
163−
164−export const fsWriteOutputSchema = z.object({
165− instanceId: z.string(),
166− path: z.string(),
167− bytesWritten: z.number(),
168−});
169−
170−export const fsReaddirInputSchema = fsAccessSchema.extend({
171− path: z.string().optional(),
172−});
173−
174−export const fsReaddirOutputSchema = z.object({
175− instanceId: z.string(),
176− path: z.string(),
177− entries: z.array(z.object({
178− name: z.string(),
179− type: z.enum(["file", "directory", "symlink", "unknown"]),
180− size: z.number().optional(),
181− mtime: z.number().optional(),
182− })),
183−});
184−
185−export const fsStatInputSchema = fsAccessSchema.extend({
186− path: z.string(),
187−});
188−
189−export const fsStatOutputSchema = z.object({
190− instanceId: z.string(),
191− path: z.string(),
192− exists: z.boolean(),
193− type: z.enum(["file", "directory", "symlink", "unknown"]).optional(),
194− size: z.number().optional(),
195− mode: z.number().optional(),
196− mtime: z.number().optional(),
197− ctime: z.number().optional(),
198−});
199−
200−export const fsMkdirInputSchema = fsAccessSchema.extend({
201− path: z.string(),
202−});
203−
204−export const fsMkdirOutputSchema = z.object({
205− instanceId: z.string(),
206− path: z.string(),
207− ok: z.boolean(),
208−});
209−
210−export const fsRemoveInputSchema = fsAccessSchema.extend({
211− path: z.string(),
212− recursive: z.boolean().optional(),
213− force: z.boolean().optional(),
214−});
215−
216−export const fsRemoveOutputSchema = z.object({
217− instanceId: z.string(),
218− path: z.string(),
219− ok: z.boolean(),
220−});
221−
222−export const kvGetInputSchema = fsAccessSchema.extend({
223− key: z.string(),
224−});
225−
226−export const kvGetOutputSchema = z.object({
227− instanceId: z.string(),
228− key: z.string(),
229− found: z.boolean(),
230− value: jsonValueSchema.optional(),
231−});
232−
233−export const kvSetInputSchema = fsAccessSchema.extend({
234− key: z.string(),
235− value: jsonValueSchema,
236−});
237−
238−export const kvSetOutputSchema = z.object({
239− instanceId: z.string(),
240− key: z.string(),
241− ok: z.boolean(),
242−});
243−
244−export const kvListInputSchema = fsAccessSchema.extend({
245− prefix: z.string().optional(),
246− limit: z.coerce.number().optional(),
247−});
248−
249−export const kvListOutputSchema = z.object({
250− instanceId: z.string(),
251− items: z.array(z.object({ key: z.string(), value: jsonValueSchema })),
252− total: z.number(),
253−});
254−
255−export const kvDeleteInputSchema = fsAccessSchema.extend({
256− key: z.string(),
257−});
258−
259−export const kvDeleteOutputSchema = z.object({
260− instanceId: z.string(),
261− key: z.string(),
262− ok: z.boolean(),
263−});
264−
265−export const kvIncrInputSchema = fsAccessSchema.extend({
266− key: z.string(),
267− by: z.coerce.number().optional(),
268− initial: z.coerce.number().optional(),
269−});
270−
271−export const kvIncrOutputSchema = z.object({
272− instanceId: z.string(),
273− key: z.string(),
274− by: z.number(),
275− previous: z.number(),
276− value: z.number(),
277−});
278−
279−export const sqliteQueryInputSchema = fsAccessSchema.extend({
280− sql: z.string(),
281− params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
282− mode: z.enum(["read", "write"]).optional(),
283− maxRows: z.coerce.number().optional(),
284−});
285−
286−export const sqliteQueryOutputSchema = z.object({
287− instanceId: z.string(),
288− mode: z.enum(["read", "write"]),
289− rows: z.array(z.record(jsonValueSchema)).optional(),
290− rowCount: z.number(),
291− changes: z.number().optional(),
292−});
293−
294−const sqliteScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
295−
296−export const sqliteCapabilitiesInputSchema = fsAccessSchema.extend({});
297−
298−export const sqliteCapabilitiesOutputSchema = z.object({
299− instanceId: z.string(),
300− provider: storageProviderSchema,
301− maxBindVariables: z.number(),
302− supportsJsonEach: z.boolean(),
303− supportsInsertRowsTool: z.boolean(),
304− guidance: z.array(z.string()),
305−});
306−
307−export const sqliteInsertRowsInputSchema = fsAccessSchema.extend({
308− table: z.string(),
309− columns: z.array(z.string()).min(1),
310− rows: z.array(z.array(sqliteScalarSchema)).min(1),
311− onConflict: z.enum(["none", "ignore", "replace"]).optional(),
312− chunkSize: z.coerce.number().optional(),
313−});
314−
315−export const sqliteInsertRowsOutputSchema = z.object({
316− instanceId: z.string(),
317− table: z.string(),
318− columns: z.array(z.string()),
319− rowsReceived: z.number(),
320− rowsProcessed: z.number(),
321− chunkCount: z.number(),
322− rowsPerChunk: z.number(),
323− maxBindVariables: z.number(),
324− changes: z.number(),
325−});
326−
327−const jsonValueJsonSchema = {
328− oneOf: [
329− { type: "string" },
330− { type: "number" },
331− { type: "boolean" },
332− { type: "null" },
333− { type: "array", items: {} },
334− { type: "object", additionalProperties: {} },
335− ],
336−};
337−
338−export const storageOpenInputJsonSchema = toJsonSchema(storageOpenInputSchema, {
339− type: "object",
340− properties: {
341− instanceId: { type: "string" },
342− scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
343− durability: { type: "string", enum: ["ephemeral", "durable"] },
344− purpose: { type: "string" },
345− ttlHours: { type: "number" },
346− },
347− additionalProperties: false,
348−});
349−
350−export const storageOpenOutputJsonSchema = toJsonSchema(storageOpenOutputSchema, {
351− type: "object",
352− properties: {
353− instance: { type: "object" },
354− },
355− required: ["instance"],
356− additionalProperties: false,
357−});
358−
359−export const storageListInputJsonSchema = toJsonSchema(storageListInputSchema, {
360− type: "object",
361− properties: {
362− scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
363− includeDeleted: { type: "boolean" },
364− },
365− additionalProperties: false,
366−});
367−
368−export const storageListOutputJsonSchema = toJsonSchema(storageListOutputSchema, {
369− type: "object",
370− properties: {
371− instances: { type: "array", items: { type: "object" } },
372− total: { type: "number" },
373− },
374− required: ["instances", "total"],
375− additionalProperties: false,
376−});
377−
378−export const storageCloseInputJsonSchema = toJsonSchema(storageCloseInputSchema, {
379− type: "object",
380− properties: {
381− instanceId: { type: "string" },
382− },
383− required: ["instanceId"],
384− additionalProperties: false,
385−});
386−
387−export const storageCloseOutputJsonSchema = toJsonSchema(storageCloseOutputSchema, {
388− type: "object",
389− properties: {
390− instance: {
391− oneOf: [{ type: "object" }, { type: "null" }],
392− },
393− },
394− required: ["instance"],
395− additionalProperties: false,
396−});
397−
398−export const storageDeleteInputJsonSchema = storageCloseInputJsonSchema;
399−export const storageDeleteOutputJsonSchema = storageCloseOutputJsonSchema;
400−
401−function fsAccessJsonProperties() {
402− return {
403− instanceId: {
404− type: "string",
405− description: "Storage instance identifier. Recommended for cross-task/cross-run persistence.",
406− },
407− scopeType: {
408− type: "string",
409− enum: ["scratch", "account", "workspace", "organization"],
410− description: "Scope used to open/select a default instance when instanceId is omitted.",
411− },
412− };
413−}
414−
415−export const fsReadInputJsonSchema = toJsonSchema(fsReadInputSchema, {
416− type: "object",
417− properties: {
418− ...fsAccessJsonProperties(),
419− path: { type: "string" },
420− encoding: { type: "string", enum: ["utf8", "base64"] },
421− },
422− required: ["path"],
423− additionalProperties: false,
424−});
425−
426−export const fsReadOutputJsonSchema = toJsonSchema(fsReadOutputSchema, {
427− type: "object",
428− properties: {
429− instanceId: { type: "string" },
430− path: { type: "string" },
431− encoding: { type: "string", enum: ["utf8", "base64"] },
432− content: { type: "string" },
433− bytes: { type: "number" },
434− },
435− required: ["instanceId", "path", "encoding", "content", "bytes"],
436− additionalProperties: false,
437−});
438−
439−export const fsWriteInputJsonSchema = toJsonSchema(fsWriteInputSchema, {
440− type: "object",
441− properties: {
442− ...fsAccessJsonProperties(),
443− path: { type: "string" },
444− content: { type: "string" },
445− encoding: { type: "string", enum: ["utf8", "base64"] },
446− },
447− required: ["path", "content"],
448− additionalProperties: false,
449−});
450−
451−export const fsWriteOutputJsonSchema = toJsonSchema(fsWriteOutputSchema, {
452− type: "object",
453− properties: {
454− instanceId: { type: "string" },
455− path: { type: "string" },
456− bytesWritten: { type: "number" },
457− },
458− required: ["instanceId", "path", "bytesWritten"],
459− additionalProperties: false,
460−});
461−
462−export const fsReaddirInputJsonSchema = toJsonSchema(fsReaddirInputSchema, {
463− type: "object",
464− properties: {
465− ...fsAccessJsonProperties(),
466− path: { type: "string" },
467− },
468− additionalProperties: false,
469−});
470−
471−export const fsReaddirOutputJsonSchema = toJsonSchema(fsReaddirOutputSchema, {
472− type: "object",
473− properties: {
474− instanceId: { type: "string" },
475− path: { type: "string" },
476− entries: { type: "array", items: { type: "object" } },
477− },
478− required: ["instanceId", "path", "entries"],
479− additionalProperties: false,
480−});
481−
482−export const fsStatInputJsonSchema = toJsonSchema(fsStatInputSchema, {
483− type: "object",
484− properties: {
485− ...fsAccessJsonProperties(),
486− path: { type: "string" },
487− },
488− required: ["path"],
489− additionalProperties: false,
490−});
491−
492−export const fsStatOutputJsonSchema = toJsonSchema(fsStatOutputSchema, {
493− type: "object",
494− properties: {
495− instanceId: { type: "string" },
496− path: { type: "string" },
497− exists: { type: "boolean" },
498− type: { type: "string", enum: ["file", "directory", "symlink", "unknown"] },
499− size: { type: "number" },
500− mode: { type: "number" },
501− mtime: { type: "number" },
502− ctime: { type: "number" },
503− },
504− required: ["instanceId", "path", "exists"],
505− additionalProperties: false,
506−});
507−
508−export const fsMkdirInputJsonSchema = toJsonSchema(fsMkdirInputSchema, {
509− type: "object",
510− properties: {
511− ...fsAccessJsonProperties(),
512− path: { type: "string" },
513− },
514− required: ["path"],
515− additionalProperties: false,
516−});
517−
518−export const fsMkdirOutputJsonSchema = toJsonSchema(fsMkdirOutputSchema, {
519− type: "object",
520− properties: {
521− instanceId: { type: "string" },
522− path: { type: "string" },
523− ok: { type: "boolean" },
524− },
525− required: ["instanceId", "path", "ok"],
526− additionalProperties: false,
527−});
528−
529−export const fsRemoveInputJsonSchema = toJsonSchema(fsRemoveInputSchema, {
530− type: "object",
531− properties: {
532− ...fsAccessJsonProperties(),
533− path: { type: "string" },
534− recursive: { type: "boolean" },
535− force: { type: "boolean" },
536− },
537− required: ["path"],
538− additionalProperties: false,
539−});
540−
541−export const fsRemoveOutputJsonSchema = toJsonSchema(fsRemoveOutputSchema, {
542− type: "object",
543− properties: {
544− instanceId: { type: "string" },
545− path: { type: "string" },
546− ok: { type: "boolean" },
547− },
548− required: ["instanceId", "path", "ok"],
549− additionalProperties: false,
550−});
551−
552−export const kvGetInputJsonSchema = toJsonSchema(kvGetInputSchema, {
553− type: "object",
554− properties: {
555− ...fsAccessJsonProperties(),
556− key: { type: "string" },
557− },
558− required: ["key"],
559− additionalProperties: false,
560−});
561−
562−export const kvGetOutputJsonSchema = toJsonSchema(kvGetOutputSchema, {
563− type: "object",
564− properties: {
565− instanceId: { type: "string" },
566− key: { type: "string" },
567− found: { type: "boolean" },
568− value: jsonValueJsonSchema,
569− },
570− required: ["instanceId", "key", "found"],
571− additionalProperties: false,
572−});
573−
574−export const kvSetInputJsonSchema = toJsonSchema(kvSetInputSchema, {
575− type: "object",
576− properties: {
577− ...fsAccessJsonProperties(),
578− key: { type: "string" },
579− value: jsonValueJsonSchema,
580− },
581− required: ["key", "value"],
582− additionalProperties: false,
583−});
584−
585−export const kvSetOutputJsonSchema = toJsonSchema(kvSetOutputSchema, {
586− type: "object",
587− properties: {
588− instanceId: { type: "string" },
589− key: { type: "string" },
590− ok: { type: "boolean" },
591− },
592− required: ["instanceId", "key", "ok"],
593− additionalProperties: false,
594−});
595−
596−export const kvListInputJsonSchema = toJsonSchema(kvListInputSchema, {
597− type: "object",
598− properties: {
599− ...fsAccessJsonProperties(),
600− prefix: { type: "string" },
601− limit: { type: "number" },
602− },
603− additionalProperties: false,
604−});
605−
606−export const kvListOutputJsonSchema = toJsonSchema(kvListOutputSchema, {
607− type: "object",
608− properties: {
609− instanceId: { type: "string" },
610− items: {
611− type: "array",
612− items: {
613− type: "object",
614− properties: {
615− key: { type: "string" },
616− value: jsonValueJsonSchema,
617− },
618− required: ["key", "value"],
619− additionalProperties: false,
620− },
621− },
622− total: { type: "number" },
623− },
624− required: ["instanceId", "items", "total"],
625− additionalProperties: false,
626−});
627−
628−export const kvDeleteInputJsonSchema = toJsonSchema(kvDeleteInputSchema, {
629− type: "object",
630− properties: {
631− ...fsAccessJsonProperties(),
632− key: { type: "string" },
633− },
634− required: ["key"],
635− additionalProperties: false,
636−});
637−
638−export const kvDeleteOutputJsonSchema = toJsonSchema(kvDeleteOutputSchema, {
639− type: "object",
640− properties: {
641− instanceId: { type: "string" },
642− key: { type: "string" },
643− ok: { type: "boolean" },
644− },
645− required: ["instanceId", "key", "ok"],
646− additionalProperties: false,
647−});
648−
649−export const kvIncrInputJsonSchema = toJsonSchema(kvIncrInputSchema, {
650− type: "object",
651− properties: {
652− ...fsAccessJsonProperties(),
653− key: { type: "string" },
654− by: { type: "number" },
655− initial: { type: "number" },
656− },
657− required: ["key"],
658− additionalProperties: false,
659−});
660−
661−export const kvIncrOutputJsonSchema = toJsonSchema(kvIncrOutputSchema, {
662− type: "object",
663− properties: {
664− instanceId: { type: "string" },
665− key: { type: "string" },
666− by: { type: "number" },
667− previous: { type: "number" },
668− value: { type: "number" },
669− },
670− required: ["instanceId", "key", "by", "previous", "value"],
671− additionalProperties: false,
672−});
673−
674−export const sqliteQueryInputJsonSchema = toJsonSchema(sqliteQueryInputSchema, {
675− type: "object",
676− description:
677− "Execute SQL on a storage-backed SQLite database. Use instanceId to target the same database across separate tasks or sessions.",
678− properties: {
679− ...fsAccessJsonProperties(),
680− sql: { type: "string", description: "SQL statement text to execute." },
681− params: {
682− type: "array",
683− description:
684− "Positional SQL parameters. Keep per-call bind count modest (for large inserts, batch rows into smaller chunks or pass one JSON payload and expand with json_each(?)).",
685− items: {
686− oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }],
687− },
688− },
689− mode: {
690− type: "string",
691− enum: ["read", "write"],
692− description: "Use 'write' for CREATE/INSERT/UPDATE/DELETE statements.",
693− },
694− maxRows: { type: "number", description: "Read mode row cap for result payload size." },
695− },
696− required: ["sql"],
697− additionalProperties: false,
698−});
699−
700−export const sqliteQueryOutputJsonSchema = toJsonSchema(sqliteQueryOutputSchema, {
701− type: "object",
702− properties: {
703− instanceId: { type: "string" },
704− mode: { type: "string", enum: ["read", "write"] },
705− rows: { type: "array", items: { type: "object", additionalProperties: jsonValueJsonSchema } },
706− rowCount: { type: "number" },
707− changes: { type: "number" },
708− },
709− required: ["instanceId", "mode", "rowCount"],
710− additionalProperties: false,
711−});
712−
713−export const sqliteCapabilitiesInputJsonSchema = toJsonSchema(sqliteCapabilitiesInputSchema, {
714− type: "object",
715− description: "Get provider-specific SQLite execution capabilities and batching guidance.",
716− properties: {
717− ...fsAccessJsonProperties(),
718− },
719− additionalProperties: false,
720−});
721−
722−export const sqliteCapabilitiesOutputJsonSchema = toJsonSchema(sqliteCapabilitiesOutputSchema, {
723− type: "object",
724− properties: {
725− instanceId: { type: "string" },
726− provider: { type: "string", enum: ["agentfs-local", "agentfs-cloudflare"] },
727− maxBindVariables: { type: "number" },
728− supportsJsonEach: { type: "boolean" },
729− supportsInsertRowsTool: { type: "boolean" },
730− guidance: { type: "array", items: { type: "string" } },
731− },
732− required: [
733− "instanceId",
734− "provider",
735− "maxBindVariables",
736− "supportsJsonEach",
737− "supportsInsertRowsTool",
738− "guidance",
739− ],
740− additionalProperties: false,
741−});
742−
743−export const sqliteInsertRowsInputJsonSchema = toJsonSchema(sqliteInsertRowsInputSchema, {
744− type: "object",
745− description:
746− "Insert tabular rows with automatic chunking to stay under SQLite bind-variable limits. Prefer this over huge VALUES(...) statements.",
747− properties: {
748− ...fsAccessJsonProperties(),
749− table: { type: "string", description: "Target table name." },
750− columns: { type: "array", items: { type: "string" }, minItems: 1 },
751− rows: {
752− type: "array",
753− minItems: 1,
754− items: {
755− type: "array",
756− items: { oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }] },
757− },
758− },
759− onConflict: { type: "string", enum: ["none", "ignore", "replace"] },
760− chunkSize: { type: "number", description: "Optional max rows per insert chunk." },
761− },
762− required: ["table", "columns", "rows"],
763− additionalProperties: false,
764−});
765−
766−export const sqliteInsertRowsOutputJsonSchema = toJsonSchema(sqliteInsertRowsOutputSchema, {
767− type: "object",
768− properties: {
769− instanceId: { type: "string" },
770− table: { type: "string" },
771− columns: { type: "array", items: { type: "string" } },
772− rowsReceived: { type: "number" },
773− rowsProcessed: { type: "number" },
774− chunkCount: { type: "number" },
775− rowsPerChunk: { type: "number" },
776− maxBindVariables: { type: "number" },
777− changes: { type: "number" },
778− },
779− required: [
780− "instanceId",
781− "table",
782− "columns",
783− "rowsReceived",
784− "rowsProcessed",
785− "chunkCount",
786− "rowsPerChunk",
787− "maxBindVariables",
788− "changes",
789− ],
790− additionalProperties: false,
791−});
1+export * from "./storage_tool_contracts/shared";
2+export * from "./storage_tool_contracts/storage";
3+export * from "./storage_tool_contracts/fs";
4+export * from "./storage_tool_contracts/kv";
5+export * from "./storage_tool_contracts/sqlite";
executor/packages/database/src/runtime/storage_tool_contracts/fs.ts+216−0
@@ -0,0 +1,216 @@
1+import { z } from "zod";
2+import { fsAccessJsonProperties, fsAccessSchema, toJsonSchema } from "./shared";
3+
4+export const fsReadInputSchema = fsAccessSchema.extend({
5+ path: z.string(),
6+ encoding: z.enum(["utf8", "base64"]).optional(),
7+});
8+
9+export const fsReadOutputSchema = z.object({
10+ instanceId: z.string(),
11+ path: z.string(),
12+ encoding: z.enum(["utf8", "base64"]),
13+ content: z.string(),
14+ bytes: z.number(),
15+});
16+
17+export const fsWriteInputSchema = fsAccessSchema.extend({
18+ path: z.string(),
19+ content: z.string(),
20+ encoding: z.enum(["utf8", "base64"]).optional(),
21+});
22+
23+export const fsWriteOutputSchema = z.object({
24+ instanceId: z.string(),
25+ path: z.string(),
26+ bytesWritten: z.number(),
27+});
28+
29+export const fsReaddirInputSchema = fsAccessSchema.extend({
30+ path: z.string().optional(),
31+});
32+
33+export const fsReaddirOutputSchema = z.object({
34+ instanceId: z.string(),
35+ path: z.string(),
36+ entries: z.array(z.object({
37+ name: z.string(),
38+ type: z.enum(["file", "directory", "symlink", "unknown"]),
39+ size: z.number().optional(),
40+ mtime: z.number().optional(),
41+ })),
42+});
43+
44+export const fsStatInputSchema = fsAccessSchema.extend({
45+ path: z.string(),
46+});
47+
48+export const fsStatOutputSchema = z.object({
49+ instanceId: z.string(),
50+ path: z.string(),
51+ exists: z.boolean(),
52+ type: z.enum(["file", "directory", "symlink", "unknown"]).optional(),
53+ size: z.number().optional(),
54+ mode: z.number().optional(),
55+ mtime: z.number().optional(),
56+ ctime: z.number().optional(),
57+});
58+
59+export const fsMkdirInputSchema = fsAccessSchema.extend({
60+ path: z.string(),
61+});
62+
63+export const fsMkdirOutputSchema = z.object({
64+ instanceId: z.string(),
65+ path: z.string(),
66+ ok: z.boolean(),
67+});
68+
69+export const fsRemoveInputSchema = fsAccessSchema.extend({
70+ path: z.string(),
71+ recursive: z.boolean().optional(),
72+ force: z.boolean().optional(),
73+});
74+
75+export const fsRemoveOutputSchema = z.object({
76+ instanceId: z.string(),
77+ path: z.string(),
78+ ok: z.boolean(),
79+});
80+
81+export const fsReadInputJsonSchema = toJsonSchema(fsReadInputSchema, {
82+ type: "object",
83+ properties: {
84+ ...fsAccessJsonProperties(),
85+ path: { type: "string" },
86+ encoding: { type: "string", enum: ["utf8", "base64"] },
87+ },
88+ required: ["path"],
89+ additionalProperties: false,
90+});
91+
92+export const fsReadOutputJsonSchema = toJsonSchema(fsReadOutputSchema, {
93+ type: "object",
94+ properties: {
95+ instanceId: { type: "string" },
96+ path: { type: "string" },
97+ encoding: { type: "string", enum: ["utf8", "base64"] },
98+ content: { type: "string" },
99+ bytes: { type: "number" },
100+ },
101+ required: ["instanceId", "path", "encoding", "content", "bytes"],
102+ additionalProperties: false,
103+});
104+
105+export const fsWriteInputJsonSchema = toJsonSchema(fsWriteInputSchema, {
106+ type: "object",
107+ properties: {
108+ ...fsAccessJsonProperties(),
109+ path: { type: "string" },
110+ content: { type: "string" },
111+ encoding: { type: "string", enum: ["utf8", "base64"] },
112+ },
113+ required: ["path", "content"],
114+ additionalProperties: false,
115+});
116+
117+export const fsWriteOutputJsonSchema = toJsonSchema(fsWriteOutputSchema, {
118+ type: "object",
119+ properties: {
120+ instanceId: { type: "string" },
121+ path: { type: "string" },
122+ bytesWritten: { type: "number" },
123+ },
124+ required: ["instanceId", "path", "bytesWritten"],
125+ additionalProperties: false,
126+});
127+
128+export const fsReaddirInputJsonSchema = toJsonSchema(fsReaddirInputSchema, {
129+ type: "object",
130+ properties: {
131+ ...fsAccessJsonProperties(),
132+ path: { type: "string" },
133+ },
134+ additionalProperties: false,
135+});
136+
137+export const fsReaddirOutputJsonSchema = toJsonSchema(fsReaddirOutputSchema, {
138+ type: "object",
139+ properties: {
140+ instanceId: { type: "string" },
141+ path: { type: "string" },
142+ entries: { type: "array", items: { type: "object" } },
143+ },
144+ required: ["instanceId", "path", "entries"],
145+ additionalProperties: false,
146+});
147+
148+export const fsStatInputJsonSchema = toJsonSchema(fsStatInputSchema, {
149+ type: "object",
150+ properties: {
151+ ...fsAccessJsonProperties(),
152+ path: { type: "string" },
153+ },
154+ required: ["path"],
155+ additionalProperties: false,
156+});
157+
158+export const fsStatOutputJsonSchema = toJsonSchema(fsStatOutputSchema, {
159+ type: "object",
160+ properties: {
161+ instanceId: { type: "string" },
162+ path: { type: "string" },
163+ exists: { type: "boolean" },
164+ type: { type: "string", enum: ["file", "directory", "symlink", "unknown"] },
165+ size: { type: "number" },
166+ mode: { type: "number" },
167+ mtime: { type: "number" },
168+ ctime: { type: "number" },
169+ },
170+ required: ["instanceId", "path", "exists"],
171+ additionalProperties: false,
172+});
173+
174+export const fsMkdirInputJsonSchema = toJsonSchema(fsMkdirInputSchema, {
175+ type: "object",
176+ properties: {
177+ ...fsAccessJsonProperties(),
178+ path: { type: "string" },
179+ },
180+ required: ["path"],
181+ additionalProperties: false,
182+});
183+
184+export const fsMkdirOutputJsonSchema = toJsonSchema(fsMkdirOutputSchema, {
185+ type: "object",
186+ properties: {
187+ instanceId: { type: "string" },
188+ path: { type: "string" },
189+ ok: { type: "boolean" },
190+ },
191+ required: ["instanceId", "path", "ok"],
192+ additionalProperties: false,
193+});
194+
195+export const fsRemoveInputJsonSchema = toJsonSchema(fsRemoveInputSchema, {
196+ type: "object",
197+ properties: {
198+ ...fsAccessJsonProperties(),
199+ path: { type: "string" },
200+ recursive: { type: "boolean" },
201+ force: { type: "boolean" },
202+ },
203+ required: ["path"],
204+ additionalProperties: false,
205+});
206+
207+export const fsRemoveOutputJsonSchema = toJsonSchema(fsRemoveOutputSchema, {
208+ type: "object",
209+ properties: {
210+ instanceId: { type: "string" },
211+ path: { type: "string" },
212+ ok: { type: "boolean" },
213+ },
214+ required: ["instanceId", "path", "ok"],
215+ additionalProperties: false,
216+});
executor/packages/database/src/runtime/storage_tool_contracts/kv.ts+187−0
@@ -0,0 +1,187 @@
1+import { z } from "zod";
2+import {
3+ fsAccessJsonProperties,
4+ fsAccessSchema,
5+ jsonValueJsonSchema,
6+ jsonValueSchema,
7+ toJsonSchema,
8+} from "./shared";
9+
10+export const kvGetInputSchema = fsAccessSchema.extend({
11+ key: z.string(),
12+});
13+
14+export const kvGetOutputSchema = z.object({
15+ instanceId: z.string(),
16+ key: z.string(),
17+ found: z.boolean(),
18+ value: jsonValueSchema.optional(),
19+});
20+
21+export const kvSetInputSchema = fsAccessSchema.extend({
22+ key: z.string(),
23+ value: jsonValueSchema,
24+});
25+
26+export const kvSetOutputSchema = z.object({
27+ instanceId: z.string(),
28+ key: z.string(),
29+ ok: z.boolean(),
30+});
31+
32+export const kvListInputSchema = fsAccessSchema.extend({
33+ prefix: z.string().optional(),
34+ limit: z.coerce.number().optional(),
35+});
36+
37+export const kvListOutputSchema = z.object({
38+ instanceId: z.string(),
39+ items: z.array(z.object({ key: z.string(), value: jsonValueSchema })),
40+ total: z.number(),
41+});
42+
43+export const kvDeleteInputSchema = fsAccessSchema.extend({
44+ key: z.string(),
45+});
46+
47+export const kvDeleteOutputSchema = z.object({
48+ instanceId: z.string(),
49+ key: z.string(),
50+ ok: z.boolean(),
51+});
52+
53+export const kvIncrInputSchema = fsAccessSchema.extend({
54+ key: z.string(),
55+ by: z.coerce.number().optional(),
56+ initial: z.coerce.number().optional(),
57+});
58+
59+export const kvIncrOutputSchema = z.object({
60+ instanceId: z.string(),
61+ key: z.string(),
62+ by: z.number(),
63+ previous: z.number(),
64+ value: z.number(),
65+});
66+
67+export const kvGetInputJsonSchema = toJsonSchema(kvGetInputSchema, {
68+ type: "object",
69+ properties: {
70+ ...fsAccessJsonProperties(),
71+ key: { type: "string" },
72+ },
73+ required: ["key"],
74+ additionalProperties: false,
75+});
76+
77+export const kvGetOutputJsonSchema = toJsonSchema(kvGetOutputSchema, {
78+ type: "object",
79+ properties: {
80+ instanceId: { type: "string" },
81+ key: { type: "string" },
82+ found: { type: "boolean" },
83+ value: jsonValueJsonSchema,
84+ },
85+ required: ["instanceId", "key", "found"],
86+ additionalProperties: false,
87+});
88+
89+export const kvSetInputJsonSchema = toJsonSchema(kvSetInputSchema, {
90+ type: "object",
91+ properties: {
92+ ...fsAccessJsonProperties(),
93+ key: { type: "string" },
94+ value: jsonValueJsonSchema,
95+ },
96+ required: ["key", "value"],
97+ additionalProperties: false,
98+});
99+
100+export const kvSetOutputJsonSchema = toJsonSchema(kvSetOutputSchema, {
101+ type: "object",
102+ properties: {
103+ instanceId: { type: "string" },
104+ key: { type: "string" },
105+ ok: { type: "boolean" },
106+ },
107+ required: ["instanceId", "key", "ok"],
108+ additionalProperties: false,
109+});
110+
111+export const kvListInputJsonSchema = toJsonSchema(kvListInputSchema, {
112+ type: "object",
113+ properties: {
114+ ...fsAccessJsonProperties(),
115+ prefix: { type: "string" },
116+ limit: { type: "number" },
117+ },
118+ additionalProperties: false,
119+});
120+
121+export const kvListOutputJsonSchema = toJsonSchema(kvListOutputSchema, {
122+ type: "object",
123+ properties: {
124+ instanceId: { type: "string" },
125+ items: {
126+ type: "array",
127+ items: {
128+ type: "object",
129+ properties: {
130+ key: { type: "string" },
131+ value: jsonValueJsonSchema,
132+ },
133+ required: ["key", "value"],
134+ additionalProperties: false,
135+ },
136+ },
137+ total: { type: "number" },
138+ },
139+ required: ["instanceId", "items", "total"],
140+ additionalProperties: false,
141+});
142+
143+export const kvDeleteInputJsonSchema = toJsonSchema(kvDeleteInputSchema, {
144+ type: "object",
145+ properties: {
146+ ...fsAccessJsonProperties(),
147+ key: { type: "string" },
148+ },
149+ required: ["key"],
150+ additionalProperties: false,
151+});
152+
153+export const kvDeleteOutputJsonSchema = toJsonSchema(kvDeleteOutputSchema, {
154+ type: "object",
155+ properties: {
156+ instanceId: { type: "string" },
157+ key: { type: "string" },
158+ ok: { type: "boolean" },
159+ },
160+ required: ["instanceId", "key", "ok"],
161+ additionalProperties: false,
162+});
163+
164+export const kvIncrInputJsonSchema = toJsonSchema(kvIncrInputSchema, {
165+ type: "object",
166+ properties: {
167+ ...fsAccessJsonProperties(),
168+ key: { type: "string" },
169+ by: { type: "number" },
170+ initial: { type: "number" },
171+ },
172+ required: ["key"],
173+ additionalProperties: false,
174+});
175+
176+export const kvIncrOutputJsonSchema = toJsonSchema(kvIncrOutputSchema, {
177+ type: "object",
178+ properties: {
179+ instanceId: { type: "string" },
180+ key: { type: "string" },
181+ by: { type: "number" },
182+ previous: { type: "number" },
183+ value: { type: "number" },
184+ },
185+ required: ["instanceId", "key", "by", "previous", "value"],
186+ additionalProperties: false,
187+});
executor/packages/database/src/runtime/storage_tool_contracts/shared.ts+109−0
@@ -0,0 +1,109 @@
1+import { z } from "zod";
2+
3+export type JsonSchema = Record<string, unknown>;
4+export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
5+
6+function isRecord(value: unknown): value is Record<string, unknown> {
7+ return typeof value === "object" && value !== null && !Array.isArray(value);
8+}
9+
10+function mergeSchemaWithFallback(schema: unknown, fallback: JsonSchema): JsonSchema {
11+ if (!isRecord(schema)) {
12+ return fallback;
13+ }
14+
15+ const merged: JsonSchema = { ...schema };
16+
17+ if (typeof fallback.description === "string" && typeof merged.description !== "string") {
18+ merged.description = fallback.description;
19+ }
20+
21+ const schemaProps = isRecord(merged.properties) ? { ...merged.properties } : {};
22+ const fallbackProps = isRecord(fallback.properties) ? fallback.properties : {};
23+ for (const [key, fallbackProp] of Object.entries(fallbackProps)) {
24+ const currentProp = schemaProps[key];
25+ if (!isRecord(currentProp)) {
26+ schemaProps[key] = fallbackProp;
27+ continue;
28+ }
29+
30+ if (isRecord(fallbackProp)) {
31+ schemaProps[key] = {
32+ ...fallbackProp,
33+ ...currentProp,
34+ ...(typeof fallbackProp.description === "string" && typeof currentProp.description !== "string"
35+ ? { description: fallbackProp.description }
36+ : {}),
37+ };
38+ }
39+ }
40+ if (Object.keys(schemaProps).length > 0) {
41+ merged.properties = schemaProps;
42+ }
43+
44+ if (!Array.isArray(merged.required) && Array.isArray(fallback.required)) {
45+ merged.required = fallback.required;
46+ }
47+ if (!("additionalProperties" in merged) && "additionalProperties" in fallback) {
48+ merged.additionalProperties = fallback.additionalProperties;
49+ }
50+
51+ return merged;
52+}
53+
54+export function toJsonSchema(schema: z.ZodTypeAny, fallback: JsonSchema): JsonSchema {
55+ const maybeToJsonSchema = (z as unknown as { toJSONSchema?: (value: z.ZodTypeAny) => unknown }).toJSONSchema;
56+ if (typeof maybeToJsonSchema === "function") {
57+ try {
58+ return mergeSchemaWithFallback(maybeToJsonSchema(schema), fallback);
59+ } catch {
60+ return fallback;
61+ }
62+ }
63+
64+ return fallback;
65+}
66+
67+export const storageScopeSchema = z.enum(["scratch", "account", "workspace", "organization"]);
68+export const storageDurabilitySchema = z.enum(["ephemeral", "durable"]);
69+export const storageStatusSchema = z.enum(["active", "closed", "deleted"]);
70+export const storageProviderSchema = z.enum(["agentfs-local", "agentfs-cloudflare"]);
71+
72+export const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() => z.union([
73+ z.string(),
74+ z.number(),
75+ z.boolean(),
76+ z.null(),
77+ z.array(jsonValueSchema),
78+ z.record(jsonValueSchema),
79+]));
80+
81+export const jsonValueJsonSchema = {
82+ oneOf: [
83+ { type: "string" },
84+ { type: "number" },
85+ { type: "boolean" },
86+ { type: "null" },
87+ { type: "array", items: {} },
88+ { type: "object", additionalProperties: {} },
89+ ],
90+};
91+
92+export const fsAccessSchema = z.object({
93+ instanceId: z.string().optional(),
94+ scopeType: storageScopeSchema.optional(),
95+});
96+
97+export function fsAccessJsonProperties() {
98+ return {
99+ instanceId: {
100+ type: "string",
101+ description: "Storage instance identifier. Recommended for cross-task/cross-run persistence.",
102+ },
103+ scopeType: {
104+ type: "string",
105+ enum: ["scratch", "account", "workspace", "organization"],
106+ description: "Scope used to open/select a default instance when instanceId is omitted.",
107+ },
108+ };
109+}
executor/packages/database/src/runtime/storage_tool_contracts/sqlite.ts+176−0
@@ -0,0 +1,176 @@
1+import { z } from "zod";
2+import {
3+ fsAccessJsonProperties,
4+ fsAccessSchema,
5+ jsonValueJsonSchema,
6+ jsonValueSchema,
7+ storageProviderSchema,
8+ toJsonSchema,
9+} from "./shared";
10+
11+export const sqliteQueryInputSchema = fsAccessSchema.extend({
12+ sql: z.string(),
13+ params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(),
14+ mode: z.enum(["read", "write"]).optional(),
15+ maxRows: z.coerce.number().optional(),
16+});
17+
18+export const sqliteQueryOutputSchema = z.object({
19+ instanceId: z.string(),
20+ mode: z.enum(["read", "write"]),
21+ rows: z.array(z.record(jsonValueSchema)).optional(),
22+ rowCount: z.number(),
23+ changes: z.number().optional(),
24+});
25+
26+const sqliteScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
27+
28+export const sqliteCapabilitiesInputSchema = fsAccessSchema.extend({});
29+
30+export const sqliteCapabilitiesOutputSchema = z.object({
31+ instanceId: z.string(),
32+ provider: storageProviderSchema,
33+ maxBindVariables: z.number(),
34+ supportsJsonEach: z.boolean(),
35+ supportsInsertRowsTool: z.boolean(),
36+ guidance: z.array(z.string()),
37+});
38+
39+export const sqliteInsertRowsInputSchema = fsAccessSchema.extend({
40+ table: z.string(),
41+ columns: z.array(z.string()).min(1),
42+ rows: z.array(z.array(sqliteScalarSchema)).min(1),
43+ onConflict: z.enum(["none", "ignore", "replace"]).optional(),
44+ chunkSize: z.coerce.number().optional(),
45+});
46+
47+export const sqliteInsertRowsOutputSchema = z.object({
48+ instanceId: z.string(),
49+ table: z.string(),
50+ columns: z.array(z.string()),
51+ rowsReceived: z.number(),
52+ rowsProcessed: z.number(),
53+ chunkCount: z.number(),
54+ rowsPerChunk: z.number(),
55+ maxBindVariables: z.number(),
56+ changes: z.number(),
57+});
58+
59+export const sqliteQueryInputJsonSchema = toJsonSchema(sqliteQueryInputSchema, {
60+ type: "object",
61+ description:
62+ "Execute SQL on a storage-backed SQLite database. Use instanceId to target the same database across separate tasks or sessions.",
63+ properties: {
64+ ...fsAccessJsonProperties(),
65+ sql: { type: "string", description: "SQL statement text to execute." },
66+ params: {
67+ type: "array",
68+ description:
69+ "Positional SQL parameters. Keep per-call bind count modest (for large inserts, batch rows into smaller chunks or pass one JSON payload and expand with json_each(?)).",
70+ items: {
71+ oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }],
72+ },
73+ },
74+ mode: {
75+ type: "string",
76+ enum: ["read", "write"],
77+ description: "Use 'write' for CREATE/INSERT/UPDATE/DELETE statements.",
78+ },
79+ maxRows: { type: "number", description: "Read mode row cap for result payload size." },
80+ },
81+ required: ["sql"],
82+ additionalProperties: false,
83+});
84+
85+export const sqliteQueryOutputJsonSchema = toJsonSchema(sqliteQueryOutputSchema, {
86+ type: "object",
87+ properties: {
88+ instanceId: { type: "string" },
89+ mode: { type: "string", enum: ["read", "write"] },
90+ rows: { type: "array", items: { type: "object", additionalProperties: jsonValueJsonSchema } },
91+ rowCount: { type: "number" },
92+ changes: { type: "number" },
93+ },
94+ required: ["instanceId", "mode", "rowCount"],
95+ additionalProperties: false,
96+});
97+
98+export const sqliteCapabilitiesInputJsonSchema = toJsonSchema(sqliteCapabilitiesInputSchema, {
99+ type: "object",
100+ description: "Get provider-specific SQLite execution capabilities and batching guidance.",
101+ properties: {
102+ ...fsAccessJsonProperties(),
103+ },
104+ additionalProperties: false,
105+});
106+
107+export const sqliteCapabilitiesOutputJsonSchema = toJsonSchema(sqliteCapabilitiesOutputSchema, {
108+ type: "object",
109+ properties: {
110+ instanceId: { type: "string" },
111+ provider: { type: "string", enum: ["agentfs-local", "agentfs-cloudflare"] },
112+ maxBindVariables: { type: "number" },
113+ supportsJsonEach: { type: "boolean" },
114+ supportsInsertRowsTool: { type: "boolean" },
115+ guidance: { type: "array", items: { type: "string" } },
116+ },
117+ required: [
118+ "instanceId",
119+ "provider",
120+ "maxBindVariables",
121+ "supportsJsonEach",
122+ "supportsInsertRowsTool",
123+ "guidance",
124+ ],
125+ additionalProperties: false,
126+});
127+
128+export const sqliteInsertRowsInputJsonSchema = toJsonSchema(sqliteInsertRowsInputSchema, {
129+ type: "object",
130+ description:
131+ "Insert tabular rows with automatic chunking to stay under SQLite bind-variable limits. Prefer this over huge VALUES(...) statements.",
132+ properties: {
133+ ...fsAccessJsonProperties(),
134+ table: { type: "string", description: "Target table name." },
135+ columns: { type: "array", items: { type: "string" }, minItems: 1 },
136+ rows: {
137+ type: "array",
138+ minItems: 1,
139+ items: {
140+ type: "array",
141+ items: { oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }, { type: "null" }] },
142+ },
143+ },
144+ onConflict: { type: "string", enum: ["none", "ignore", "replace"] },
145+ chunkSize: { type: "number", description: "Optional max rows per insert chunk." },
146+ },
147+ required: ["table", "columns", "rows"],
148+ additionalProperties: false,
149+});
150+
151+export const sqliteInsertRowsOutputJsonSchema = toJsonSchema(sqliteInsertRowsOutputSchema, {
152+ type: "object",
153+ properties: {
154+ instanceId: { type: "string" },
155+ table: { type: "string" },
156+ columns: { type: "array", items: { type: "string" } },
157+ rowsReceived: { type: "number" },
158+ rowsProcessed: { type: "number" },
159+ chunkCount: { type: "number" },
160+ rowsPerChunk: { type: "number" },
161+ maxBindVariables: { type: "number" },
162+ changes: { type: "number" },
163+ },
164+ required: [
165+ "instanceId",
166+ "table",
167+ "columns",
168+ "rowsReceived",
169+ "rowsProcessed",
170+ "chunkCount",
171+ "rowsPerChunk",
172+ "maxBindVariables",
173+ "changes",
174+ ],
175+ additionalProperties: false,
176+});
executor/packages/database/src/runtime/storage_tool_contracts/storage.ts+130−0
@@ -0,0 +1,130 @@
1+import { z } from "zod";
2+import {
3+ storageDurabilitySchema,
4+ storageProviderSchema,
5+ storageScopeSchema,
6+ storageStatusSchema,
7+ toJsonSchema,
8+} from "./shared";
9+
10+export const storageInstanceSchema = z.object({
11+ id: z.string(),
12+ scopeType: storageScopeSchema,
13+ durability: storageDurabilitySchema,
14+ status: storageStatusSchema,
15+ provider: storageProviderSchema,
16+ backendKey: z.string(),
17+ organizationId: z.string(),
18+ workspaceId: z.string().optional(),
19+ accountId: z.string().optional(),
20+ createdByAccountId: z.string().optional(),
21+ purpose: z.string().optional(),
22+ sizeBytes: z.number().optional(),
23+ fileCount: z.number().optional(),
24+ createdAt: z.number(),
25+ updatedAt: z.number(),
26+ lastSeenAt: z.number(),
27+ closedAt: z.number().optional(),
28+ expiresAt: z.number().optional(),
29+});
30+
31+export const storageOpenInputSchema = z.object({
32+ instanceId: z.string().optional(),
33+ scopeType: storageScopeSchema.optional(),
34+ durability: storageDurabilitySchema.optional(),
35+ purpose: z.string().optional(),
36+ ttlHours: z.coerce.number().optional(),
37+});
38+
39+export const storageOpenOutputSchema = z.object({
40+ instance: storageInstanceSchema,
41+});
42+
43+export const storageListInputSchema = z.object({
44+ scopeType: storageScopeSchema.optional(),
45+ includeDeleted: z.boolean().optional(),
46+});
47+
48+export const storageListOutputSchema = z.object({
49+ instances: z.array(storageInstanceSchema),
50+ total: z.number(),
51+});
52+
53+export const storageCloseInputSchema = z.object({
54+ instanceId: z.string(),
55+});
56+
57+export const storageCloseOutputSchema = z.object({
58+ instance: storageInstanceSchema.nullable(),
59+});
60+
61+export const storageDeleteInputSchema = z.object({
62+ instanceId: z.string(),
63+});
64+
65+export const storageDeleteOutputSchema = z.object({
66+ instance: storageInstanceSchema.nullable(),
67+});
68+
69+export const storageOpenInputJsonSchema = toJsonSchema(storageOpenInputSchema, {
70+ type: "object",
71+ properties: {
72+ instanceId: { type: "string" },
73+ scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
74+ durability: { type: "string", enum: ["ephemeral", "durable"] },
75+ purpose: { type: "string" },
76+ ttlHours: { type: "number" },
77+ },
78+ additionalProperties: false,
79+});
80+
81+export const storageOpenOutputJsonSchema = toJsonSchema(storageOpenOutputSchema, {
82+ type: "object",
83+ properties: {
84+ instance: { type: "object" },
85+ },
86+ required: ["instance"],
87+ additionalProperties: false,
88+});
89+
90+export const storageListInputJsonSchema = toJsonSchema(storageListInputSchema, {
91+ type: "object",
92+ properties: {
93+ scopeType: { type: "string", enum: ["scratch", "account", "workspace", "organization"] },
94+ includeDeleted: { type: "boolean" },
95+ },
96+ additionalProperties: false,
97+});
98+
99+export const storageListOutputJsonSchema = toJsonSchema(storageListOutputSchema, {
100+ type: "object",
101+ properties: {
102+ instances: { type: "array", items: { type: "object" } },
103+ total: { type: "number" },
104+ },
105+ required: ["instances", "total"],
106+ additionalProperties: false,
107+});
108+
109+export const storageCloseInputJsonSchema = toJsonSchema(storageCloseInputSchema, {
110+ type: "object",
111+ properties: {
112+ instanceId: { type: "string" },
113+ },
114+ required: ["instanceId"],
115+ additionalProperties: false,
116+});
117+
118+export const storageCloseOutputJsonSchema = toJsonSchema(storageCloseOutputSchema, {
119+ type: "object",
120+ properties: {
121+ instance: {
122+ oneOf: [{ type: "object" }, { type: "null" }],
123+ },
124+ },
125+ required: ["instance"],
126+ additionalProperties: false,
127+});
128+
129+export const storageDeleteInputJsonSchema = storageCloseInputJsonSchema;
130+export const storageDeleteOutputJsonSchema = storageCloseOutputJsonSchema;
executor/packages/database/src/runtime/storage_tooling/context.ts+366−0
@@ -0,0 +1,366 @@
1+import type { ActionCtx } from "../../../convex/_generated/server";
2+import { internal } from "../../../convex/_generated/api";
3+import type { TaskRecord, StorageInstanceRecord } from "../../../../core/src/types";
4+import { getStorageProvider, type StorageProvider } from "../storage_provider";
5+import { shouldTouchStorageOnRead } from "../storage_touch_policy";
6+import { shouldRefreshStorageUsage } from "../storage_usage_refresh";
7+
8+export type StorageScopeType = "scratch" | "account" | "workspace" | "organization";
9+
10+type TaskStorageDefaults = {
11+ currentInstanceId?: string;
12+ currentScopeType?: StorageScopeType;
13+ byScope: Partial<Record<StorageScopeType, string>>;
14+};
15+
16+export type StorageToolHandlerArgs = {
17+ ctx: ActionCtx;
18+ task: TaskRecord;
19+ payload: Record<string, unknown>;
20+ normalizedToolPath: string;
21+};
22+
23+export const SQLITE_MAX_BIND_VARIABLES = 100;
24+
25+function toInputRecord(input: unknown): Record<string, unknown> {
26+ if (!input || typeof input !== "object" || Array.isArray(input)) {
27+ return {};
28+ }
29+
30+ return input as Record<string, unknown>;
31+}
32+
33+export function normalizeScopeType(value: unknown): StorageScopeType | undefined {
34+ if (typeof value !== "string") return undefined;
35+ const normalized = value.trim().toLowerCase();
36+ if (normalized === "scratch" || normalized === "account" || normalized === "workspace" || normalized === "organization") {
37+ return normalized;
38+ }
39+ return undefined;
40+}
41+
42+export function isReadOnlySql(sql: string): boolean {
43+ const trimmed = sql.trim().toLowerCase();
44+ return trimmed.startsWith("select")
45+ || trimmed.startsWith("pragma")
46+ || trimmed.startsWith("explain")
47+ || trimmed.startsWith("with");
48+}
49+
50+function toErrorMessage(error: unknown): string {
51+ if (error instanceof Error && typeof error.message === "string") {
52+ return error.message;
53+ }
54+ return String(error);
55+}
56+
57+function estimateSqlBindCount(sql: string): number {
58+ let inSingleQuote = false;
59+ let inDoubleQuote = false;
60+ let count = 0;
61+
62+ for (let index = 0; index < sql.length; index += 1) {
63+ const char = sql[index];
64+ if (char === "'" && !inDoubleQuote) {
65+ inSingleQuote = !inSingleQuote;
66+ continue;
67+ }
68+ if (char === '"' && !inSingleQuote) {
69+ inDoubleQuote = !inDoubleQuote;
70+ continue;
71+ }
72+ if (!inSingleQuote && !inDoubleQuote && char === "?") {
73+ count += 1;
74+ }
75+ }
76+
77+ return count;
78+}
79+
80+export function decorateSqliteError(error: unknown, args: {
81+ sql: string;
82+ params: Array<string | number | boolean | null>;
83+ instanceId: string;
84+}): Error {
85+ const original = toErrorMessage(error);
86+ const normalized = original.toLowerCase();
87+
88+ if (normalized.includes("too many sql variables")) {
89+ const bindCount = estimateSqlBindCount(args.sql);
90+ const paramsCount = args.params.length;
91+ return new Error(
92+ [
93+ original,
94+ `sqlite.query guidance: this statement used ${paramsCount} params and ~${bindCount} '?' placeholders.`,
95+ "Use smaller batches, or prefer JSON batching: INSERT ... SELECT ... FROM json_each(?) with one JSON payload param.",
96+ "Keep using instanceId to write/read the same database across task runs.",
97+ ].join(" "),
98+ );
99+ }
100+
101+ if (normalized.includes("no such table")) {
102+ return new Error(
103+ [
104+ original,
105+ `sqlite.query guidance: table lookup happened in instanceId=${args.instanceId}.`,
106+ "If the table was created in another run, pass that exact instanceId explicitly.",
107+ ].join(" "),
108+ );
109+ }
110+
111+ if (normalized.includes("timed out") || normalized.includes("timeout")) {
112+ return new Error(
113+ [
114+ original,
115+ "sqlite.query guidance: reduce batch size and split long imports into multiple calls.",
116+ "For bulk inserts, use json_each(?) payload batches instead of very large VALUES(...) statements.",
117+ ].join(" "),
118+ );
119+ }
120+
121+ return error instanceof Error ? error : new Error(original);
122+}
123+
124+export function assertSqlIdentifier(identifier: string, label: string): string {
125+ const trimmed = identifier.trim();
126+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
127+ throw new Error(`${label} must be a valid SQLite identifier`);
128+ }
129+ return trimmed;
130+}
131+
132+export function quoteSqlIdentifier(identifier: string): string {
133+ return `"${identifier.replaceAll('"', '""')}"`;
134+}
135+
136+export function assertFiniteNumber(value: unknown, label: string): number {
137+ if (typeof value !== "number" || !Number.isFinite(value)) {
138+ throw new Error(`${label} must be a finite number`);
139+ }
140+ return value;
141+}
142+
143+export function normalizeInstanceId(value: unknown): string | undefined {
144+ if (typeof value !== "string") return undefined;
145+ const normalized = value.trim();
146+ return normalized.length > 0 ? normalized : undefined;
147+}
148+
149+export function normalizeInputPayload(input: unknown): Record<string, unknown> {
150+ const payload = toInputRecord(input);
151+ return {
152+ ...payload,
153+ ...(normalizeScopeType(payload.scopeType) ? { scopeType: normalizeScopeType(payload.scopeType) } : {}),
154+ ...(normalizeInstanceId(payload.instanceId) ? { instanceId: normalizeInstanceId(payload.instanceId) } : {}),
155+ };
156+}
157+
158+function asRecord(value: unknown): Record<string, unknown> {
159+ return value && typeof value === "object" && !Array.isArray(value)
160+ ? value as Record<string, unknown>
161+ : {};
162+}
163+
164+function parseTaskStorageDefaults(metadata: unknown): TaskStorageDefaults {
165+ const root = asRecord(metadata);
166+ const storage = asRecord(root.storage);
167+ const byScopeRaw = asRecord(storage.defaultInstanceByScope);
168+ const byScope: Partial<Record<StorageScopeType, string>> = {};
169+
170+ for (const scopeType of ["scratch", "account", "workspace", "organization"] as const) {
171+ const value = normalizeInstanceId(byScopeRaw[scopeType]);
172+ if (value) {
173+ byScope[scopeType] = value;
174+ }
175+ }
176+
177+ return {
178+ currentInstanceId: normalizeInstanceId(storage.currentInstanceId),
179+ currentScopeType: normalizeScopeType(storage.currentScopeType),
180+ byScope,
181+ };
182+}
183+
184+async function getTaskStorageDefaults(
185+ ctx: ActionCtx,
186+ task: TaskRecord,
187+): Promise<TaskStorageDefaults> {
188+ const latest = await ctx.runQuery(internal.database.getTask, { taskId: task.id });
189+ if (!latest) {
190+ return { byScope: {} };
191+ }
192+
193+ return parseTaskStorageDefaults((latest as TaskRecord).metadata);
194+}
195+
196+export async function saveTaskStorageDefault(
197+ ctx: ActionCtx,
198+ task: TaskRecord,
199+ scopeType: StorageScopeType,
200+ instanceId: string,
201+ setCurrent = true,
202+ accessType: "opened" | "provided" | "accessed" = "accessed",
203+) {
204+ await ctx.runMutation(internal.database.setTaskStorageDefaultInstance, {
205+ taskId: task.id,
206+ scopeType,
207+ instanceId,
208+ setCurrent,
209+ });
210+
211+ await ctx.runMutation(internal.database.trackTaskStorageAccess, {
212+ taskId: task.id,
213+ instanceId,
214+ scopeType,
215+ accessType,
216+ });
217+}
218+
219+export async function trackTaskStorageAccess(
220+ ctx: ActionCtx,
221+ task: TaskRecord,
222+ args: {
223+ instanceId: string;
224+ scopeType?: StorageScopeType;
225+ accessType: "opened" | "provided" | "accessed";
226+ },
227+) {
228+ await ctx.runMutation(internal.database.trackTaskStorageAccess, {
229+ taskId: task.id,
230+ instanceId: args.instanceId,
231+ scopeType: args.scopeType,
232+ accessType: args.accessType,
233+ });
234+}
235+
236+export async function openStorageInstanceForTask(
237+ ctx: ActionCtx,
238+ task: TaskRecord,
239+ args: {
240+ instanceId?: string;
241+ scopeType?: StorageScopeType;
242+ durability?: "ephemeral" | "durable";
243+ purpose?: string;
244+ ttlHours?: number;
245+ },
246+): Promise<StorageInstanceRecord> {
247+ const opened = await ctx.runMutation(internal.database.openStorageInstance, {
248+ workspaceId: task.workspaceId,
249+ accountId: task.accountId,
250+ instanceId: args.instanceId,
251+ scopeType: args.scopeType,
252+ durability: args.durability,
253+ purpose: args.purpose,
254+ ttlHours: args.ttlHours,
255+ });
256+ return opened as StorageInstanceRecord;
257+}
258+
259+export async function resolveStorageInstance(
260+ ctx: ActionCtx,
261+ task: TaskRecord,
262+ payload: Record<string, unknown>,
263+): Promise<StorageInstanceRecord> {
264+ const requestedInstanceId = normalizeInstanceId(payload.instanceId);
265+ const requestedScopeType = normalizeScopeType(payload.scopeType);
266+
267+ if (requestedInstanceId) {
268+ const existing = await ctx.runQuery(internal.database.getStorageInstance, {
269+ workspaceId: task.workspaceId,
270+ accountId: task.accountId,
271+ instanceId: requestedInstanceId,
272+ });
273+ if (!existing) {
274+ throw new Error(`Storage instance not found: ${requestedInstanceId}`);
275+ }
276+
277+ const reopened = await openStorageInstanceForTask(ctx, task, {
278+ instanceId: requestedInstanceId,
279+ });
280+ await saveTaskStorageDefault(ctx, task, reopened.scopeType, reopened.id, true, "provided");
281+ return reopened;
282+ }
283+
284+ const defaults = await getTaskStorageDefaults(ctx, task);
285+
286+ const candidateIds: string[] = [];
287+ if (requestedScopeType) {
288+ const forScope = defaults.byScope[requestedScopeType];
289+ if (forScope) {
290+ candidateIds.push(forScope);
291+ }
292+ if (defaults.currentScopeType === requestedScopeType && defaults.currentInstanceId) {
293+ candidateIds.push(defaults.currentInstanceId);
294+ }
295+ } else {
296+ if (defaults.currentInstanceId) {
297+ candidateIds.push(defaults.currentInstanceId);
298+ }
299+ if (defaults.byScope.scratch) {
300+ candidateIds.push(defaults.byScope.scratch);
301+ }
302+ }
303+
304+ const uniqueCandidateIds = [...new Set(candidateIds)];
305+ for (const candidateId of uniqueCandidateIds) {
306+ const existing = await ctx.runQuery(internal.database.getStorageInstance, {
307+ workspaceId: task.workspaceId,
308+ accountId: task.accountId,
309+ instanceId: candidateId,
310+ }) as StorageInstanceRecord | null;
311+ if (!existing) {
312+ continue;
313+ }
314+
315+ const reopened = await openStorageInstanceForTask(ctx, task, {
316+ instanceId: candidateId,
317+ });
318+ await saveTaskStorageDefault(ctx, task, reopened.scopeType, reopened.id, true, "accessed");
319+ return reopened;
320+ }
321+
322+ const fallbackScopeType = requestedScopeType ?? defaults.currentScopeType ?? "scratch";
323+
324+ const created = await openStorageInstanceForTask(ctx, task, {
325+ scopeType: fallbackScopeType,
326+ });
327+ await saveTaskStorageDefault(ctx, task, created.scopeType, created.id, true, "opened");
328+ return created;
329+}
330+
331+export async function touchInstance(
332+ ctx: ActionCtx,
333+ task: TaskRecord,
334+ instance: StorageInstanceRecord,
335+ provider: StorageProvider,
336+ withUsage: boolean,
337+) {
338+ if (!withUsage && !shouldTouchStorageOnRead()) {
339+ return;
340+ }
341+
342+ const usage = withUsage && shouldRefreshStorageUsage(instance.id)
343+ ? await provider.usage(instance)
344+ : undefined;
345+
346+ await ctx.runMutation(internal.database.touchStorageInstance, {
347+ workspaceId: task.workspaceId,
348+ accountId: task.accountId,
349+ instanceId: instance.id,
350+ provider: instance.provider,
351+ ...(usage?.sizeBytes !== undefined ? { sizeBytes: usage.sizeBytes } : {}),
352+ ...(usage?.fileCount !== undefined ? { fileCount: usage.fileCount } : {}),
353+ });
354+}
355+
356+export async function resolveStorageProviderForPayload(
357+ ctx: ActionCtx,
358+ task: TaskRecord,
359+ payload: Record<string, unknown>,
360+): Promise<{ instance: StorageInstanceRecord; provider: StorageProvider }> {
361+ const instance = await resolveStorageInstance(ctx, task, payload);
362+ return {
363+ instance,
364+ provider: getStorageProvider(instance.provider),
365+ };
366+}
executor/packages/database/src/runtime/storage_tooling/handlers_fs.ts+111−0
@@ -0,0 +1,111 @@
1+import type { StorageEncoding } from "../storage_provider";
2+import {
3+ fsMkdirInputSchema,
4+ fsMkdirOutputSchema,
5+ fsReadInputSchema,
6+ fsReadOutputSchema,
7+ fsReaddirInputSchema,
8+ fsReaddirOutputSchema,
9+ fsRemoveInputSchema,
10+ fsRemoveOutputSchema,
11+ fsStatInputSchema,
12+ fsStatOutputSchema,
13+ fsWriteInputSchema,
14+ fsWriteOutputSchema,
15+} from "../storage_tool_contracts/fs";
16+import {
17+ resolveStorageProviderForPayload,
18+ touchInstance,
19+ type StorageToolHandlerArgs,
20+} from "./context";
21+
22+export async function runFsHandler(args: StorageToolHandlerArgs): Promise<unknown | undefined> {
23+ const {
24+ ctx,
25+ task,
26+ payload,
27+ normalizedToolPath,
28+ } = args;
29+
30+ if (normalizedToolPath === "fs.read") {
31+ const parsed = fsReadInputSchema.parse(payload);
32+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
33+ const encoding = parsed.encoding ?? "utf8";
34+ const file = await provider.readFile(instance, parsed.path, encoding as StorageEncoding);
35+ await touchInstance(ctx, task, instance, provider, false);
36+ return fsReadOutputSchema.parse({
37+ instanceId: instance.id,
38+ path: parsed.path,
39+ encoding,
40+ content: file.content,
41+ bytes: file.bytes,
42+ });
43+ }
44+
45+ if (normalizedToolPath === "fs.write") {
46+ const parsed = fsWriteInputSchema.parse(payload);
47+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
48+ const encoding = parsed.encoding ?? "utf8";
49+ const result = await provider.writeFile(instance, parsed.path, parsed.content, encoding as StorageEncoding);
50+ await touchInstance(ctx, task, instance, provider, true);
51+ return fsWriteOutputSchema.parse({
52+ instanceId: instance.id,
53+ path: parsed.path,
54+ bytesWritten: result.bytesWritten,
55+ });
56+ }
57+
58+ if (normalizedToolPath === "fs.readdir") {
59+ const parsed = fsReaddirInputSchema.parse(payload);
60+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
61+ const path = parsed.path ?? "/";
62+ const entries = await provider.readdir(instance, path);
63+ await touchInstance(ctx, task, instance, provider, false);
64+ return fsReaddirOutputSchema.parse({
65+ instanceId: instance.id,
66+ path,
67+ entries,
68+ });
69+ }
70+
71+ if (normalizedToolPath === "fs.stat") {
72+ const parsed = fsStatInputSchema.parse(payload);
73+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
74+ const stat = await provider.stat(instance, parsed.path);
75+ await touchInstance(ctx, task, instance, provider, false);
76+ return fsStatOutputSchema.parse({
77+ instanceId: instance.id,
78+ path: parsed.path,
79+ ...stat,
80+ });
81+ }
82+
83+ if (normalizedToolPath === "fs.mkdir") {
84+ const parsed = fsMkdirInputSchema.parse(payload);
85+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
86+ await provider.mkdir(instance, parsed.path);
87+ await touchInstance(ctx, task, instance, provider, true);
88+ return fsMkdirOutputSchema.parse({
89+ instanceId: instance.id,
90+ path: parsed.path,
91+ ok: true,
92+ });
93+ }
94+
95+ if (normalizedToolPath === "fs.remove") {
96+ const parsed = fsRemoveInputSchema.parse(payload);
97+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
98+ await provider.remove(instance, parsed.path, {
99+ recursive: parsed.recursive,
100+ force: parsed.force,
101+ });
102+ await touchInstance(ctx, task, instance, provider, true);
103+ return fsRemoveOutputSchema.parse({
104+ instanceId: instance.id,
105+ path: parsed.path,
106+ ok: true,
107+ });
108+ }
109+
110+ return undefined;
111+}
executor/packages/database/src/runtime/storage_tooling/handlers_kv.ts+105−0
@@ -0,0 +1,105 @@
1+import {
2+ kvDeleteInputSchema,
3+ kvDeleteOutputSchema,
4+ kvGetInputSchema,
5+ kvGetOutputSchema,
6+ kvIncrInputSchema,
7+ kvIncrOutputSchema,
8+ kvListInputSchema,
9+ kvListOutputSchema,
10+ kvSetInputSchema,
11+ kvSetOutputSchema,
12+} from "../storage_tool_contracts/kv";
13+import {
14+ assertFiniteNumber,
15+ resolveStorageProviderForPayload,
16+ touchInstance,
17+ type StorageToolHandlerArgs,
18+} from "./context";
19+
20+export async function runKvHandler(args: StorageToolHandlerArgs): Promise<unknown | undefined> {
21+ const {
22+ ctx,
23+ task,
24+ payload,
25+ normalizedToolPath,
26+ } = args;
27+
28+ if (normalizedToolPath === "kv.get") {
29+ const parsed = kvGetInputSchema.parse(payload);
30+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
31+ const value = await provider.kvGet(instance, parsed.key);
32+ await touchInstance(ctx, task, instance, provider, false);
33+ return kvGetOutputSchema.parse({
34+ instanceId: instance.id,
35+ key: parsed.key,
36+ found: value !== undefined,
37+ ...(value !== undefined ? { value } : {}),
38+ });
39+ }
40+
41+ if (normalizedToolPath === "kv.set") {
42+ const parsed = kvSetInputSchema.parse(payload);
43+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
44+ await provider.kvSet(instance, parsed.key, parsed.value);
45+ await touchInstance(ctx, task, instance, provider, true);
46+ return kvSetOutputSchema.parse({
47+ instanceId: instance.id,
48+ key: parsed.key,
49+ ok: true,
50+ });
51+ }
52+
53+ if (normalizedToolPath === "kv.list") {
54+ const parsed = kvListInputSchema.parse(payload);
55+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
56+ const limit = Math.max(1, Math.min(500, Math.floor(parsed.limit ?? 100)));
57+ const items = await provider.kvList(instance, parsed.prefix ?? "", limit);
58+ await touchInstance(ctx, task, instance, provider, false);
59+ return kvListOutputSchema.parse({
60+ instanceId: instance.id,
61+ items,
62+ total: items.length,
63+ });
64+ }
65+
66+ if (normalizedToolPath === "kv.delete") {
67+ const parsed = kvDeleteInputSchema.parse(payload);
68+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
69+ await provider.kvDelete(instance, parsed.key);
70+ await touchInstance(ctx, task, instance, provider, true);
71+ return kvDeleteOutputSchema.parse({
72+ instanceId: instance.id,
73+ key: parsed.key,
74+ ok: true,
75+ });
76+ }
77+
78+ if (normalizedToolPath === "kv.incr" || normalizedToolPath === "kv.decr") {
79+ const parsed = kvIncrInputSchema.parse(payload);
80+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
81+
82+ const existing = await provider.kvGet(instance, parsed.key);
83+ const initial = assertFiniteNumber(parsed.initial ?? 0, "initial");
84+ const previous = existing === undefined
85+ ? initial
86+ : assertFiniteNumber(existing, `kv value at '${parsed.key}'`);
87+
88+ const rawBy = assertFiniteNumber(parsed.by ?? 1, "by");
89+ const by = normalizedToolPath === "kv.decr" ? -Math.abs(rawBy) : rawBy;
90+ const value = previous + by;
91+
92+ await provider.kvSet(instance, parsed.key, value);
93+ await touchInstance(ctx, task, instance, provider, true);
94+
95+ return kvIncrOutputSchema.parse({
96+ instanceId: instance.id,
97+ key: parsed.key,
98+ by,
99+ previous,
100+ value,
101+ });
102+ }
103+
104+ return undefined;
105+}
executor/packages/database/src/runtime/storage_tooling/handlers_sqlite.ts+151−0
@@ -0,0 +1,151 @@
1+import {
2+ sqliteCapabilitiesInputSchema,
3+ sqliteCapabilitiesOutputSchema,
4+ sqliteInsertRowsInputSchema,
5+ sqliteInsertRowsOutputSchema,
6+ sqliteQueryInputSchema,
7+ sqliteQueryOutputSchema,
8+} from "../storage_tool_contracts/sqlite";
9+import {
10+ assertSqlIdentifier,
11+ decorateSqliteError,
12+ isReadOnlySql,
13+ quoteSqlIdentifier,
14+ resolveStorageProviderForPayload,
15+ SQLITE_MAX_BIND_VARIABLES,
16+ touchInstance,
17+ type StorageToolHandlerArgs,
18+} from "./context";
19+
20+export async function runSqliteHandler(args: StorageToolHandlerArgs): Promise<unknown | undefined> {
21+ const {
22+ ctx,
23+ task,
24+ payload,
25+ normalizedToolPath,
26+ } = args;
27+
28+ if (normalizedToolPath === "sqlite.capabilities") {
29+ sqliteCapabilitiesInputSchema.parse(payload);
30+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
31+ await touchInstance(ctx, task, instance, provider, false);
32+ return sqliteCapabilitiesOutputSchema.parse({
33+ instanceId: instance.id,
34+ provider: instance.provider,
35+ maxBindVariables: SQLITE_MAX_BIND_VARIABLES,
36+ supportsJsonEach: true,
37+ supportsInsertRowsTool: true,
38+ guidance: [
39+ "Prefer sqlite.insert_rows for bulk tabular inserts.",
40+ "Keep bind params per statement under maxBindVariables.",
41+ "For very large payloads, use one JSON payload param and expand with json_each(?).",
42+ "Pass instanceId explicitly to reuse the same database across task runs.",
43+ ],
44+ });
45+ }
46+
47+ if (normalizedToolPath === "sqlite.insert_rows") {
48+ const parsed = sqliteInsertRowsInputSchema.parse(payload);
49+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
50+
51+ const table = assertSqlIdentifier(parsed.table, "table");
52+ const columns = parsed.columns.map((column, index) => assertSqlIdentifier(column, `columns[${index}]`));
53+ if (new Set(columns).size !== columns.length) {
54+ throw new Error("columns must be unique");
55+ }
56+
57+ const rows = parsed.rows;
58+ for (let index = 0; index < rows.length; index += 1) {
59+ if (rows[index].length !== columns.length) {
60+ throw new Error(`rows[${index}] has ${rows[index].length} values but expected ${columns.length}`);
61+ }
62+ }
63+
64+ const maxRowsPerChunk = Math.max(1, Math.floor(SQLITE_MAX_BIND_VARIABLES / Math.max(1, columns.length)));
65+ const requestedChunkSize = typeof parsed.chunkSize === "number" && Number.isFinite(parsed.chunkSize)
66+ ? Math.max(1, Math.floor(parsed.chunkSize))
67+ : maxRowsPerChunk;
68+ const rowsPerChunk = Math.max(1, Math.min(maxRowsPerChunk, requestedChunkSize));
69+
70+ const conflictClause = parsed.onConflict === "ignore"
71+ ? " OR IGNORE"
72+ : parsed.onConflict === "replace"
73+ ? " OR REPLACE"
74+ : "";
75+
76+ const quotedTable = quoteSqlIdentifier(table);
77+ const quotedColumns = columns.map(quoteSqlIdentifier).join(", ");
78+ const placeholderRow = `(${columns.map(() => "?").join(", ")})`;
79+
80+ let totalChanges = 0;
81+ let chunkCount = 0;
82+ for (let start = 0; start < rows.length; start += rowsPerChunk) {
83+ const chunk = rows.slice(start, start + rowsPerChunk);
84+ const values = chunk.map(() => placeholderRow).join(", ");
85+ const params = chunk.flat();
86+ const sql = `INSERT${conflictClause} INTO ${quotedTable} (${quotedColumns}) VALUES ${values}`;
87+
88+ let writeResult;
89+ try {
90+ writeResult = await provider.sqliteQuery(instance, {
91+ sql,
92+ params,
93+ mode: "write",
94+ maxRows: 1,
95+ });
96+ } catch (error) {
97+ throw decorateSqliteError(error, {
98+ sql,
99+ params,
100+ instanceId: instance.id,
101+ });
102+ }
103+
104+ totalChanges += Number(writeResult.changes ?? 0);
105+ chunkCount += 1;
106+ }
107+
108+ await touchInstance(ctx, task, instance, provider, true);
109+ return sqliteInsertRowsOutputSchema.parse({
110+ instanceId: instance.id,
111+ table,
112+ columns,
113+ rowsReceived: rows.length,
114+ rowsProcessed: rows.length,
115+ chunkCount,
116+ rowsPerChunk,
117+ maxBindVariables: SQLITE_MAX_BIND_VARIABLES,
118+ changes: totalChanges,
119+ });
120+ }
121+
122+ if (normalizedToolPath === "sqlite.query") {
123+ const parsed = sqliteQueryInputSchema.parse(payload);
124+ const { instance, provider } = await resolveStorageProviderForPayload(ctx, task, payload);
125+ const mode = parsed.mode ?? (isReadOnlySql(parsed.sql) ? "read" : "write");
126+ const maxRows = Math.max(1, Math.min(1_000, Math.floor(parsed.maxRows ?? 200)));
127+ const params = parsed.params ?? [];
128+ let result;
129+ try {
130+ result = await provider.sqliteQuery(instance, {
131+ sql: parsed.sql,
132+ params,
133+ mode,
134+ maxRows,
135+ });
136+ } catch (error) {
137+ throw decorateSqliteError(error, {
138+ sql: parsed.sql,
139+ params,
140+ instanceId: instance.id,
141+ });
142+ }
143+ await touchInstance(ctx, task, instance, provider, mode === "write");
144+ return sqliteQueryOutputSchema.parse({
145+ instanceId: instance.id,
146+ ...result,
147+ });
148+ }
149+
150+ return undefined;
151+}
executor/packages/database/src/runtime/storage_tooling/handlers_storage.ts+108−0
@@ -0,0 +1,108 @@
1+import { internal } from "../../../convex/_generated/api";
2+import type { StorageInstanceRecord } from "../../../../core/src/types";
3+import { getStorageProvider } from "../storage_provider";
4+import {
5+ storageCloseInputSchema,
6+ storageCloseOutputSchema,
7+ storageDeleteInputSchema,
8+ storageDeleteOutputSchema,
9+ storageListInputSchema,
10+ storageListOutputSchema,
11+ storageOpenInputSchema,
12+ storageOpenOutputSchema,
13+} from "../storage_tool_contracts/storage";
14+import {
15+ openStorageInstanceForTask,
16+ saveTaskStorageDefault,
17+ trackTaskStorageAccess,
18+ type StorageToolHandlerArgs,
19+} from "./context";
20+
21+export async function runStorageInstanceHandler(args: StorageToolHandlerArgs): Promise<unknown | undefined> {
22+ const {
23+ ctx,
24+ task,
25+ payload,
26+ normalizedToolPath,
27+ } = args;
28+
29+ if (normalizedToolPath === "storage.open") {
30+ const parsed = storageOpenInputSchema.parse(payload);
31+ const instance = await openStorageInstanceForTask(ctx, task, parsed);
32+ await saveTaskStorageDefault(
33+ ctx,
34+ task,
35+ instance.scopeType,
36+ instance.id,
37+ true,
38+ parsed.instanceId ? "provided" : "opened",
39+ );
40+ return storageOpenOutputSchema.parse({ instance });
41+ }
42+
43+ if (normalizedToolPath === "storage.list") {
44+ const parsed = storageListInputSchema.parse(payload);
45+ const instances = await ctx.runQuery(internal.database.listStorageInstances, {
46+ workspaceId: task.workspaceId,
47+ accountId: task.accountId,
48+ scopeType: parsed.scopeType,
49+ includeDeleted: parsed.includeDeleted,
50+ });
51+
52+ return storageListOutputSchema.parse({
53+ instances,
54+ total: instances.length,
55+ });
56+ }
57+
58+ if (normalizedToolPath === "storage.close") {
59+ const parsed = storageCloseInputSchema.parse(payload);
60+ const instance = await ctx.runMutation(internal.database.closeStorageInstance, {
61+ workspaceId: task.workspaceId,
62+ accountId: task.accountId,
63+ instanceId: parsed.instanceId,
64+ });
65+
66+ await trackTaskStorageAccess(ctx, task, {
67+ instanceId: parsed.instanceId,
68+ scopeType: instance?.scopeType,
69+ accessType: "provided",
70+ });
71+
72+ return storageCloseOutputSchema.parse({ instance });
73+ }
74+
75+ if (normalizedToolPath === "storage.delete") {
76+ const parsed = storageDeleteInputSchema.parse(payload);
77+ const existing = await ctx.runQuery(internal.database.getStorageInstance, {
78+ workspaceId: task.workspaceId,
79+ accountId: task.accountId,
80+ instanceId: parsed.instanceId,
81+ }) as StorageInstanceRecord | null;
82+
83+ if (existing) {
84+ const provider = getStorageProvider(existing.provider);
85+ try {
86+ await provider.deleteInstance(existing);
87+ } catch {
88+ // Continue marking the instance deleted even if backend cleanup fails.
89+ }
90+ }
91+
92+ const instance = await ctx.runMutation(internal.database.deleteStorageInstance, {
93+ workspaceId: task.workspaceId,
94+ accountId: task.accountId,
95+ instanceId: parsed.instanceId,
96+ });
97+
98+ await trackTaskStorageAccess(ctx, task, {
99+ instanceId: parsed.instanceId,
100+ scopeType: existing?.scopeType,
101+ accessType: "provided",
102+ });
103+
104+ return storageDeleteOutputSchema.parse({ instance });
105+ }
106+
107+ return undefined;
108+}
executor/packages/database/src/runtime/storage_tools.test.ts+23−0
@@ -0,0 +1,23 @@
1+import { describe, expect, test } from "bun:test";
2+import { isStorageSystemToolPath } from "./storage_tools";
3+
4+describe("storage system tool registry", () => {
5+ test("includes canonical storage tools", () => {
6+ expect(isStorageSystemToolPath("storage.open")).toBe(true);
7+ expect(isStorageSystemToolPath("fs.read")).toBe(true);
8+ expect(isStorageSystemToolPath("kv.set")).toBe(true);
9+ expect(isStorageSystemToolPath("sqlite.query")).toBe(true);
10+ });
11+
12+ test("includes alias tool paths", () => {
13+ expect(isStorageSystemToolPath("kv.put")).toBe(true);
14+ expect(isStorageSystemToolPath("kv.del")).toBe(true);
15+ expect(isStorageSystemToolPath("sqlite.exec")).toBe(true);
16+ expect(isStorageSystemToolPath("sqlite.bulk_insert")).toBe(true);
17+ });
18+
19+ test("rejects unrelated tool paths", () => {
20+ expect(isStorageSystemToolPath("discover")).toBe(false);
21+ expect(isStorageSystemToolPath("github.repos.list")).toBe(false);
22+ });
23+});
executor/packages/database/src/runtime/storage_tools.ts+40−756
@@ -1,55 +1,25 @@
11 import type { ActionCtx } from "../../convex/_generated/server";
2−import { internal } from "../../convex/_generated/api";
3−import type { TaskRecord, StorageInstanceRecord } from "../../../core/src/types";
4−import { getStorageProvider, type StorageEncoding, type StorageProvider } from "./storage_provider";
5−import {
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− kvIncrInputSchema,
23− kvIncrOutputSchema,
24− kvListInputSchema,
25− kvListOutputSchema,
26− kvSetInputSchema,
27− kvSetOutputSchema,
28− sqliteCapabilitiesInputSchema,
29− sqliteCapabilitiesOutputSchema,
30− sqliteInsertRowsInputSchema,
31− sqliteInsertRowsOutputSchema,
32− sqliteQueryInputSchema,
33− sqliteQueryOutputSchema,
34− storageCloseInputSchema,
35− storageCloseOutputSchema,
36− storageDeleteInputSchema,
37− storageDeleteOutputSchema,
38− storageListInputSchema,
39− storageListOutputSchema,
40− storageOpenInputSchema,
41− storageOpenOutputSchema,
42−} from "./storage_tool_contracts";
43−
44−type StorageScopeType = "scratch" | "account" | "workspace" | "organization";
45−
46−type TaskStorageDefaults = {
47− currentInstanceId?: string;
48− currentScopeType?: StorageScopeType;
49− byScope: Partial<Record<StorageScopeType, string>>;
2+import type { TaskRecord } from "../../../core/src/types";
3+import { normalizeInputPayload } from "./storage_tooling/context";
4+import { runFsHandler } from "./storage_tooling/handlers_fs";
5+import { runKvHandler } from "./storage_tooling/handlers_kv";
6+import { runSqliteHandler } from "./storage_tooling/handlers_sqlite";
7+import { runStorageInstanceHandler } from "./storage_tooling/handlers_storage";
8+
9+const STORAGE_TOOL_ALIASES: Record<string, string> = {
10+ "kv.put": "kv.set",
11+ "kv.create": "kv.set",
12+ "kv.update": "kv.set",
13+ "kv.del": "kv.delete",
14+ "kv.has": "kv.get",
15+ "kv.exists": "kv.get",
16+ "kv.value": "kv.get",
17+ "kv.keys": "kv.list",
18+ "sqlite.exec": "sqlite.query",
19+ "sqlite.bulk_insert": "sqlite.insert_rows",
5020 };
5121
52−const STORAGE_SYSTEM_TOOLS = new Set([
22+const STORAGE_SYSTEM_TOOLS = new Set<string>([
5323 "storage.open",
5424 "storage.list",
5525 "storage.close",
@@ -81,330 +51,8 @@ const STORAGE_SYSTEM_TOOLS = new Set([ · 25 unmodified lines
8151 "sqlite.bulk_insert",
8252 ]);
8353
84−const SQLITE_MAX_BIND_VARIABLES = 100;
85−
86−function toInputRecord(input: unknown): Record<string, unknown> {
87− if (!input || typeof input !== "object" || Array.isArray(input)) {
88− return {};
89− }
90−
91− return input as Record<string, unknown>;
92−}
93−
94−function normalizeScopeType(value: unknown): undefined | "scratch" | "account" | "workspace" | "organization" {
95− if (typeof value !== "string") return undefined;
96− const normalized = value.trim().toLowerCase();
97− if (normalized === "scratch" || normalized === "account" || normalized === "workspace" || normalized === "organization") {
98− return normalized;
99− }
100− return undefined;
101−}
102−
103−function isReadOnlySql(sql: string): boolean {
104− const trimmed = sql.trim().toLowerCase();
105− return trimmed.startsWith("select")
106− || trimmed.startsWith("pragma")
107− || trimmed.startsWith("explain")
108− || trimmed.startsWith("with");
109−}
110−
111−function toErrorMessage(error: unknown): string {
112− if (error instanceof Error && typeof error.message === "string") {
113− return error.message;
114− }
115− return String(error);
116−}
117−
118−function estimateSqlBindCount(sql: string): number {
119− let inSingleQuote = false;
120− let inDoubleQuote = false;
121− let count = 0;
122−
123− for (let index = 0; index < sql.length; index += 1) {
124− const char = sql[index];
125− if (char === "'" && !inDoubleQuote) {
126− inSingleQuote = !inSingleQuote;
127− continue;
128− }
129− if (char === '"' && !inSingleQuote) {
130− inDoubleQuote = !inDoubleQuote;
131− continue;
132− }
133− if (!inSingleQuote && !inDoubleQuote && char === "?") {
134− count += 1;
135− }
136− }
137−
138− return count;
139−}
140−
141−function decorateSqliteError(error: unknown, args: {
142− sql: string;
143− params: Array<string | number | boolean | null>;
144− instanceId: string;
145−}): Error {
146− const original = toErrorMessage(error);
147− const normalized = original.toLowerCase();
148−
149− if (normalized.includes("too many sql variables")) {
150− const bindCount = estimateSqlBindCount(args.sql);
151− const paramsCount = args.params.length;
152− return new Error(
153− [
154− original,
155− `sqlite.query guidance: this statement used ${paramsCount} params and ~${bindCount} '?' placeholders.`,
156− "Use smaller batches, or prefer JSON batching: INSERT ... SELECT ... FROM json_each(?) with one JSON payload param.",
157− "Keep using instanceId to write/read the same database across task runs.",
158− ].join(" "),
159− );
160− }
161−
162− if (normalized.includes("no such table")) {
163− return new Error(
164− [
165− original,
166− `sqlite.query guidance: table lookup happened in instanceId=${args.instanceId}.`,
167− "If the table was created in another run, pass that exact instanceId explicitly.",
168− ].join(" "),
169− );
170− }
171−
172− if (normalized.includes("timed out") || normalized.includes("timeout")) {
173− return new Error(
174− [
175− original,
176− "sqlite.query guidance: reduce batch size and split long imports into multiple calls.",
177− "For bulk inserts, use json_each(?) payload batches instead of very large VALUES(...) statements.",
178− ].join(" "),
179− );
180− }
181−
182− return error instanceof Error ? error : new Error(original);
183−}
184−
185−function assertSqlIdentifier(identifier: string, label: string): string {
186− const trimmed = identifier.trim();
187− if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
188− throw new Error(`${label} must be a valid SQLite identifier`);
189− }
190− return trimmed;
191−}
192−
193−function quoteSqlIdentifier(identifier: string): string {
194− return `"${identifier.replaceAll('"', '""')}"`;
195−}
196−
197−function assertFiniteNumber(value: unknown, label: string): number {
198− if (typeof value !== "number" || !Number.isFinite(value)) {
199− throw new Error(`${label} must be a finite number`);
200− }
201− return value;
202−}
203−
204−function normalizeInstanceId(value: unknown): string | undefined {
205− if (typeof value !== "string") return undefined;
206− const normalized = value.trim();
207− return normalized.length > 0 ? normalized : undefined;
208−}
209−
210−function normalizeInputPayload(input: unknown): Record<string, unknown> {
211− const payload = toInputRecord(input);
212− return {
213− ...payload,
214− ...(normalizeScopeType(payload.scopeType) ? { scopeType: normalizeScopeType(payload.scopeType) } : {}),
215− ...(normalizeInstanceId(payload.instanceId) ? { instanceId: normalizeInstanceId(payload.instanceId) } : {}),
216− };
217−}
218−
219−function asRecord(value: unknown): Record<string, unknown> {
220− return value && typeof value === "object" && !Array.isArray(value)
221− ? value as Record<string, unknown>
222− : {};
223−}
224−
225−function parseTaskStorageDefaults(metadata: unknown): TaskStorageDefaults {
226− const root = asRecord(metadata);
227− const storage = asRecord(root.storage);
228− const byScopeRaw = asRecord(storage.defaultInstanceByScope);
229− const byScope: Partial<Record<StorageScopeType, string>> = {};
230−
231− for (const scopeType of ["scratch", "account", "workspace", "organization"] as const) {
232− const value = normalizeInstanceId(byScopeRaw[scopeType]);
233− if (value) {
234− byScope[scopeType] = value;
235− }
236− }
237−
238− return {
239− currentInstanceId: normalizeInstanceId(storage.currentInstanceId),
240− currentScopeType: normalizeScopeType(storage.currentScopeType),
241− byScope,
242− };
243−}
244−
245−async function getTaskStorageDefaults(
246− ctx: ActionCtx,
247− task: TaskRecord,
248−): Promise<TaskStorageDefaults> {
249− const latest = await ctx.runQuery(internal.database.getTask, { taskId: task.id });
250− if (!latest) {
251− return { byScope: {} };
252− }
253−
254− return parseTaskStorageDefaults((latest as TaskRecord).metadata);
255−}
256−
257−async function saveTaskStorageDefault(
258− ctx: ActionCtx,
259− task: TaskRecord,
260− scopeType: StorageScopeType,
261− instanceId: string,
262− setCurrent = true,
263− accessType: "opened" | "provided" | "accessed" = "accessed",
264−) {
265− await ctx.runMutation(internal.database.setTaskStorageDefaultInstance, {
266− taskId: task.id,
267− scopeType,
268− instanceId,
269− setCurrent,
270− });
271−
272− await ctx.runMutation(internal.database.trackTaskStorageAccess, {
273− taskId: task.id,
274− instanceId,
275− scopeType,
276− accessType,
277− });
278−}
279−
280−async function trackTaskStorageAccess(
281− ctx: ActionCtx,
282− task: TaskRecord,
283− args: {
284− instanceId: string;
285− scopeType?: StorageScopeType;
286− accessType: "opened" | "provided" | "accessed";
287− },
288−) {
289− await ctx.runMutation(internal.database.trackTaskStorageAccess, {
290− taskId: task.id,
291− instanceId: args.instanceId,
292− scopeType: args.scopeType,
293− accessType: args.accessType,
294− });
295−}
296−
297−async function openStorageInstanceForTask(
298− ctx: ActionCtx,
299− task: TaskRecord,
300− args: {
301− instanceId?: string;
302− scopeType?: "scratch" | "account" | "workspace" | "organization";
303− durability?: "ephemeral" | "durable";
304− purpose?: string;
305− ttlHours?: number;
306− },
307−): Promise<StorageInstanceRecord> {
308− const opened = await ctx.runMutation(internal.database.openStorageInstance, {
309− workspaceId: task.workspaceId,
310− accountId: task.accountId,
311− instanceId: args.instanceId,
312− scopeType: args.scopeType,
313− durability: args.durability,
314− purpose: args.purpose,
315− ttlHours: args.ttlHours,
316− });
317− return opened as StorageInstanceRecord;
318−}
319−
320−async function resolveStorageInstance(
321− ctx: ActionCtx,
322− task: TaskRecord,
323− payload: Record<string, unknown>,
324−): Promise<StorageInstanceRecord> {
325− const requestedInstanceId = normalizeInstanceId(payload.instanceId);
326− const requestedScopeType = normalizeScopeType(payload.scopeType);
327−
328− if (requestedInstanceId) {
329− const existing = await ctx.runQuery(internal.database.getStorageInstance, {
330− workspaceId: task.workspaceId,
331− accountId: task.accountId,
332− instanceId: requestedInstanceId,
333− });
334− if (!existing) {
335− throw new Error(`Storage instance not found: ${requestedInstanceId}`);
336− }
337−
338− const reopened = await openStorageInstanceForTask(ctx, task, {
339− instanceId: requestedInstanceId,
340− });
341− await saveTaskStorageDefault(ctx, task, reopened.scopeType, reopened.id, true, "provided");
342− return reopened;
343− }
344−
345− const defaults = await getTaskStorageDefaults(ctx, task);
346−
347− const candidateIds: string[] = [];
348− if (requestedScopeType) {
349− const forScope = defaults.byScope[requestedScopeType];
350− if (forScope) {
351− candidateIds.push(forScope);
352− }
353− if (defaults.currentScopeType === requestedScopeType && defaults.currentInstanceId) {
354− candidateIds.push(defaults.currentInstanceId);
355− }
356− } else {
357− if (defaults.currentInstanceId) {
358− candidateIds.push(defaults.currentInstanceId);
359− }
360− if (defaults.byScope.scratch) {
361− candidateIds.push(defaults.byScope.scratch);
362− }
363− }
364−
365− const uniqueCandidateIds = [...new Set(candidateIds)];
366− for (const candidateId of uniqueCandidateIds) {
367− const existing = await ctx.runQuery(internal.database.getStorageInstance, {
368− workspaceId: task.workspaceId,
369− accountId: task.accountId,
370− instanceId: candidateId,
371− }) as StorageInstanceRecord | null;
372− if (!existing) {
373− continue;
374− }
375−
376− const reopened = await openStorageInstanceForTask(ctx, task, {
377− instanceId: candidateId,
378− });
379− await saveTaskStorageDefault(ctx, task, reopened.scopeType, reopened.id, true, "accessed");
380− return reopened;
381− }
382−
383− const fallbackScopeType = requestedScopeType ?? defaults.currentScopeType ?? "scratch";
384−
385− const created = await openStorageInstanceForTask(ctx, task, {
386− scopeType: fallbackScopeType,
387− });
388− await saveTaskStorageDefault(ctx, task, created.scopeType, created.id, true, "opened");
389− return created;
390−}
391−
392−async function touchInstance(
393− ctx: ActionCtx,
394− task: TaskRecord,
395− instance: StorageInstanceRecord,
396− provider: StorageProvider,
397− withUsage: boolean,
398−) {
399− const usage = withUsage ? await provider.usage(instance) : undefined;
400− await ctx.runMutation(internal.database.touchStorageInstance, {
401− workspaceId: task.workspaceId,
402− accountId: task.accountId,
403− instanceId: instance.id,
404− provider: instance.provider,
405− ...(usage?.sizeBytes !== undefined ? { sizeBytes: usage.sizeBytes } : {}),
406− ...(usage?.fileCount !== undefined ? { fileCount: usage.fileCount } : {}),
407− });
54+function normalizeStorageToolPath(toolPath: string): string {
55+ return STORAGE_TOOL_ALIASES[toolPath] ?? toolPath;
40856 }
40957
41058 export function isStorageSystemToolPath(path: string): boolean {
@@ -418,396 +66,32 @@ export async function runStorageSystemTool( · 7 unmodified lines
41866 input: unknown,
41967 ): Promise<unknown> {
42068 const payload = normalizeInputPayload(input);
421− const normalizedToolPath = toolPath === "kv.put"
422− ? "kv.set"
423− : toolPath === "kv.create"
424− ? "kv.set"
425− : toolPath === "kv.update"
426− ? "kv.set"
427− : toolPath === "kv.del"
428− ? "kv.delete"
429− : toolPath === "kv.has"
430− ? "kv.get"
431− : toolPath === "kv.exists"
432− ? "kv.get"
433− : toolPath === "kv.value"
434− ? "kv.get"
435− : toolPath === "kv.keys"
436− ? "kv.list"
437− : toolPath === "sqlite.exec"
438− ? "sqlite.query"
439− : toolPath === "sqlite.bulk_insert"
440− ? "sqlite.insert_rows"
441− : toolPath;
442−
443− if (normalizedToolPath === "storage.open") {
444− const parsed = storageOpenInputSchema.parse(payload);
445− const instance = await openStorageInstanceForTask(ctx, task, parsed);
446− await saveTaskStorageDefault(
447− ctx,
448− task,
449− instance.scopeType,
450− instance.id,
451− true,
452− parsed.instanceId ? "provided" : "opened",
453− );
454− return storageOpenOutputSchema.parse({ instance });
455− }
456−
457− if (normalizedToolPath === "storage.list") {
458− const parsed = storageListInputSchema.parse(payload);
459− const instances = await ctx.runQuery(internal.database.listStorageInstances, {
460− workspaceId: task.workspaceId,
461− accountId: task.accountId,
462− scopeType: parsed.scopeType,
463− includeDeleted: parsed.includeDeleted,
464− });
465−
466− return storageListOutputSchema.parse({
467− instances,
468− total: instances.length,
469− });
470− }
471−
472− if (normalizedToolPath === "storage.close") {
473− const parsed = storageCloseInputSchema.parse(payload);
474− const instance = await ctx.runMutation(internal.database.closeStorageInstance, {
475− workspaceId: task.workspaceId,
476− accountId: task.accountId,
477− instanceId: parsed.instanceId,
478− });
479−
480− await trackTaskStorageAccess(ctx, task, {
481− instanceId: parsed.instanceId,
482− scopeType: instance?.scopeType,
483− accessType: "provided",
484− });
485−
486− return storageCloseOutputSchema.parse({ instance });
487− }
488−
489− if (normalizedToolPath === "storage.delete") {
490− const parsed = storageDeleteInputSchema.parse(payload);
491− const existing = await ctx.runQuery(internal.database.getStorageInstance, {
492− workspaceId: task.workspaceId,
493− accountId: task.accountId,
494− instanceId: parsed.instanceId,
495− }) as StorageInstanceRecord | null;
496−
497− if (existing) {
498− const provider = getStorageProvider(existing.provider);
499− try {
500− await provider.deleteInstance(existing);
501− } catch {
502− // Continue marking the instance deleted even if backend cleanup fails.
503− }
504− }
505−
506− const instance = await ctx.runMutation(internal.database.deleteStorageInstance, {
507− workspaceId: task.workspaceId,
508− accountId: task.accountId,
509− instanceId: parsed.instanceId,
510− });
511−
512− await trackTaskStorageAccess(ctx, task, {
513− instanceId: parsed.instanceId,
514− scopeType: existing?.scopeType,
515− accessType: "provided",
516− });
517−
518− return storageDeleteOutputSchema.parse({ instance });
519− }
520−
521− if (normalizedToolPath === "fs.read") {
522− const parsed = fsReadInputSchema.parse(payload);
523− const instance = await resolveStorageInstance(ctx, task, payload);
524− const provider = getStorageProvider(instance.provider);
525− const encoding = parsed.encoding ?? "utf8";
526− const file = await provider.readFile(instance, parsed.path, encoding as StorageEncoding);
527− await touchInstance(ctx, task, instance, provider, false);
528− return fsReadOutputSchema.parse({
529− instanceId: instance.id,
530− path: parsed.path,
531− encoding,
532− content: file.content,
533− bytes: file.bytes,
534− });
535− }
536−
537− if (normalizedToolPath === "fs.write") {
538− const parsed = fsWriteInputSchema.parse(payload);
539− const instance = await resolveStorageInstance(ctx, task, payload);
540− const provider = getStorageProvider(instance.provider);
541− const encoding = parsed.encoding ?? "utf8";
542− const result = await provider.writeFile(instance, parsed.path, parsed.content, encoding as StorageEncoding);
543− await touchInstance(ctx, task, instance, provider, true);
544− return fsWriteOutputSchema.parse({
545− instanceId: instance.id,
546− path: parsed.path,
547− bytesWritten: result.bytesWritten,
548− });
549− }
550−
551− if (normalizedToolPath === "fs.readdir") {
552− const parsed = fsReaddirInputSchema.parse(payload);
553− const instance = await resolveStorageInstance(ctx, task, payload);
554− const provider = getStorageProvider(instance.provider);
555− const path = parsed.path ?? "/";
556− const entries = await provider.readdir(instance, path);
557− await touchInstance(ctx, task, instance, provider, false);
558− return fsReaddirOutputSchema.parse({
559− instanceId: instance.id,
560− path,
561− entries,
562− });
563− }
564−
565− if (normalizedToolPath === "fs.stat") {
566− const parsed = fsStatInputSchema.parse(payload);
567− const instance = await resolveStorageInstance(ctx, task, payload);
568− const provider = getStorageProvider(instance.provider);
569− const stat = await provider.stat(instance, parsed.path);
570− await touchInstance(ctx, task, instance, provider, false);
571− return fsStatOutputSchema.parse({
572− instanceId: instance.id,
573− path: parsed.path,
574− ...stat,
575− });
576− }
577−
578− if (normalizedToolPath === "fs.mkdir") {
579− const parsed = fsMkdirInputSchema.parse(payload);
580− const instance = await resolveStorageInstance(ctx, task, payload);
581− const provider = getStorageProvider(instance.provider);
582− await provider.mkdir(instance, parsed.path);
583− await touchInstance(ctx, task, instance, provider, true);
584− return fsMkdirOutputSchema.parse({
585− instanceId: instance.id,
586− path: parsed.path,
587− ok: true,
588− });
589− }
590−
591− if (normalizedToolPath === "fs.remove") {
592− const parsed = fsRemoveInputSchema.parse(payload);
593− const instance = await resolveStorageInstance(ctx, task, payload);
594− const provider = getStorageProvider(instance.provider);
595− await provider.remove(instance, parsed.path, {
596− recursive: parsed.recursive,
597− force: parsed.force,
598− });
599− await touchInstance(ctx, task, instance, provider, true);
600− return fsRemoveOutputSchema.parse({
601− instanceId: instance.id,
602− path: parsed.path,
603− ok: true,
604− });
605− }
606−
607− if (normalizedToolPath === "kv.get") {
608− const parsed = kvGetInputSchema.parse(payload);
609− const instance = await resolveStorageInstance(ctx, task, payload);
610− const provider = getStorageProvider(instance.provider);
611− const value = await provider.kvGet(instance, parsed.key);
612− await touchInstance(ctx, task, instance, provider, false);
613− return kvGetOutputSchema.parse({
614− instanceId: instance.id,
615− key: parsed.key,
616− found: value !== undefined,
617− ...(value !== undefined ? { value } : {}),
618− });
619− }
620−
621− if (normalizedToolPath === "kv.set") {
622− const parsed = kvSetInputSchema.parse(payload);
623− const instance = await resolveStorageInstance(ctx, task, payload);
624− const provider = getStorageProvider(instance.provider);
625− await provider.kvSet(instance, parsed.key, parsed.value);
626− await touchInstance(ctx, task, instance, provider, true);
627− return kvSetOutputSchema.parse({
628− instanceId: instance.id,
629− key: parsed.key,
630− ok: true,
631− });
632− }
633−
634− if (normalizedToolPath === "kv.list") {
635− const parsed = kvListInputSchema.parse(payload);
636− const instance = await resolveStorageInstance(ctx, task, payload);
637− const provider = getStorageProvider(instance.provider);
638− const limit = Math.max(1, Math.min(500, Math.floor(parsed.limit ?? 100)));
639− const items = await provider.kvList(instance, parsed.prefix ?? "", limit);
640− await touchInstance(ctx, task, instance, provider, false);
641− return kvListOutputSchema.parse({
642− instanceId: instance.id,
643− items,
644− total: items.length,
645− });
646− }
647−
648− if (normalizedToolPath === "kv.delete") {
649− const parsed = kvDeleteInputSchema.parse(payload);
650− const instance = await resolveStorageInstance(ctx, task, payload);
651− const provider = getStorageProvider(instance.provider);
652− await provider.kvDelete(instance, parsed.key);
653− await touchInstance(ctx, task, instance, provider, true);
654− return kvDeleteOutputSchema.parse({
655− instanceId: instance.id,
656− key: parsed.key,
657− ok: true,
658− });
659− }
660−
661− if (normalizedToolPath === "kv.incr" || normalizedToolPath === "kv.decr") {
662− const parsed = kvIncrInputSchema.parse(payload);
663− const instance = await resolveStorageInstance(ctx, task, payload);
664− const provider = getStorageProvider(instance.provider);
665−
666− const existing = await provider.kvGet(instance, parsed.key);
667− const initial = assertFiniteNumber(parsed.initial ?? 0, "initial");
668− const previous = existing === undefined
669− ? initial
670− : assertFiniteNumber(existing, `kv value at '${parsed.key}'`);
671−
672− const rawBy = assertFiniteNumber(parsed.by ?? 1, "by");
673− const by = normalizedToolPath === "kv.decr" ? -Math.abs(rawBy) : rawBy;
674− const value = previous + by;
675−
676− await provider.kvSet(instance, parsed.key, value);
677− await touchInstance(ctx, task, instance, provider, true);
69+ const normalizedToolPath = normalizeStorageToolPath(toolPath);
70+ const handlerArgs = {
71+ ctx,
72+ task,
73+ payload,
74+ normalizedToolPath,
75+ };
67876
679− return kvIncrOutputSchema.parse({
680− instanceId: instance.id,
681− key: parsed.key,
682− by,
683− previous,
684− value,
685− });
77+ const instanceResult = await runStorageInstanceHandler(handlerArgs);
78+ if (instanceResult !== undefined) {
79+ return instanceResult;
68680 }
68781
688− if (normalizedToolPath === "sqlite.capabilities") {
689− sqliteCapabilitiesInputSchema.parse(payload);
690− const instance = await resolveStorageInstance(ctx, task, payload);
691− const provider = getStorageProvider(instance.provider);
692− await touchInstance(ctx, task, instance, provider, false);
693− return sqliteCapabilitiesOutputSchema.parse({
694− instanceId: instance.id,
695− provider: instance.provider,
696− maxBindVariables: SQLITE_MAX_BIND_VARIABLES,
697− supportsJsonEach: true,
698− supportsInsertRowsTool: true,
699− guidance: [
700− "Prefer sqlite.insert_rows for bulk tabular inserts.",
701− "Keep bind params per statement under maxBindVariables.",
702− "For very large payloads, use one JSON payload param and expand with json_each(?).",
703− "Pass instanceId explicitly to reuse the same database across task runs.",
704− ],
705− });
82+ const fsResult = await runFsHandler(handlerArgs);
83+ if (fsResult !== undefined) {
84+ return fsResult;
70685 }
70786
708− if (normalizedToolPath === "sqlite.insert_rows") {
709− const parsed = sqliteInsertRowsInputSchema.parse(payload);
710− const instance = await resolveStorageInstance(ctx, task, payload);
711− const provider = getStorageProvider(instance.provider);
712−
713− const table = assertSqlIdentifier(parsed.table, "table");
714− const columns = parsed.columns.map((column, index) => assertSqlIdentifier(column, `columns[${index}]`));
715− if (new Set(columns).size !== columns.length) {
716− throw new Error("columns must be unique");
717− }
718−
719− const rows = parsed.rows;
720− for (let index = 0; index < rows.length; index += 1) {
721− if (rows[index].length !== columns.length) {
722− throw new Error(`rows[${index}] has ${rows[index].length} values but expected ${columns.length}`);
723− }
724− }
725−
726− const maxRowsPerChunk = Math.max(1, Math.floor(SQLITE_MAX_BIND_VARIABLES / Math.max(1, columns.length)));
727− const requestedChunkSize = typeof parsed.chunkSize === "number" && Number.isFinite(parsed.chunkSize)
728− ? Math.max(1, Math.floor(parsed.chunkSize))
729− : maxRowsPerChunk;
730− const rowsPerChunk = Math.max(1, Math.min(maxRowsPerChunk, requestedChunkSize));
731−
732− const conflictClause = parsed.onConflict === "ignore"
733− ? " OR IGNORE"
734− : parsed.onConflict === "replace"
735− ? " OR REPLACE"
736− : "";
737−
738− const quotedTable = quoteSqlIdentifier(table);
739− const quotedColumns = columns.map(quoteSqlIdentifier).join(", ");
740− const placeholderRow = `(${columns.map(() => "?").join(", ")})`;
741−
742− let totalChanges = 0;
743− let chunkCount = 0;
744− for (let start = 0; start < rows.length; start += rowsPerChunk) {
745− const chunk = rows.slice(start, start + rowsPerChunk);
746− const values = chunk.map(() => placeholderRow).join(", ");
747− const params = chunk.flat();
748− const sql = `INSERT${conflictClause} INTO ${quotedTable} (${quotedColumns}) VALUES ${values}`;
749−
750− let writeResult;
751− try {
752− writeResult = await provider.sqliteQuery(instance, {
753− sql,
754− params,
755− mode: "write",
756− maxRows: 1,
757− });
758− } catch (error) {
759− throw decorateSqliteError(error, {
760− sql,
761− params,
762− instanceId: instance.id,
763− });
764− }
765−
766− totalChanges += Number(writeResult.changes ?? 0);
767− chunkCount += 1;
768− }
769−
770− await touchInstance(ctx, task, instance, provider, true);
771− return sqliteInsertRowsOutputSchema.parse({
772− instanceId: instance.id,
773− table,
774− columns,
775− rowsReceived: rows.length,
776− rowsProcessed: rows.length,
777− chunkCount,
778− rowsPerChunk,
779− maxBindVariables: SQLITE_MAX_BIND_VARIABLES,
780− changes: totalChanges,
781− });
87+ const kvResult = await runKvHandler(handlerArgs);
88+ if (kvResult !== undefined) {
89+ return kvResult;
78290 }
78391
784− if (normalizedToolPath === "sqlite.query") {
785− const parsed = sqliteQueryInputSchema.parse(payload);
786− const instance = await resolveStorageInstance(ctx, task, payload);
787− const provider = getStorageProvider(instance.provider);
788− const mode = parsed.mode ?? (isReadOnlySql(parsed.sql) ? "read" : "write");
789− const maxRows = Math.max(1, Math.min(1_000, Math.floor(parsed.maxRows ?? 200)));
790− const params = parsed.params ?? [];
791− let result;
792− try {
793− result = await provider.sqliteQuery(instance, {
794− sql: parsed.sql,
795− params,
796− mode,
797− maxRows,
798− });
799− } catch (error) {
800− throw decorateSqliteError(error, {
801− sql: parsed.sql,
802− params,
803− instanceId: instance.id,
804− });
805− }
806− await touchInstance(ctx, task, instance, provider, mode === "write");
807− return sqliteQueryOutputSchema.parse({
808− instanceId: instance.id,
809− ...result,
810− });
92+ const sqliteResult = await runSqliteHandler(handlerArgs);
93+ if (sqliteResult !== undefined) {
94+ return sqliteResult;
81195 }
81296
81397 throw new Error(`Unsupported storage system tool: ${toolPath}`);
executor/packages/database/src/runtime/storage_touch_policy.test.ts+24−0
@@ -0,0 +1,24 @@
1+import { describe, expect, test } from "bun:test";
2+import { shouldTouchStorageOnRead } from "./storage_touch_policy";
3+
4+describe("storage read touch policy", () => {
5+ test("defaults to enabled when env value is missing or blank", () => {
6+ expect(shouldTouchStorageOnRead(undefined)).toBe(true);
7+ expect(shouldTouchStorageOnRead("")).toBe(true);
8+ expect(shouldTouchStorageOnRead(" ")).toBe(true);
9+ });
10+
11+ test("disables read touches for explicit false values", () => {
12+ expect(shouldTouchStorageOnRead("0")).toBe(false);
13+ expect(shouldTouchStorageOnRead("false")).toBe(false);
14+ expect(shouldTouchStorageOnRead("off")).toBe(false);
15+ expect(shouldTouchStorageOnRead("no")).toBe(false);
16+ });
17+
18+ test("keeps read touches enabled for true-ish values", () => {
19+ expect(shouldTouchStorageOnRead("1")).toBe(true);
20+ expect(shouldTouchStorageOnRead("true")).toBe(true);
21+ expect(shouldTouchStorageOnRead("yes")).toBe(true);
22+ expect(shouldTouchStorageOnRead("on")).toBe(true);
23+ });
24+});
executor/packages/database/src/runtime/storage_touch_policy.ts+16−0
@@ -0,0 +1,16 @@
1+export function shouldTouchStorageOnRead(rawValue = process.env.AGENT_STORAGE_TOUCH_ON_READ): boolean {
2+ if (rawValue === undefined) {
3+ return true;
4+ }
5+
6+ const normalized = rawValue.trim().toLowerCase();
7+ if (normalized.length === 0) {
8+ return true;
9+ }
10+
11+ if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no") {
12+ return false;
13+ }
14+
15+ return true;
16+}
executor/packages/database/src/runtime/storage_usage_refresh.test.ts+83−0
@@ -0,0 +1,83 @@
1+import { afterEach, describe, expect, test } from "bun:test";
2+import {
3+ getStorageUsageRefreshStats,
4+ resetStorageUsageRefreshForTests,
5+ shouldRefreshStorageUsage,
6+} from "./storage_usage_refresh";
7+
8+describe("storage usage refresh throttling", () => {
9+ afterEach(() => {
10+ resetStorageUsageRefreshForTests();
11+ });
12+
13+ test("refreshes first touch then throttles within interval", () => {
14+ const first = shouldRefreshStorageUsage("inst_1", { now: 1_000, intervalMs: 5_000 });
15+ const second = shouldRefreshStorageUsage("inst_1", { now: 2_000, intervalMs: 5_000 });
16+ const third = shouldRefreshStorageUsage("inst_1", { now: 6_500, intervalMs: 5_000 });
17+
18+ expect(first).toBe(true);
19+ expect(second).toBe(false);
20+ expect(third).toBe(true);
21+ });
22+
23+ test("reset clears in-memory refresh history", () => {
24+ expect(shouldRefreshStorageUsage("inst_2", { now: 100 })).toBe(true);
25+ expect(shouldRefreshStorageUsage("inst_2", { now: 200, intervalMs: 10_000 })).toBe(false);
26+
27+ resetStorageUsageRefreshForTests();
28+
29+ expect(shouldRefreshStorageUsage("inst_2", { now: 300, intervalMs: 10_000 })).toBe(true);
30+ });
31+
32+ test("tracks refresh and throttle stats", () => {
33+ expect(shouldRefreshStorageUsage("inst_a", { now: 10, intervalMs: 5_000 })).toBe(true);
34+ expect(shouldRefreshStorageUsage("inst_a", { now: 20, intervalMs: 5_000 })).toBe(false);
35+ expect(shouldRefreshStorageUsage("inst_b", { now: 30, intervalMs: 5_000 })).toBe(true);
36+
37+ expect(getStorageUsageRefreshStats()).toEqual({
38+ checks: 3,
39+ refreshed: 2,
40+ throttled: 1,
41+ evicted: 0,
42+ trackedInstances: 2,
43+ });
44+ });
45+
46+ test("evicts stale instance refresh records during periodic sweeps", () => {
47+ expect(shouldRefreshStorageUsage("inst_old", {
48+ now: 1_000,
49+ intervalMs: 1_000,
50+ staleMultiplier: 2,
51+ sweepEveryChecks: 2,
52+ })).toBe(true);
53+
54+ expect(shouldRefreshStorageUsage("inst_new", {
55+ now: 1_500,
56+ intervalMs: 1_000,
57+ staleMultiplier: 2,
58+ sweepEveryChecks: 2,
59+ })).toBe(true);
60+
61+ expect(shouldRefreshStorageUsage("inst_trigger", {
62+ now: 3_200,
63+ intervalMs: 1_000,
64+ staleMultiplier: 2,
65+ sweepEveryChecks: 2,
66+ })).toBe(true);
67+
68+ expect(shouldRefreshStorageUsage("inst_trigger", {
69+ now: 3_201,
70+ intervalMs: 1_000,
71+ staleMultiplier: 2,
72+ sweepEveryChecks: 2,
73+ })).toBe(false);
74+
75+ expect(getStorageUsageRefreshStats()).toEqual({
76+ checks: 4,
77+ refreshed: 3,
78+ throttled: 1,
79+ evicted: 1,
80+ trackedInstances: 2,
81+ });
82+ });
83+});
executor/packages/database/src/runtime/storage_usage_refresh.ts+82−0
@@ -0,0 +1,82 @@
1+const DEFAULT_STORAGE_USAGE_REFRESH_INTERVAL_MS = 30_000;
2+const DEFAULT_STALE_MULTIPLIER = 10;
3+const DEFAULT_SWEEP_EVERY_CHECKS = 200;
4+
5+const usageRefreshByInstanceId = new Map<string, number>();
6+let checksSinceSweep = 0;
7+
8+type StorageUsageRefreshStats = {
9+ checks: number;
10+ refreshed: number;
11+ throttled: number;
12+ evicted: number;
13+};
14+
15+const usageRefreshStats: StorageUsageRefreshStats = {
16+ checks: 0,
17+ refreshed: 0,
18+ throttled: 0,
19+ evicted: 0,
20+};
21+
22+function sweepStaleEntries(now: number, intervalMs: number, staleMultiplier: number): void {
23+ const maxAgeMs = intervalMs * staleMultiplier;
24+ for (const [instanceId, lastRefreshedAt] of usageRefreshByInstanceId.entries()) {
25+ if (now - lastRefreshedAt >= maxAgeMs) {
26+ usageRefreshByInstanceId.delete(instanceId);
27+ usageRefreshStats.evicted += 1;
28+ }
29+ }
30+}
31+
32+export function shouldRefreshStorageUsage(
33+ instanceId: string,
34+ args?: {
35+ now?: number;
36+ intervalMs?: number;
37+ staleMultiplier?: number;
38+ sweepEveryChecks?: number;
39+ },
40+): boolean {
41+ usageRefreshStats.checks += 1;
42+ checksSinceSweep += 1;
43+
44+ const now = args?.now ?? Date.now();
45+ const intervalMs = Math.max(1_000, Math.floor(args?.intervalMs ?? DEFAULT_STORAGE_USAGE_REFRESH_INTERVAL_MS));
46+ const staleMultiplier = Math.max(2, Math.floor(args?.staleMultiplier ?? DEFAULT_STALE_MULTIPLIER));
47+ const sweepEveryChecks = Math.max(1, Math.floor(args?.sweepEveryChecks ?? DEFAULT_SWEEP_EVERY_CHECKS));
48+ if (checksSinceSweep >= sweepEveryChecks) {
49+ sweepStaleEntries(now, intervalMs, staleMultiplier);
50+ checksSinceSweep = 0;
51+ }
52+
53+ const lastRefreshedAt = usageRefreshByInstanceId.get(instanceId);
54+
55+ if (typeof lastRefreshedAt === "number" && now - lastRefreshedAt < intervalMs) {
56+ usageRefreshStats.throttled += 1;
57+ return false;
58+ }
59+
60+ usageRefreshByInstanceId.set(instanceId, now);
61+ usageRefreshStats.refreshed += 1;
62+ return true;
63+}
64+
65+export function getStorageUsageRefreshStats(): StorageUsageRefreshStats & { trackedInstances: number } {
66+ return {
67+ checks: usageRefreshStats.checks,
68+ refreshed: usageRefreshStats.refreshed,
69+ throttled: usageRefreshStats.throttled,
70+ evicted: usageRefreshStats.evicted,
71+ trackedInstances: usageRefreshByInstanceId.size,
72+ };
73+}
74+
75+export function resetStorageUsageRefreshForTests() {
76+ usageRefreshByInstanceId.clear();
77+ checksSinceSweep = 0;
78+ usageRefreshStats.checks = 0;
79+ usageRefreshStats.refreshed = 0;
80+ usageRefreshStats.throttled = 0;
81+ usageRefreshStats.evicted = 0;
82+}
executor/packages/database/src/runtime/tool_invocation.approval.test.ts+153−0
@@ -0,0 +1,153 @@
1+import { describe, expect, test } from "bun:test";
2+import { ToolCallControlError } from "../../../core/src/tool-call-control";
3+import type { TaskRecord } from "../../../core/src/types";
4+import { enforceToolApproval } from "./tool_invocation";
5+
6+type FakeApproval = {
7+ id: string;
8+ taskId: string;
9+ toolPath: string;
10+ input: unknown;
11+ status: "pending" | "approved" | "denied";
12+ createdAt: number;
13+};
14+
15+function makeTask(id = "task_approval"): TaskRecord {
16+ const now = Date.now();
17+ return {
18+ id,
19+ code: "",
20+ runtimeId: "local-bun",
21+ status: "running",
22+ timeoutMs: 30_000,
23+ metadata: {},
24+ workspaceId: "ws_test" as TaskRecord["workspaceId"],
25+ accountId: "acct_test" as TaskRecord["accountId"],
26+ clientId: "web",
27+ createdAt: now,
28+ updatedAt: now,
29+ };
30+}
31+
32+function makeFakeCtx(seedApprovals?: FakeApproval[]) {
33+ const approvals = new Map((seedApprovals ?? []).map((approval) => [approval.id, approval]));
34+ const mutations: Array<{
35+ kind: "create_approval" | "set_pending" | "create_event";
36+ args: Record<string, unknown>;
37+ }> = [];
38+
39+ const ctx = {
40+ runQuery: async (query: unknown, args: Record<string, unknown>) => {
41+ if (query && "approvalId" in args) {
42+ return approvals.get(String(args.approvalId)) ?? null;
43+ }
44+
45+ throw new Error("Unexpected query arguments in enforceToolApproval test");
46+ },
47+ runMutation: async (_mutation: unknown, args: Record<string, unknown>) => {
48+ if ("id" in args && "toolPath" in args && "input" in args) {
49+ mutations.push({ kind: "create_approval", args });
50+ const created: FakeApproval = {
51+ id: String(args.id),
52+ taskId: String(args.taskId),
53+ toolPath: String(args.toolPath),
54+ input: args.input,
55+ status: "pending",
56+ createdAt: Date.now(),
57+ };
58+ approvals.set(created.id, created);
59+ return created;
60+ }
61+
62+ if ("callId" in args && "approvalId" in args) {
63+ mutations.push({ kind: "set_pending", args });
64+ return null;
65+ }
66+
67+ if ("eventName" in args && "type" in args && "payload" in args) {
68+ mutations.push({ kind: "create_event", args });
69+ return null;
70+ }
71+
72+ throw new Error("Unexpected mutation in enforceToolApproval test");
73+ },
74+ };
75+
76+ return {
77+ ctx,
78+ mutations,
79+ approvals,
80+ };
81+}
82+
83+describe("storage/tool approval enforcement", () => {
84+ test("throws approval_pending when existing approval is still pending", async () => {
85+ const pendingApproval: FakeApproval = {
86+ id: "approval_pending_1",
87+ taskId: "task_approval",
88+ toolPath: "storage.delete",
89+ input: { instanceId: "inst_1" },
90+ status: "pending",
91+ createdAt: Date.now(),
92+ };
93+ const { ctx, mutations } = makeFakeCtx([pendingApproval]);
94+
95+ await expect(
96+ enforceToolApproval(ctx as never, {
97+ task: makeTask(),
98+ callId: "call_1",
99+ toolPath: "storage.delete",
100+ input: { instanceId: "inst_1" },
101+ requireApproval: true,
102+ existingApprovalId: pendingApproval.id,
103+ }),
104+ ).rejects.toBeInstanceOf(ToolCallControlError);
105+
106+ expect(mutations).toHaveLength(0);
107+ });
108+
109+ test("creates approval and marks call pending when approval is required", async () => {
110+ const { ctx, mutations } = makeFakeCtx();
111+
112+ await expect(
113+ enforceToolApproval(ctx as never, {
114+ task: makeTask("task_new_approval"),
115+ callId: "call_2",
116+ toolPath: "storage.delete",
117+ input: { instanceId: "inst_2" },
118+ requireApproval: true,
119+ }),
120+ ).rejects.toBeInstanceOf(ToolCallControlError);
121+
122+ const createdApprovalMutation = mutations.find((entry) => entry.kind === "create_approval");
123+ const setPendingMutation = mutations.find((entry) => entry.kind === "set_pending");
124+ const approvalEventMutation = mutations.find((entry) => entry.kind === "create_event");
125+
126+ expect(createdApprovalMutation?.args.toolPath).toBe("storage.delete");
127+ expect(setPendingMutation).toBeDefined();
128+ expect(approvalEventMutation?.args.type).toBe("approval.requested");
129+ });
130+
131+ test("returns without mutations when existing approval is already approved", async () => {
132+ const approvedApproval: FakeApproval = {
133+ id: "approval_done",
134+ taskId: "task_approval",
135+ toolPath: "storage.delete",
136+ input: { instanceId: "inst_3" },
137+ status: "approved",
138+ createdAt: Date.now(),
139+ };
140+ const { ctx, mutations } = makeFakeCtx([approvedApproval]);
141+
142+ await enforceToolApproval(ctx as never, {
143+ task: makeTask(),
144+ callId: "call_3",
145+ toolPath: "storage.delete",
146+ input: { instanceId: "inst_3" },
147+ requireApproval: true,
148+ existingApprovalId: approvedApproval.id,
149+ });
150+
151+ expect(mutations).toHaveLength(0);
152+ });
153+});
executor/packages/database/src/runtime/tool_invocation.ts+121−75
@@ -385,6 +385,95 @@ async function denyToolCallForApproval( · 384 unmodified lines
385385 });
386386 }
387387
388+export async function enforceToolApproval(
389+ ctx: ActionCtx,
390+ args: {
391+ task: TaskRecord;
392+ callId: string;
393+ toolPath: string;
394+ input: unknown;
395+ requireApproval: boolean;
396+ existingApprovalId?: string;
397+ },
398+): Promise<void> {
399+ let approvalSatisfied = false;
400+ if (args.existingApprovalId) {
401+ const existingApproval = await ctx.runQuery(internal.database.getApproval, {
402+ approvalId: args.existingApprovalId,
403+ });
404+ if (!existingApproval) {
405+ throw new Error(`Approval ${args.existingApprovalId} not found for call ${args.callId}`);
406+ }
407+
408+ if (existingApproval.status === "pending") {
409+ throw new ToolCallControlError({
410+ kind: "approval_pending",
411+ approvalId: existingApproval.id,
412+ });
413+ }
414+
415+ if (existingApproval.status === "denied") {
416+ await denyToolCallForApproval(ctx, {
417+ task: args.task,
418+ callId: args.callId,
419+ toolPath: args.toolPath,
420+ approvalId: existingApproval.id,
421+ });
422+ }
423+
424+ approvalSatisfied = existingApproval.status === "approved";
425+ }
426+
427+ if (!args.requireApproval || approvalSatisfied) {
428+ return;
429+ }
430+
431+ const approvalId = args.existingApprovalId ?? createApprovalId();
432+ let approval = await ctx.runQuery(internal.database.getApproval, {
433+ approvalId,
434+ });
435+
436+ if (!approval) {
437+ approval = await ctx.runMutation(internal.database.createApproval, {
438+ id: approvalId,
439+ taskId: args.task.id,
440+ toolPath: args.toolPath,
441+ input: toInputPayload(args.input),
442+ });
443+
444+ await publishTaskEvent(ctx, args.task.id, "approval", "approval.requested", {
445+ approvalId: approval.id,
446+ taskId: args.task.id,
447+ callId: args.callId,
448+ toolPath: approval.toolPath,
449+ input: toInputPayload(approval.input),
450+ createdAt: approval.createdAt,
451+ });
452+ }
453+
454+ await ctx.runMutation(internal.database.setToolCallPendingApproval, {
455+ taskId: args.task.id,
456+ callId: args.callId,
457+ approvalId: approval.id,
458+ });
459+
460+ if (approval.status === "pending") {
461+ throw new ToolCallControlError({
462+ kind: "approval_pending",
463+ approvalId: approval.id,
464+ });
465+ }
466+
467+ if (approval.status === "denied") {
468+ await denyToolCallForApproval(ctx, {
469+ task: args.task,
470+ callId: args.callId,
471+ toolPath: args.toolPath,
472+ approvalId: approval.id,
473+ });
474+ }
475+}
476+
388477 export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
389478 const { toolPath, input, callId } = call;
390479 const persistedCall = await upsertRequestedToolCall(ctx, {
@@ -445,8 +534,31 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 54 unmodified lines
445534 }
446535
447536 if (isStorageSystemToolPath(toolPath)) {
537+ if (persistedCall.status === "requested") {
538+ await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
539+ taskId: task.id,
540+ callId,
541+ toolPath,
542+ approval: systemToolDecision === "require_approval" ? "required" : "auto",
543+ });
544+ }
545+
546+ await enforceToolApproval(ctx, {
547+ task,
548+ callId,
549+ toolPath,
550+ input,
551+ requireApproval: systemToolDecision === "require_approval",
552+ existingApprovalId: persistedCall.approvalId,
553+ });
554+
448555 const output = await runStorageSystemTool(ctx, task, toolPath, input);
449− return await finalizeImmediateTool(output);
556+ await completeToolCall(ctx, {
557+ taskId: task.id,
558+ callId,
559+ toolPath,
560+ });
561+ return output;
450562 }
451563
452564 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
@@ -799,80 +911,14 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 346 unmodified lines
799911 });
800912 }
801913
802− let approvalSatisfied = false;
803− if (persistedCall.approvalId) {
804− const existingApproval = await ctx.runQuery(internal.database.getApproval, {
805− approvalId: persistedCall.approvalId,
806− });
807− if (!existingApproval) {
808− throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`);
809− }
810−
811− if (existingApproval.status === "pending") {
812− throw new ToolCallControlError({
813− kind: "approval_pending",
814− approvalId: existingApproval.id,
815− });
816− }
817−
818− if (existingApproval.status === "denied") {
819− await denyToolCallForApproval(ctx, {
820− task,
821− callId,
822− toolPath: effectiveToolPath,
823− approvalId: existingApproval.id,
824− });
825− }
826−
827− approvalSatisfied = existingApproval.status === "approved";
828− }
829−
830− if (decision === "require_approval" && !approvalSatisfied) {
831− const approvalId = persistedCall.approvalId ?? createApprovalId();
832− let approval = await ctx.runQuery(internal.database.getApproval, {
833− approvalId,
834− });
835−
836− if (!approval) {
837− approval = await ctx.runMutation(internal.database.createApproval, {
838− id: approvalId,
839− taskId: task.id,
840− toolPath: effectiveToolPath,
841− input: toInputPayload(input),
842− });
843−
844− await publishTaskEvent(ctx, task.id, "approval", "approval.requested", {
845− approvalId: approval.id,
846− taskId: task.id,
847− callId,
848− toolPath: approval.toolPath,
849− input: toInputPayload(approval.input),
850− createdAt: approval.createdAt,
851− });
852− }
853−
854− await ctx.runMutation(internal.database.setToolCallPendingApproval, {
855− taskId: task.id,
856− callId,
857− approvalId: approval.id,
858− });
859−
860− if (approval.status === "pending") {
861− throw new ToolCallControlError({
862− kind: "approval_pending",
863− approvalId: approval.id,
864− });
865− }
866−
867− if (approval.status === "denied") {
868− await denyToolCallForApproval(ctx, {
869− task,
870− callId,
871− toolPath: effectiveToolPath,
872− approvalId: approval.id,
873− });
874− }
875− }
914+ await enforceToolApproval(ctx, {
915+ task,
916+ callId,
917+ toolPath: effectiveToolPath,
918+ input,
919+ requireApproval: decision === "require_approval",
920+ existingApprovalId: persistedCall.approvalId,
921+ });
876922
877923 const context: ToolRunContext = {
878924 taskId: task.id,