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
19# Hosted Convex deployments must use AGENT_STORAGE_PROVIDER=agentfs-cloudflare.19# Hosted Convex deployments must use AGENT_STORAGE_PROVIDER=agentfs-cloudflare.
20# AGENT_STORAGE_PROVIDER=agentfs-local20# AGENT_STORAGE_PROVIDER=agentfs-local
21# AGENT_STORAGE_ROOT=/tmp/executor-agentfs21# 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
22# For Cloudflare Durable Objects storage proxy (served by runner-sandbox-host):24# For Cloudflare Durable Objects storage proxy (served by runner-sandbox-host):
23# AGENT_STORAGE_PROVIDER=agentfs-cloudflare25# AGENT_STORAGE_PROVIDER=agentfs-cloudflare
24# Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config.26# 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
354 const canInspect = Boolean(workspaceId && selectedInstance);354 const canInspect = Boolean(workspaceId && selectedInstance);
355 const sqlRows = useMemo(() => (sqlResult?.rows ?? []) as Array<Record<string, unknown>>, [sqlResult]);355 const sqlRows = useMemo(() => (sqlResult?.rows ?? []) as Array<Record<string, unknown>>, [sqlResult]);
356 const sqlColumns = useMemo(() => collectSqlColumns(sqlRows), [sqlRows]);356 const sqlColumns = useMemo(() => collectSqlColumns(sqlRows), [sqlRows]);
357 const parsedFilePreviewJson = useMemo(
358 () => (isJsonLike(filePreviewContent) ? tryParseJson(filePreviewContent) : null),
359 [filePreviewContent],
360 );
357 const visibleSqlObjects = useMemo(361 const visibleSqlObjects = useMemo(
358 () => sqlObjects.filter((entry) => sqlShowInternalObjects || !isInternalSqlObject(entry.name)),362 () => sqlObjects.filter((entry) => sqlShowInternalObjects || !isInternalSqlObject(entry.name)),
359 [sqlObjects, sqlShowInternalObjects],363 [sqlObjects, sqlShowInternalObjects],
@@ -905,8 +909,8 @@ export function StoragePanel({ · 545 unmodified lines
905 <Skeleton key={i} className="h-4 rounded" style={{ width: `${40 + Math.random() * 50}%` }} />909 <Skeleton key={i} className="h-4 rounded" style={{ width: `${40 + Math.random() * 50}%` }} />
906 ))}910 ))}
907 </div>911 </div>
908 ) : isJsonLike(filePreviewContent) && tryParseJson(filePreviewContent) !== null ? (912 ) : parsedFilePreviewJson !== null ? (
909 <JsonPreview data={tryParseJson(filePreviewContent)} />913 <JsonPreview data={parsedFilePreviewJson} />
910 ) : (914 ) : (
911 <pre className="font-mono text-xs leading-relaxed text-foreground/80 whitespace-pre-wrap break-words">915 <pre className="font-mono text-xs leading-relaxed text-foreground/80 whitespace-pre-wrap break-words">
912 {filePreviewContent}916 {filePreviewContent}
executor/packages/database/convex/access-controls.test.ts+133−0
@@ -1608,6 +1608,139 @@ describe("credential security", () => { · 1607 unmodified lines
1608 });1608 });
1609});1609});
1610 1610
1611describe("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
1611describe("role hierarchy validation", () => {1744describe("role hierarchy validation", () => {
1612 test("admin role grants access to admin-only operations", async () => {1745 test("admin role grants access to admin-only operations", async () => {
1613 const t = setup();1746 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
290 });290 });
291 expect(relinked?.secretJson).toEqual({ token: "token_v2" });291 expect(relinked?.secretJson).toEqual({ token: "token_v2" });
292});292});
293
294test("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 @@
1import { v } from "convex/values";1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "../_generated/server";2import { internalMutation, internalQuery, type MutationCtx } from "../_generated/server";
3import type { Id } from "../_generated/dataModel";3import type { Id } from "../_generated/dataModel";
4import { mapStorageInstance } from "../../src/database/mappers";4import { mapStorageInstance } from "../../src/database/mappers";
5import {5import {
@@ -11,6 +11,7 @@ import { · 5 unmodified lines
11 11
12const DEFAULT_EPHEMERAL_TTL_HOURS = 24;12const DEFAULT_EPHEMERAL_TTL_HOURS = 24;
13const MAX_EPHEMERAL_TTL_HOURS = 24 * 30;13const MAX_EPHEMERAL_TTL_HOURS = 24 * 30;
14const STORAGE_TOUCH_DEBOUNCE_MS = 5_000;
14 15
15function resolveStorageProvider(): "agentfs-local" | "agentfs-cloudflare" {16function resolveStorageProvider(): "agentfs-local" | "agentfs-cloudflare" {
16 const raw = (process.env.AGENT_STORAGE_PROVIDER ?? "").trim().toLowerCase();17 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
78 return typeof doc.expiresAt === "number" && Number.isFinite(doc.expiresAt) && doc.expiresAt <= now;79 return typeof doc.expiresAt === "number" && Number.isFinite(doc.expiresAt) && doc.expiresAt <= now;
79}80}
80 81
82async 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
105async 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
81export const openStorageInstance = internalMutation({125export const openStorageInstance = internalMutation({
82 args: {126 args: {
83 workspaceId: v.id("workspaces"),127 workspaceId: v.id("workspaces"),
@@ -150,6 +194,14 @@ export const openStorageInstance = internalMutation({ · 66 unmodified lines
150 if (scopeType === "account" && !args.accountId) {194 if (scopeType === "account" && !args.accountId) {
151 throw new Error("accountId is required for account-scoped storage instances");195 throw new Error("accountId is required for account-scoped storage instances");
152 }196 }
197 if (scopeType === "workspace" || scopeType === "organization") {
198 await assertCanManageSharedScope(ctx, {
199 organizationId,
200 accountId: args.accountId,
201 scopeType,
202 action: "open",
203 });
204 }
153 205
154 const durability = args.durability206 const durability = args.durability
155 ?? (scopeType === "scratch" ? "ephemeral" : "durable");207 ?? (scopeType === "scratch" ? "ephemeral" : "durable");
@@ -308,6 +360,15 @@ export const closeStorageInstance = internalMutation({ · 152 unmodified lines
308 throw new Error("Storage instance is not accessible in this workspace context");360 throw new Error("Storage instance is not accessible in this workspace context");
309 }361 }
310 362
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
311 const now = Date.now();372 const now = Date.now();
312 await ctx.db.patch(doc._id, {373 await ctx.db.patch(doc._id, {
313 status: "closed",374 status: "closed",
@@ -353,13 +414,13 @@ export const deleteStorageInstance = internalMutation({ · 39 unmodified lines
353 throw new Error("Storage instance is not accessible in this workspace context");414 throw new Error("Storage instance is not accessible in this workspace context");
354 }415 }
355 416
356 if (417 if (doc.scopeType === "workspace" || doc.scopeType === "organization") {
357 (doc.scopeType === "organization" || doc.scopeType === "workspace")418 await assertCanManageSharedScope(ctx, {
358 && doc.createdByAccountId419 organizationId: workspace.organizationId,
359 && args.accountId420 accountId: args.accountId,
360 && doc.createdByAccountId !== args.accountId421 scopeType: doc.scopeType,
361 ) {422 action: "delete",
362 throw new Error("Only the instance creator can delete shared storage instances");423 });
363 }424 }
364 425
365 const now = Date.now();426 const now = Date.now();
@@ -416,6 +477,16 @@ export const touchStorageInstance = internalMutation({ · 50 unmodified lines
416 }477 }
417 478
418 const now = Date.now();479 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
419 await ctx.db.patch(doc._id, {490 await ctx.db.patch(doc._id, {
420 updatedAt: now,491 updatedAt: now,
421 lastSeenAt: now,492 lastSeenAt: now,
executor/packages/database/convex/executorNode.ts+9−1
@@ -24,6 +24,8 @@ import { customAction } from "../../core/src/function-builders"; · 23 unmodified lines
24import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport";24import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport";
25import { previewOpenApiSourceUpgradeForContext, type OpenApiUpgradeDiffPreview } from "../src/runtime/tool_upgrade";25import { previewOpenApiSourceUpgradeForContext, type OpenApiUpgradeDiffPreview } from "../src/runtime/tool_upgrade";
26import { getStorageProvider, type StorageEncoding, type StorageProvider } from "../src/runtime/storage_provider";26import { getStorageProvider, type StorageEncoding, type StorageProvider } from "../src/runtime/storage_provider";
27import { shouldTouchStorageOnRead } from "../src/runtime/storage_touch_policy";
28import { shouldRefreshStorageUsage } from "../src/runtime/storage_usage_refresh";
27 29
28function isReadOnlySql(sql: string): boolean {30function isReadOnlySql(sql: string): boolean {
29 const trimmed = sql.trim().toLowerCase();31 const trimmed = sql.trim().toLowerCase();
@@ -79,7 +81,13 @@ async function touchStorageInstance( · 49 unmodified lines
79 withUsage: boolean;81 withUsage: boolean;
80 },82 },
81) {83) {
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;
83 await ctx.runMutation(internal.database.touchStorageInstance, {91 await ctx.runMutation(internal.database.touchStorageInstance, {
84 workspaceId: args.workspaceId,92 workspaceId: args.workspaceId,
85 accountId: args.accountId,93 accountId: args.accountId,
executor/packages/database/convex/workspace.ts+1−2
@@ -417,7 +417,7 @@ export const openStorageInstance = workspaceMutation({ · 416 unmodified lines
417 const scopeType = args.scopeType ?? "scratch";417 const scopeType = args.scopeType ?? "scratch";
418 if (scopeType === "organization" || scopeType === "workspace") {418 if (scopeType === "organization" || scopeType === "workspace") {
419 if (!isAdminRole(ctx.organizationMembership.role)) {419 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");
421 }421 }
422 }422 }
423 423
@@ -460,7 +460,6 @@ export const closeStorageInstance = workspaceMutation({ · 36 unmodified lines
460 460
461export const deleteStorageInstance = workspaceMutation({461export const deleteStorageInstance = workspaceMutation({
462 method: "POST",462 method: "POST",
463 requireAdmin: true,
464 args: {463 args: {
465 instanceId: v.string(),464 instanceId: v.string(),
466 },465 },
executor/packages/database/src/runtime/base_tools.ts+9−3
@@ -20,6 +20,8 @@ import { · 19 unmodified lines
20 fsStatOutputJsonSchema,20 fsStatOutputJsonSchema,
21 fsWriteInputJsonSchema,21 fsWriteInputJsonSchema,
22 fsWriteOutputJsonSchema,22 fsWriteOutputJsonSchema,
23} from "./storage_tool_contracts/fs";
24import {
23 kvDeleteInputJsonSchema,25 kvDeleteInputJsonSchema,
24 kvDeleteOutputJsonSchema,26 kvDeleteOutputJsonSchema,
25 kvGetInputJsonSchema,27 kvGetInputJsonSchema,
@@ -30,12 +32,16 @@ import { · 4 unmodified lines
30 kvListOutputJsonSchema,32 kvListOutputJsonSchema,
31 kvSetInputJsonSchema,33 kvSetInputJsonSchema,
32 kvSetOutputJsonSchema,34 kvSetOutputJsonSchema,
33 sqliteQueryInputJsonSchema,35} from "./storage_tool_contracts/kv";
34 sqliteQueryOutputJsonSchema,36import {
35 sqliteCapabilitiesInputJsonSchema,37 sqliteCapabilitiesInputJsonSchema,
36 sqliteCapabilitiesOutputJsonSchema,38 sqliteCapabilitiesOutputJsonSchema,
37 sqliteInsertRowsInputJsonSchema,39 sqliteInsertRowsInputJsonSchema,
38 sqliteInsertRowsOutputJsonSchema,40 sqliteInsertRowsOutputJsonSchema,
41 sqliteQueryInputJsonSchema,
42 sqliteQueryOutputJsonSchema,
43} from "./storage_tool_contracts/sqlite";
44import {
39 storageCloseInputJsonSchema,45 storageCloseInputJsonSchema,
40 storageCloseOutputJsonSchema,46 storageCloseOutputJsonSchema,
41 storageDeleteInputJsonSchema,47 storageDeleteInputJsonSchema,
@@ -44,7 +50,7 @@ import { · 2 unmodified lines
44 storageListOutputJsonSchema,50 storageListOutputJsonSchema,
45 storageOpenInputJsonSchema,51 storageOpenInputJsonSchema,
46 storageOpenOutputJsonSchema,52 storageOpenOutputJsonSchema,
47} from "./storage_tool_contracts";53} from "./storage_tool_contracts/storage";
48 54
49export const baseTools = new Map<string, ToolDefinition>();55export const baseTools = new Map<string, ToolDefinition>();
50 56
executor/packages/database/src/runtime/policy.test.ts+58−0
@@ -134,3 +134,61 @@ test("discover can be denied by policy", () => { · 133 unmodified lines
134 134
135 expect(decision).toBe("deny");135 expect(decision).toBe("deny");
136});136});
137
138test("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
167test("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 @@
1import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2import { mkdtemp, rm } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import type { StorageInstanceRecord } from "../../../core/src/types";
6import { getStorageProvider, resetStorageProviderSingletonsForTests } from "./storage_provider";
7
8function 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
26describe("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
167 167
168class AgentFsLocalStorageProvider implements StorageProvider {168class AgentFsLocalStorageProvider implements StorageProvider {
169 private readonly baseDir: string;169 private readonly baseDir: string;
170 private readonly instanceWriteLocks = new Map<string, Promise<void>>();
170 171
171 constructor(baseDir: string) {172 constructor(baseDir: string) {
172 this.baseDir = baseDir;173 this.baseDir = baseDir;
@@ -196,7 +197,27 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 23 unmodified lines
196 return fullPath;197 return fullPath;
197 }198 }
198 199
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>(
200 instance: StorageInstanceRecord,221 instance: StorageInstanceRecord,
201 writeMode: boolean,222 writeMode: boolean,
202 callback: (db: SqlJsDatabase) => Promise<T> | T,223 callback: (db: SqlJsDatabase) => Promise<T> | T,
@@ -228,6 +249,20 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 25 unmodified lines
228 }249 }
229 }250 }
230 251
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
231 private async ensureKvTable(db: SqlJsDatabase) {266 private async ensureKvTable(db: SqlJsDatabase) {
232 db.run(`267 db.run(`
233 CREATE TABLE IF NOT EXISTS kv_store (268 CREATE TABLE IF NOT EXISTS kv_store (
@@ -261,15 +296,17 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 27 unmodified lines
261 content: string,296 content: string,
262 encoding: StorageEncoding,297 encoding: StorageEncoding,
263 ): Promise<{ bytesWritten: number }> {298 ): Promise<{ bytesWritten: number }> {
264 const fullPath = this.resolveFsPath(instance, path);299 return await this.withInstanceWriteLock(instance, async () => {
265 await mkdir(dirname(fullPath), { recursive: true });300 const fullPath = this.resolveFsPath(instance, path);
266 const payload = encoding === "base64"301 await mkdir(dirname(fullPath), { recursive: true });
267 ? Buffer.from(content, "base64")302 const payload = encoding === "base64"
268 : Buffer.from(content, "utf8");303 ? Buffer.from(content, "base64")
269 await writeFile(fullPath, payload);304 : Buffer.from(content, "utf8");
270 return {305 await writeFile(fullPath, payload);
271 bytesWritten: payload.length,306 return {
272 };307 bytesWritten: payload.length,
308 };
309 });
273 }310 }
274 311
275 async readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]> {312 async readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]> {
@@ -314,15 +351,19 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 38 unmodified lines
314 }351 }
315 352
316 async mkdir(instance: StorageInstanceRecord, path: string): Promise<void> {353 async mkdir(instance: StorageInstanceRecord, path: string): Promise<void> {
317 const fullPath = this.resolveFsPath(instance, path);354 await this.withInstanceWriteLock(instance, async () => {
318 await mkdir(fullPath, { recursive: true });355 const fullPath = this.resolveFsPath(instance, path);
356 await mkdir(fullPath, { recursive: true });
357 });
319 }358 }
320 359
321 async remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void> {360 async remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void> {
322 const fullPath = this.resolveFsPath(instance, path);361 await this.withInstanceWriteLock(instance, async () => {
323 await rm(fullPath, {362 const fullPath = this.resolveFsPath(instance, path);
324 recursive: options.recursive ?? false,363 await rm(fullPath, {
325 force: options.force ?? false,364 recursive: options.recursive ?? false,
365 force: options.force ?? false,
366 });
326 });367 });
327 }368 }
328 369
@@ -679,3 +720,9 @@ export function getStorageProvider(provider: StorageProviderId): StorageProvider · 350 unmodified lines
679 720
680 return getLocalProvider();721 return getLocalProvider();
681}722}
723
724export 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 @@
1import { describe, expect, test } from "bun:test";
2import {
3 fsReadInputSchema,
4 kvSetInputSchema,
5 sqliteQueryInputSchema,
6 storageOpenInputSchema,
7 storageOpenInputJsonSchema,
8 sqliteInsertRowsInputJsonSchema,
9} from "./storage_tool_contracts";
10
11describe("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 @@
1import { z } from "zod";1export * from "./storage_tool_contracts/shared";
2 2export * from "./storage_tool_contracts/storage";
3type JsonSchema = Record<string, unknown>;3export * from "./storage_tool_contracts/fs";
4type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };4export * from "./storage_tool_contracts/kv";
5 5export * from "./storage_tool_contracts/sqlite";
6function isRecord(value: unknown): value is Record<string, unknown> {
7 return typeof value === "object" && value !== null && !Array.isArray(value);
8}
9
10function 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
54function 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
67export const storageScopeSchema = z.enum(["scratch", "account", "workspace", "organization"]);
68export const storageDurabilitySchema = z.enum(["ephemeral", "durable"]);
69export const storageStatusSchema = z.enum(["active", "closed", "deleted"]);
70export const storageProviderSchema = z.enum(["agentfs-local", "agentfs-cloudflare"]);
71
72const 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
81export 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
102export 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
110export const storageOpenOutputSchema = z.object({
111 instance: storageInstanceSchema,
112});
113
114export const storageListInputSchema = z.object({
115 scopeType: storageScopeSchema.optional(),
116 includeDeleted: z.boolean().optional(),
117});
118
119export const storageListOutputSchema = z.object({
120 instances: z.array(storageInstanceSchema),
121 total: z.number(),
122});
123
124export const storageCloseInputSchema = z.object({
125 instanceId: z.string(),
126});
127
128export const storageCloseOutputSchema = z.object({
129 instance: storageInstanceSchema.nullable(),
130});
131
132export const storageDeleteInputSchema = z.object({
133 instanceId: z.string(),
134});
135
136export const storageDeleteOutputSchema = z.object({
137 instance: storageInstanceSchema.nullable(),
138});
139
140const fsAccessSchema = z.object({
141 instanceId: z.string().optional(),
142 scopeType: storageScopeSchema.optional(),
143});
144
145export const fsReadInputSchema = fsAccessSchema.extend({
146 path: z.string(),
147 encoding: z.enum(["utf8", "base64"]).optional(),
148});
149
150export 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
158export const fsWriteInputSchema = fsAccessSchema.extend({
159 path: z.string(),
160 content: z.string(),
161 encoding: z.enum(["utf8", "base64"]).optional(),
162});
163
164export const fsWriteOutputSchema = z.object({
165 instanceId: z.string(),
166 path: z.string(),
167 bytesWritten: z.number(),
168});
169
170export const fsReaddirInputSchema = fsAccessSchema.extend({
171 path: z.string().optional(),
172});
173
174export 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
185export const fsStatInputSchema = fsAccessSchema.extend({
186 path: z.string(),
187});
188
189export 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
200export const fsMkdirInputSchema = fsAccessSchema.extend({
201 path: z.string(),
202});
203
204export const fsMkdirOutputSchema = z.object({
205 instanceId: z.string(),
206 path: z.string(),
207 ok: z.boolean(),
208});
209
210export const fsRemoveInputSchema = fsAccessSchema.extend({
211 path: z.string(),
212 recursive: z.boolean().optional(),
213 force: z.boolean().optional(),
214});
215
216export const fsRemoveOutputSchema = z.object({
217 instanceId: z.string(),
218 path: z.string(),
219 ok: z.boolean(),
220});
221
222export const kvGetInputSchema = fsAccessSchema.extend({
223 key: z.string(),
224});
225
226export const kvGetOutputSchema = z.object({
227 instanceId: z.string(),
228 key: z.string(),
229 found: z.boolean(),
230 value: jsonValueSchema.optional(),
231});
232
233export const kvSetInputSchema = fsAccessSchema.extend({
234 key: z.string(),
235 value: jsonValueSchema,
236});
237
238export const kvSetOutputSchema = z.object({
239 instanceId: z.string(),
240 key: z.string(),
241 ok: z.boolean(),
242});
243
244export const kvListInputSchema = fsAccessSchema.extend({
245 prefix: z.string().optional(),
246 limit: z.coerce.number().optional(),
247});
248
249export 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
255export const kvDeleteInputSchema = fsAccessSchema.extend({
256 key: z.string(),
257});
258
259export const kvDeleteOutputSchema = z.object({
260 instanceId: z.string(),
261 key: z.string(),
262 ok: z.boolean(),
263});
264
265export const kvIncrInputSchema = fsAccessSchema.extend({
266 key: z.string(),
267 by: z.coerce.number().optional(),
268 initial: z.coerce.number().optional(),
269});
270
271export 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
279export 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
286export 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
294const sqliteScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
295
296export const sqliteCapabilitiesInputSchema = fsAccessSchema.extend({});
297
298export 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
307export 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
315export 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
327const 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
338export 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
350export const storageOpenOutputJsonSchema = toJsonSchema(storageOpenOutputSchema, {
351 type: "object",
352 properties: {
353 instance: { type: "object" },
354 },
355 required: ["instance"],
356 additionalProperties: false,
357});
358
359export 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
368export 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
378export const storageCloseInputJsonSchema = toJsonSchema(storageCloseInputSchema, {
379 type: "object",
380 properties: {
381 instanceId: { type: "string" },
382 },
383 required: ["instanceId"],
384 additionalProperties: false,
385});
386
387export 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
398export const storageDeleteInputJsonSchema = storageCloseInputJsonSchema;
399export const storageDeleteOutputJsonSchema = storageCloseOutputJsonSchema;
400
401function 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
415export 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
426export 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
439export 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
451export 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
462export const fsReaddirInputJsonSchema = toJsonSchema(fsReaddirInputSchema, {
463 type: "object",
464 properties: {
465 ...fsAccessJsonProperties(),
466 path: { type: "string" },
467 },
468 additionalProperties: false,
469});
470
471export 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
482export const fsStatInputJsonSchema = toJsonSchema(fsStatInputSchema, {
483 type: "object",
484 properties: {
485 ...fsAccessJsonProperties(),
486 path: { type: "string" },
487 },
488 required: ["path"],
489 additionalProperties: false,
490});
491
492export 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
508export const fsMkdirInputJsonSchema = toJsonSchema(fsMkdirInputSchema, {
509 type: "object",
510 properties: {
511 ...fsAccessJsonProperties(),
512 path: { type: "string" },
513 },
514 required: ["path"],
515 additionalProperties: false,
516});
517
518export 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
529export 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
541export 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
552export const kvGetInputJsonSchema = toJsonSchema(kvGetInputSchema, {
553 type: "object",
554 properties: {
555 ...fsAccessJsonProperties(),
556 key: { type: "string" },
557 },
558 required: ["key"],
559 additionalProperties: false,
560});
561
562export 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
574export 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
585export 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
596export 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
606export 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
628export const kvDeleteInputJsonSchema = toJsonSchema(kvDeleteInputSchema, {
629 type: "object",
630 properties: {
631 ...fsAccessJsonProperties(),
632 key: { type: "string" },
633 },
634 required: ["key"],
635 additionalProperties: false,
636});
637
638export 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
649export 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
661export 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
674export 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
700export 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
713export 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
722export 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
743export 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
766export 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});
executor/packages/database/src/runtime/storage_tool_contracts/fs.ts+216−0
@@ -0,0 +1,216 @@
1import { z } from "zod";
2import { fsAccessJsonProperties, fsAccessSchema, toJsonSchema } from "./shared";
3
4export const fsReadInputSchema = fsAccessSchema.extend({
5 path: z.string(),
6 encoding: z.enum(["utf8", "base64"]).optional(),
7});
8
9export 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
17export const fsWriteInputSchema = fsAccessSchema.extend({
18 path: z.string(),
19 content: z.string(),
20 encoding: z.enum(["utf8", "base64"]).optional(),
21});
22
23export const fsWriteOutputSchema = z.object({
24 instanceId: z.string(),
25 path: z.string(),
26 bytesWritten: z.number(),
27});
28
29export const fsReaddirInputSchema = fsAccessSchema.extend({
30 path: z.string().optional(),
31});
32
33export 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
44export const fsStatInputSchema = fsAccessSchema.extend({
45 path: z.string(),
46});
47
48export 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
59export const fsMkdirInputSchema = fsAccessSchema.extend({
60 path: z.string(),
61});
62
63export const fsMkdirOutputSchema = z.object({
64 instanceId: z.string(),
65 path: z.string(),
66 ok: z.boolean(),
67});
68
69export const fsRemoveInputSchema = fsAccessSchema.extend({
70 path: z.string(),
71 recursive: z.boolean().optional(),
72 force: z.boolean().optional(),
73});
74
75export const fsRemoveOutputSchema = z.object({
76 instanceId: z.string(),
77 path: z.string(),
78 ok: z.boolean(),
79});
80
81export 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
92export 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
105export 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
117export 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
128export const fsReaddirInputJsonSchema = toJsonSchema(fsReaddirInputSchema, {
129 type: "object",
130 properties: {
131 ...fsAccessJsonProperties(),
132 path: { type: "string" },
133 },
134 additionalProperties: false,
135});
136
137export 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
148export const fsStatInputJsonSchema = toJsonSchema(fsStatInputSchema, {
149 type: "object",
150 properties: {
151 ...fsAccessJsonProperties(),
152 path: { type: "string" },
153 },
154 required: ["path"],
155 additionalProperties: false,
156});
157
158export 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
174export const fsMkdirInputJsonSchema = toJsonSchema(fsMkdirInputSchema, {
175 type: "object",
176 properties: {
177 ...fsAccessJsonProperties(),
178 path: { type: "string" },
179 },
180 required: ["path"],
181 additionalProperties: false,
182});
183
184export 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
195export 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
207export 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 @@
1import { z } from "zod";
2import {
3 fsAccessJsonProperties,
4 fsAccessSchema,
5 jsonValueJsonSchema,
6 jsonValueSchema,
7 toJsonSchema,
8} from "./shared";
9
10export const kvGetInputSchema = fsAccessSchema.extend({
11 key: z.string(),
12});
13
14export const kvGetOutputSchema = z.object({
15 instanceId: z.string(),
16 key: z.string(),
17 found: z.boolean(),
18 value: jsonValueSchema.optional(),
19});
20
21export const kvSetInputSchema = fsAccessSchema.extend({
22 key: z.string(),
23 value: jsonValueSchema,
24});
25
26export const kvSetOutputSchema = z.object({
27 instanceId: z.string(),
28 key: z.string(),
29 ok: z.boolean(),
30});
31
32export const kvListInputSchema = fsAccessSchema.extend({
33 prefix: z.string().optional(),
34 limit: z.coerce.number().optional(),
35});
36
37export 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
43export const kvDeleteInputSchema = fsAccessSchema.extend({
44 key: z.string(),
45});
46
47export const kvDeleteOutputSchema = z.object({
48 instanceId: z.string(),
49 key: z.string(),
50 ok: z.boolean(),
51});
52
53export const kvIncrInputSchema = fsAccessSchema.extend({
54 key: z.string(),
55 by: z.coerce.number().optional(),
56 initial: z.coerce.number().optional(),
57});
58
59export 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
67export const kvGetInputJsonSchema = toJsonSchema(kvGetInputSchema, {
68 type: "object",
69 properties: {
70 ...fsAccessJsonProperties(),
71 key: { type: "string" },
72 },
73 required: ["key"],
74 additionalProperties: false,
75});
76
77export 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
89export 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
100export 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
111export 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
121export 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
143export const kvDeleteInputJsonSchema = toJsonSchema(kvDeleteInputSchema, {
144 type: "object",
145 properties: {
146 ...fsAccessJsonProperties(),
147 key: { type: "string" },
148 },
149 required: ["key"],
150 additionalProperties: false,
151});
152
153export 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
164export 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
176export 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 @@
1import { z } from "zod";
2
3export type JsonSchema = Record<string, unknown>;
4export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
5
6function isRecord(value: unknown): value is Record<string, unknown> {
7 return typeof value === "object" && value !== null && !Array.isArray(value);
8}
9
10function 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
54export 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
67export const storageScopeSchema = z.enum(["scratch", "account", "workspace", "organization"]);
68export const storageDurabilitySchema = z.enum(["ephemeral", "durable"]);
69export const storageStatusSchema = z.enum(["active", "closed", "deleted"]);
70export const storageProviderSchema = z.enum(["agentfs-local", "agentfs-cloudflare"]);
71
72export 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
81export 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
92export const fsAccessSchema = z.object({
93 instanceId: z.string().optional(),
94 scopeType: storageScopeSchema.optional(),
95});
96
97export 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 @@
1import { z } from "zod";
2import {
3 fsAccessJsonProperties,
4 fsAccessSchema,
5 jsonValueJsonSchema,
6 jsonValueSchema,
7 storageProviderSchema,
8 toJsonSchema,
9} from "./shared";
10
11export 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
18export 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
26const sqliteScalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
27
28export const sqliteCapabilitiesInputSchema = fsAccessSchema.extend({});
29
30export 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
39export 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
47export 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
59export 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
85export 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
98export 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
107export 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
128export 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
151export 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 @@
1import { z } from "zod";
2import {
3 storageDurabilitySchema,
4 storageProviderSchema,
5 storageScopeSchema,
6 storageStatusSchema,
7 toJsonSchema,
8} from "./shared";
9
10export 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
31export 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
39export const storageOpenOutputSchema = z.object({
40 instance: storageInstanceSchema,
41});
42
43export const storageListInputSchema = z.object({
44 scopeType: storageScopeSchema.optional(),
45 includeDeleted: z.boolean().optional(),
46});
47
48export const storageListOutputSchema = z.object({
49 instances: z.array(storageInstanceSchema),
50 total: z.number(),
51});
52
53export const storageCloseInputSchema = z.object({
54 instanceId: z.string(),
55});
56
57export const storageCloseOutputSchema = z.object({
58 instance: storageInstanceSchema.nullable(),
59});
60
61export const storageDeleteInputSchema = z.object({
62 instanceId: z.string(),
63});
64
65export const storageDeleteOutputSchema = z.object({
66 instance: storageInstanceSchema.nullable(),
67});
68
69export 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
81export const storageOpenOutputJsonSchema = toJsonSchema(storageOpenOutputSchema, {
82 type: "object",
83 properties: {
84 instance: { type: "object" },
85 },
86 required: ["instance"],
87 additionalProperties: false,
88});
89
90export 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
99export 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
109export const storageCloseInputJsonSchema = toJsonSchema(storageCloseInputSchema, {
110 type: "object",
111 properties: {
112 instanceId: { type: "string" },
113 },
114 required: ["instanceId"],
115 additionalProperties: false,
116});
117
118export 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
129export const storageDeleteInputJsonSchema = storageCloseInputJsonSchema;
130export const storageDeleteOutputJsonSchema = storageCloseOutputJsonSchema;
executor/packages/database/src/runtime/storage_tooling/context.ts+366−0
@@ -0,0 +1,366 @@
1import type { ActionCtx } from "../../../convex/_generated/server";
2import { internal } from "../../../convex/_generated/api";
3import type { TaskRecord, StorageInstanceRecord } from "../../../../core/src/types";
4import { getStorageProvider, type StorageProvider } from "../storage_provider";
5import { shouldTouchStorageOnRead } from "../storage_touch_policy";
6import { shouldRefreshStorageUsage } from "../storage_usage_refresh";
7
8export type StorageScopeType = "scratch" | "account" | "workspace" | "organization";
9
10type TaskStorageDefaults = {
11 currentInstanceId?: string;
12 currentScopeType?: StorageScopeType;
13 byScope: Partial<Record<StorageScopeType, string>>;
14};
15
16export type StorageToolHandlerArgs = {
17 ctx: ActionCtx;
18 task: TaskRecord;
19 payload: Record<string, unknown>;
20 normalizedToolPath: string;
21};
22
23export const SQLITE_MAX_BIND_VARIABLES = 100;
24
25function 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
33export 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
42export 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
50function toErrorMessage(error: unknown): string {
51 if (error instanceof Error && typeof error.message === "string") {
52 return error.message;
53 }
54 return String(error);
55}
56
57function 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
80export 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
124export 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
132export function quoteSqlIdentifier(identifier: string): string {
133 return `"${identifier.replaceAll('"', '""')}"`;
134}
135
136export 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
143export 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
149export 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
158function asRecord(value: unknown): Record<string, unknown> {
159 return value && typeof value === "object" && !Array.isArray(value)
160 ? value as Record<string, unknown>
161 : {};
162}
163
164function 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
184async 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
196export 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
219export 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
236export 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
259export 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
331export 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
356export 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 @@
1import type { StorageEncoding } from "../storage_provider";
2import {
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";
16import {
17 resolveStorageProviderForPayload,
18 touchInstance,
19 type StorageToolHandlerArgs,
20} from "./context";
21
22export 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 @@
1import {
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";
13import {
14 assertFiniteNumber,
15 resolveStorageProviderForPayload,
16 touchInstance,
17 type StorageToolHandlerArgs,
18} from "./context";
19
20export 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 @@
1import {
2 sqliteCapabilitiesInputSchema,
3 sqliteCapabilitiesOutputSchema,
4 sqliteInsertRowsInputSchema,
5 sqliteInsertRowsOutputSchema,
6 sqliteQueryInputSchema,
7 sqliteQueryOutputSchema,
8} from "../storage_tool_contracts/sqlite";
9import {
10 assertSqlIdentifier,
11 decorateSqliteError,
12 isReadOnlySql,
13 quoteSqlIdentifier,
14 resolveStorageProviderForPayload,
15 SQLITE_MAX_BIND_VARIABLES,
16 touchInstance,
17 type StorageToolHandlerArgs,
18} from "./context";
19
20export 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 @@
1import { internal } from "../../../convex/_generated/api";
2import type { StorageInstanceRecord } from "../../../../core/src/types";
3import { getStorageProvider } from "../storage_provider";
4import {
5 storageCloseInputSchema,
6 storageCloseOutputSchema,
7 storageDeleteInputSchema,
8 storageDeleteOutputSchema,
9 storageListInputSchema,
10 storageListOutputSchema,
11 storageOpenInputSchema,
12 storageOpenOutputSchema,
13} from "../storage_tool_contracts/storage";
14import {
15 openStorageInstanceForTask,
16 saveTaskStorageDefault,
17 trackTaskStorageAccess,
18 type StorageToolHandlerArgs,
19} from "./context";
20
21export 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 @@
1import { describe, expect, test } from "bun:test";
2import { isStorageSystemToolPath } from "./storage_tools";
3
4describe("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 @@
1import type { ActionCtx } from "../../convex/_generated/server";1import type { ActionCtx } from "../../convex/_generated/server";
2import { internal } from "../../convex/_generated/api";2import type { TaskRecord } from "../../../core/src/types";
3import type { TaskRecord, StorageInstanceRecord } from "../../../core/src/types";3import { normalizeInputPayload } from "./storage_tooling/context";
4import { getStorageProvider, type StorageEncoding, type StorageProvider } from "./storage_provider";4import { runFsHandler } from "./storage_tooling/handlers_fs";
5import {5import { runKvHandler } from "./storage_tooling/handlers_kv";
6 fsMkdirInputSchema,6import { runSqliteHandler } from "./storage_tooling/handlers_sqlite";
7 fsMkdirOutputSchema,7import { runStorageInstanceHandler } from "./storage_tooling/handlers_storage";
8 fsReadInputSchema,8
9 fsReadOutputSchema,9const STORAGE_TOOL_ALIASES: Record<string, string> = {
10 fsReaddirInputSchema,10 "kv.put": "kv.set",
11 fsReaddirOutputSchema,11 "kv.create": "kv.set",
12 fsRemoveInputSchema,12 "kv.update": "kv.set",
13 fsRemoveOutputSchema,13 "kv.del": "kv.delete",
14 fsStatInputSchema,14 "kv.has": "kv.get",
15 fsStatOutputSchema,15 "kv.exists": "kv.get",
16 fsWriteInputSchema,16 "kv.value": "kv.get",
17 fsWriteOutputSchema,17 "kv.keys": "kv.list",
18 kvDeleteInputSchema,18 "sqlite.exec": "sqlite.query",
19 kvDeleteOutputSchema,19 "sqlite.bulk_insert": "sqlite.insert_rows",
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
44type StorageScopeType = "scratch" | "account" | "workspace" | "organization";
45
46type TaskStorageDefaults = {
47 currentInstanceId?: string;
48 currentScopeType?: StorageScopeType;
49 byScope: Partial<Record<StorageScopeType, string>>;
50};20};
51 21
52const STORAGE_SYSTEM_TOOLS = new Set([22const STORAGE_SYSTEM_TOOLS = new Set<string>([
53 "storage.open",23 "storage.open",
54 "storage.list",24 "storage.list",
55 "storage.close",25 "storage.close",
@@ -81,330 +51,8 @@ const STORAGE_SYSTEM_TOOLS = new Set([ · 25 unmodified lines
81 "sqlite.bulk_insert",51 "sqlite.bulk_insert",
82]);52]);
83 53
84const SQLITE_MAX_BIND_VARIABLES = 100;54function normalizeStorageToolPath(toolPath: string): string {
85 55 return STORAGE_TOOL_ALIASES[toolPath] ?? toolPath;
86function 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
94function 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
103function 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
111function toErrorMessage(error: unknown): string {
112 if (error instanceof Error && typeof error.message === "string") {
113 return error.message;
114 }
115 return String(error);
116}
117
118function 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
141function 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
185function 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
193function quoteSqlIdentifier(identifier: string): string {
194 return `"${identifier.replaceAll('"', '""')}"`;
195}
196
197function 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
204function 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
210function 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
219function asRecord(value: unknown): Record<string, unknown> {
220 return value && typeof value === "object" && !Array.isArray(value)
221 ? value as Record<string, unknown>
222 : {};
223}
224
225function 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
245async 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
257async 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
280async 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
297async 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
320async 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
392async 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 });
408}56}
409 57
410export function isStorageSystemToolPath(path: string): boolean {58export function isStorageSystemToolPath(path: string): boolean {
@@ -418,396 +66,32 @@ export async function runStorageSystemTool( · 7 unmodified lines
418 input: unknown,66 input: unknown,
419): Promise<unknown> {67): Promise<unknown> {
420 const payload = normalizeInputPayload(input);68 const payload = normalizeInputPayload(input);
421 const normalizedToolPath = toolPath === "kv.put"69 const normalizedToolPath = normalizeStorageToolPath(toolPath);
422 ? "kv.set"70 const handlerArgs = {
423 : toolPath === "kv.create"71 ctx,
424 ? "kv.set"72 task,
425 : toolPath === "kv.update"73 payload,
426 ? "kv.set"74 normalizedToolPath,
427 : toolPath === "kv.del"75 };
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);
678 76
679 return kvIncrOutputSchema.parse({77 const instanceResult = await runStorageInstanceHandler(handlerArgs);
680 instanceId: instance.id,78 if (instanceResult !== undefined) {
681 key: parsed.key,79 return instanceResult;
682 by,
683 previous,
684 value,
685 });
686 }80 }
687 81
688 if (normalizedToolPath === "sqlite.capabilities") {82 const fsResult = await runFsHandler(handlerArgs);
689 sqliteCapabilitiesInputSchema.parse(payload);83 if (fsResult !== undefined) {
690 const instance = await resolveStorageInstance(ctx, task, payload);84 return fsResult;
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 });
706 }85 }
707 86
708 if (normalizedToolPath === "sqlite.insert_rows") {87 const kvResult = await runKvHandler(handlerArgs);
709 const parsed = sqliteInsertRowsInputSchema.parse(payload);88 if (kvResult !== undefined) {
710 const instance = await resolveStorageInstance(ctx, task, payload);89 return kvResult;
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 });
782 }90 }
783 91
784 if (normalizedToolPath === "sqlite.query") {92 const sqliteResult = await runSqliteHandler(handlerArgs);
785 const parsed = sqliteQueryInputSchema.parse(payload);93 if (sqliteResult !== undefined) {
786 const instance = await resolveStorageInstance(ctx, task, payload);94 return sqliteResult;
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 });
811 }95 }
812 96
813 throw new Error(`Unsupported storage system tool: ${toolPath}`);97 throw new Error(`Unsupported storage system tool: ${toolPath}`);
executor/packages/database/src/runtime/storage_touch_policy.test.ts+24−0
@@ -0,0 +1,24 @@
1import { describe, expect, test } from "bun:test";
2import { shouldTouchStorageOnRead } from "./storage_touch_policy";
3
4describe("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 @@
1export 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 @@
1import { afterEach, describe, expect, test } from "bun:test";
2import {
3 getStorageUsageRefreshStats,
4 resetStorageUsageRefreshForTests,
5 shouldRefreshStorageUsage,
6} from "./storage_usage_refresh";
7
8describe("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 @@
1const DEFAULT_STORAGE_USAGE_REFRESH_INTERVAL_MS = 30_000;
2const DEFAULT_STALE_MULTIPLIER = 10;
3const DEFAULT_SWEEP_EVERY_CHECKS = 200;
4
5const usageRefreshByInstanceId = new Map<string, number>();
6let checksSinceSweep = 0;
7
8type StorageUsageRefreshStats = {
9 checks: number;
10 refreshed: number;
11 throttled: number;
12 evicted: number;
13};
14
15const usageRefreshStats: StorageUsageRefreshStats = {
16 checks: 0,
17 refreshed: 0,
18 throttled: 0,
19 evicted: 0,
20};
21
22function 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
32export 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
65export 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
75export 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 @@
1import { describe, expect, test } from "bun:test";
2import { ToolCallControlError } from "../../../core/src/tool-call-control";
3import type { TaskRecord } from "../../../core/src/types";
4import { enforceToolApproval } from "./tool_invocation";
5
6type FakeApproval = {
7 id: string;
8 taskId: string;
9 toolPath: string;
10 input: unknown;
11 status: "pending" | "approved" | "denied";
12 createdAt: number;
13};
14
15function 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
32function 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
83describe("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
385 });385 });
386}386}
387 387
388export 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
388export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {477export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
389 const { toolPath, input, callId } = call;478 const { toolPath, input, callId } = call;
390 const persistedCall = await upsertRequestedToolCall(ctx, {479 const persistedCall = await upsertRequestedToolCall(ctx, {
@@ -445,8 +534,31 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 54 unmodified lines
445 }534 }
446 535
447 if (isStorageSystemToolPath(toolPath)) {536 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
448 const output = await runStorageSystemTool(ctx, task, toolPath, input);555 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;
450 }562 }
451 563
452 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {564 const buildIdResult = await getReadyRegistryBuildIdResult(ctx, {
@@ -799,80 +911,14 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 346 unmodified lines
799 });911 });
800 }912 }
801 913
802 let approvalSatisfied = false;914 await enforceToolApproval(ctx, {
803 if (persistedCall.approvalId) {915 task,
804 const existingApproval = await ctx.runQuery(internal.database.getApproval, {916 callId,
805 approvalId: persistedCall.approvalId,917 toolPath: effectiveToolPath,
806 });918 input,
807 if (!existingApproval) {919 requireApproval: decision === "require_approval",
808 throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`);920 existingApprovalId: persistedCall.approvalId,
809 }921 });
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 }
876 922
877 const context: ToolRunContext = {923 const context: ToolRunContext = {
878 taskId: task.id,924 taskId: task.id,