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