- 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 | # Hosted Convex deployments must use AGENT_STORAGE_PROVIDER=agentfs-cloudflare. | 19 | # Hosted Convex deployments must use AGENT_STORAGE_PROVIDER=agentfs-cloudflare. |
| 20 | # AGENT_STORAGE_PROVIDER=agentfs-local | 20 | # AGENT_STORAGE_PROVIDER=agentfs-local |
| 21 | # AGENT_STORAGE_ROOT=/tmp/executor-agentfs | 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 | # For Cloudflare Durable Objects storage proxy (served by runner-sandbox-host): | 24 | # For Cloudflare Durable Objects storage proxy (served by runner-sandbox-host): |
| 23 | # AGENT_STORAGE_PROVIDER=agentfs-cloudflare | 25 | # AGENT_STORAGE_PROVIDER=agentfs-cloudflare |
| 24 | # Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config. | 26 | # Reuses CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN from the runtime config. |
| @@ -354,6 +354,10 @@ export function StoragePanel({ · 353 unmodified lines | |||
| 354 | const canInspect = Boolean(workspaceId && selectedInstance); | 354 | const canInspect = Boolean(workspaceId && selectedInstance); |
| 355 | const sqlRows = useMemo(() => (sqlResult?.rows ?? []) as Array<Record<string, unknown>>, [sqlResult]); | 355 | const sqlRows = useMemo(() => (sqlResult?.rows ?? []) as Array<Record<string, unknown>>, [sqlResult]); |
| 356 | const sqlColumns = useMemo(() => collectSqlColumns(sqlRows), [sqlRows]); | 356 | const sqlColumns = useMemo(() => collectSqlColumns(sqlRows), [sqlRows]); |
| 357 | const parsedFilePreviewJson = useMemo( | ||
| 358 | () => (isJsonLike(filePreviewContent) ? tryParseJson(filePreviewContent) : null), | ||
| 359 | [filePreviewContent], | ||
| 360 | ); | ||
| 357 | const visibleSqlObjects = useMemo( | 361 | const visibleSqlObjects = useMemo( |
| 358 | () => sqlObjects.filter((entry) => sqlShowInternalObjects || !isInternalSqlObject(entry.name)), | 362 | () => sqlObjects.filter((entry) => sqlShowInternalObjects || !isInternalSqlObject(entry.name)), |
| 359 | [sqlObjects, sqlShowInternalObjects], | 363 | [sqlObjects, sqlShowInternalObjects], |
| @@ -905,8 +909,8 @@ export function StoragePanel({ · 545 unmodified lines | |||
| 905 | <Skeleton key={i} className="h-4 rounded" style={{ width: `${40 + Math.random() * 50}%` }} /> | 909 | <Skeleton key={i} className="h-4 rounded" style={{ width: `${40 + Math.random() * 50}%` }} /> |
| 906 | ))} | 910 | ))} |
| 907 | </div> | 911 | </div> |
| 908 | ) : isJsonLike(filePreviewContent) && tryParseJson(filePreviewContent) !== null ? ( | 912 | ) : parsedFilePreviewJson !== null ? ( |
| 909 | <JsonPreview data={tryParseJson(filePreviewContent)} /> | 913 | <JsonPreview data={parsedFilePreviewJson} /> |
| 910 | ) : ( | 914 | ) : ( |
| 911 | <pre className="font-mono text-xs leading-relaxed text-foreground/80 whitespace-pre-wrap break-words"> | 915 | <pre className="font-mono text-xs leading-relaxed text-foreground/80 whitespace-pre-wrap break-words"> |
| 912 | {filePreviewContent} | 916 | {filePreviewContent} |
| @@ -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 | describe("role hierarchy validation", () => { | 1744 | describe("role hierarchy validation", () => { |
| 1612 | test("admin role grants access to admin-only operations", async () => { | 1745 | test("admin role grants access to admin-only operations", async () => { |
| 1613 | const t = setup(); | 1746 | const t = setup(); |
| @@ -290,3 +290,67 @@ test("credentials can link one connection to multiple sources", async () => { · 289 unmodified lines | |||
| 290 | }); | 290 | }); |
| 291 | expect(relinked?.secretJson).toEqual({ token: "token_v2" }); | 291 | expect(relinked?.secretJson).toEqual({ token: "token_v2" }); |
| 292 | }); | 292 | }); |
| 293 | |||
| 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 | import { v } from "convex/values"; | 1 | import { v } from "convex/values"; |
| 2 | import { internalMutation, internalQuery } from "../_generated/server"; | 2 | import { internalMutation, internalQuery, type MutationCtx } from "../_generated/server"; |
| 3 | import type { Id } from "../_generated/dataModel"; | 3 | import type { Id } from "../_generated/dataModel"; |
| 4 | import { mapStorageInstance } from "../../src/database/mappers"; | 4 | import { mapStorageInstance } from "../../src/database/mappers"; |
| 5 | import { | 5 | import { |
| @@ -11,6 +11,7 @@ import { · 5 unmodified lines | |||
| 11 | 11 | ||
| 12 | const DEFAULT_EPHEMERAL_TTL_HOURS = 24; | 12 | const DEFAULT_EPHEMERAL_TTL_HOURS = 24; |
| 13 | const MAX_EPHEMERAL_TTL_HOURS = 24 * 30; | 13 | const MAX_EPHEMERAL_TTL_HOURS = 24 * 30; |
| 14 | const STORAGE_TOUCH_DEBOUNCE_MS = 5_000; | ||
| 14 | 15 | ||
| 15 | function resolveStorageProvider(): "agentfs-local" | "agentfs-cloudflare" { | 16 | function resolveStorageProvider(): "agentfs-local" | "agentfs-cloudflare" { |
| 16 | const raw = (process.env.AGENT_STORAGE_PROVIDER ?? "").trim().toLowerCase(); | 17 | const raw = (process.env.AGENT_STORAGE_PROVIDER ?? "").trim().toLowerCase(); |
| @@ -78,6 +79,49 @@ function isExpired(doc: { expiresAt?: number }, now = Date.now()): boolean { · 61 unmodified lines | |||
| 78 | return typeof doc.expiresAt === "number" && Number.isFinite(doc.expiresAt) && doc.expiresAt <= now; | 79 | return typeof doc.expiresAt === "number" && Number.isFinite(doc.expiresAt) && doc.expiresAt <= now; |
| 79 | } | 80 | } |
| 80 | 81 | ||
| 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 | export const openStorageInstance = internalMutation({ | 125 | export const openStorageInstance = internalMutation({ |
| 82 | args: { | 126 | args: { |
| 83 | workspaceId: v.id("workspaces"), | 127 | workspaceId: v.id("workspaces"), |
| @@ -150,6 +194,14 @@ export const openStorageInstance = internalMutation({ · 66 unmodified lines | |||
| 150 | if (scopeType === "account" && !args.accountId) { | 194 | if (scopeType === "account" && !args.accountId) { |
| 151 | throw new Error("accountId is required for account-scoped storage instances"); | 195 | throw new Error("accountId is required for account-scoped storage instances"); |
| 152 | } | 196 | } |
| 197 | if (scopeType === "workspace" || scopeType === "organization") { | ||
| 198 | await assertCanManageSharedScope(ctx, { | ||
| 199 | organizationId, | ||
| 200 | accountId: args.accountId, | ||
| 201 | scopeType, | ||
| 202 | action: "open", | ||
| 203 | }); | ||
| 204 | } | ||
| 153 | 205 | ||
| 154 | const durability = args.durability | 206 | const durability = args.durability |
| 155 | ?? (scopeType === "scratch" ? "ephemeral" : "durable"); | 207 | ?? (scopeType === "scratch" ? "ephemeral" : "durable"); |
| @@ -308,6 +360,15 @@ export const closeStorageInstance = internalMutation({ · 152 unmodified lines | |||
| 308 | throw new Error("Storage instance is not accessible in this workspace context"); | 360 | throw new Error("Storage instance is not accessible in this workspace context"); |
| 309 | } | 361 | } |
| 310 | 362 | ||
| 363 | if (doc.scopeType === "workspace" || doc.scopeType === "organization") { | ||
| 364 | await assertCanManageSharedScope(ctx, { | ||
| 365 | organizationId: workspace.organizationId, | ||
| 366 | accountId: args.accountId, | ||
| 367 | scopeType: doc.scopeType, | ||
| 368 | action: "close", | ||
| 369 | }); | ||
| 370 | } | ||
| 371 | |||
| 311 | const now = Date.now(); | 372 | const now = Date.now(); |
| 312 | await ctx.db.patch(doc._id, { | 373 | await ctx.db.patch(doc._id, { |
| 313 | status: "closed", | 374 | status: "closed", |
| @@ -353,13 +414,13 @@ export const deleteStorageInstance = internalMutation({ · 39 unmodified lines | |||
| 353 | throw new Error("Storage instance is not accessible in this workspace context"); | 414 | throw new Error("Storage instance is not accessible in this workspace context"); |
| 354 | } | 415 | } |
| 355 | 416 | ||
| 356 | if ( | 417 | if (doc.scopeType === "workspace" || doc.scopeType === "organization") { |
| 357 | (doc.scopeType === "organization" || doc.scopeType === "workspace") | 418 | await assertCanManageSharedScope(ctx, { |
| 358 | && doc.createdByAccountId | 419 | organizationId: workspace.organizationId, |
| 359 | && args.accountId | 420 | accountId: args.accountId, |
| 360 | && doc.createdByAccountId !== args.accountId | 421 | scopeType: doc.scopeType, |
| 361 | ) { | 422 | action: "delete", |
| 362 | throw new Error("Only the instance creator can delete shared storage instances"); | 423 | }); |
| 363 | } | 424 | } |
| 364 | 425 | ||
| 365 | const now = Date.now(); | 426 | const now = Date.now(); |
| @@ -416,6 +477,16 @@ export const touchStorageInstance = internalMutation({ · 50 unmodified lines | |||
| 416 | } | 477 | } |
| 417 | 478 | ||
| 418 | const now = Date.now(); | 479 | const now = Date.now(); |
| 480 | const hasStatusUpdate = Boolean(args.status || args.provider); | ||
| 481 | const hasUsageUpdate = typeof args.sizeBytes === "number" || typeof args.fileCount === "number"; | ||
| 482 | const shouldPatch = hasStatusUpdate | ||
| 483 | || hasUsageUpdate | ||
| 484 | || now - doc.lastSeenAt >= STORAGE_TOUCH_DEBOUNCE_MS; | ||
| 485 | |||
| 486 | if (!shouldPatch) { | ||
| 487 | return mapStorageInstance(doc); | ||
| 488 | } | ||
| 489 | |||
| 419 | await ctx.db.patch(doc._id, { | 490 | await ctx.db.patch(doc._id, { |
| 420 | updatedAt: now, | 491 | updatedAt: now, |
| 421 | lastSeenAt: now, | 492 | lastSeenAt: now, |
| @@ -24,6 +24,8 @@ import { customAction } from "../../core/src/function-builders"; · 23 unmodified lines | |||
| 24 | import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport"; | 24 | import { encodeToolCallResultForTransport } from "../../core/src/tool-call-result-transport"; |
| 25 | import { previewOpenApiSourceUpgradeForContext, type OpenApiUpgradeDiffPreview } from "../src/runtime/tool_upgrade"; | 25 | import { previewOpenApiSourceUpgradeForContext, type OpenApiUpgradeDiffPreview } from "../src/runtime/tool_upgrade"; |
| 26 | import { getStorageProvider, type StorageEncoding, type StorageProvider } from "../src/runtime/storage_provider"; | 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 | function isReadOnlySql(sql: string): boolean { | 30 | function isReadOnlySql(sql: string): boolean { |
| 29 | const trimmed = sql.trim().toLowerCase(); | 31 | const trimmed = sql.trim().toLowerCase(); |
| @@ -79,7 +81,13 @@ async function touchStorageInstance( · 49 unmodified lines | |||
| 79 | withUsage: boolean; | 81 | withUsage: boolean; |
| 80 | }, | 82 | }, |
| 81 | ) { | 83 | ) { |
| 82 | const usage = args.withUsage ? await args.provider.usage(args.instance) : undefined; | 84 | if (!args.withUsage && !shouldTouchStorageOnRead()) { |
| 85 | return; | ||
| 86 | } | ||
| 87 | |||
| 88 | const usage = args.withUsage && shouldRefreshStorageUsage(args.instance.id) | ||
| 89 | ? await args.provider.usage(args.instance) | ||
| 90 | : undefined; | ||
| 83 | await ctx.runMutation(internal.database.touchStorageInstance, { | 91 | await ctx.runMutation(internal.database.touchStorageInstance, { |
| 84 | workspaceId: args.workspaceId, | 92 | workspaceId: args.workspaceId, |
| 85 | accountId: args.accountId, | 93 | accountId: args.accountId, |
| @@ -417,7 +417,7 @@ export const openStorageInstance = workspaceMutation({ · 416 unmodified lines | |||
| 417 | const scopeType = args.scopeType ?? "scratch"; | 417 | const scopeType = args.scopeType ?? "scratch"; |
| 418 | if (scopeType === "organization" || scopeType === "workspace") { | 418 | if (scopeType === "organization" || scopeType === "workspace") { |
| 419 | if (!isAdminRole(ctx.organizationMembership.role)) { | 419 | if (!isAdminRole(ctx.organizationMembership.role)) { |
| 420 | throw new Error("Only workspace admins can open workspace or organization storage instances"); | 420 | throw new Error("Only organization admins can open workspace or organization storage instances"); |
| 421 | } | 421 | } |
| 422 | } | 422 | } |
| 423 | 423 | ||
| @@ -460,7 +460,6 @@ export const closeStorageInstance = workspaceMutation({ · 36 unmodified lines | |||
| 460 | 460 | ||
| 461 | export const deleteStorageInstance = workspaceMutation({ | 461 | export const deleteStorageInstance = workspaceMutation({ |
| 462 | method: "POST", | 462 | method: "POST", |
| 463 | requireAdmin: true, | ||
| 464 | args: { | 463 | args: { |
| 465 | instanceId: v.string(), | 464 | instanceId: v.string(), |
| 466 | }, | 465 | }, |
| @@ -20,6 +20,8 @@ import { · 19 unmodified lines | |||
| 20 | fsStatOutputJsonSchema, | 20 | fsStatOutputJsonSchema, |
| 21 | fsWriteInputJsonSchema, | 21 | fsWriteInputJsonSchema, |
| 22 | fsWriteOutputJsonSchema, | 22 | fsWriteOutputJsonSchema, |
| 23 | } from "./storage_tool_contracts/fs"; | ||
| 24 | import { | ||
| 23 | kvDeleteInputJsonSchema, | 25 | kvDeleteInputJsonSchema, |
| 24 | kvDeleteOutputJsonSchema, | 26 | kvDeleteOutputJsonSchema, |
| 25 | kvGetInputJsonSchema, | 27 | kvGetInputJsonSchema, |
| @@ -30,12 +32,16 @@ import { · 4 unmodified lines | |||
| 30 | kvListOutputJsonSchema, | 32 | kvListOutputJsonSchema, |
| 31 | kvSetInputJsonSchema, | 33 | kvSetInputJsonSchema, |
| 32 | kvSetOutputJsonSchema, | 34 | kvSetOutputJsonSchema, |
| 33 | sqliteQueryInputJsonSchema, | 35 | } from "./storage_tool_contracts/kv"; |
| 34 | sqliteQueryOutputJsonSchema, | 36 | import { |
| 35 | sqliteCapabilitiesInputJsonSchema, | 37 | sqliteCapabilitiesInputJsonSchema, |
| 36 | sqliteCapabilitiesOutputJsonSchema, | 38 | sqliteCapabilitiesOutputJsonSchema, |
| 37 | sqliteInsertRowsInputJsonSchema, | 39 | sqliteInsertRowsInputJsonSchema, |
| 38 | sqliteInsertRowsOutputJsonSchema, | 40 | sqliteInsertRowsOutputJsonSchema, |
| 41 | sqliteQueryInputJsonSchema, | ||
| 42 | sqliteQueryOutputJsonSchema, | ||
| 43 | } from "./storage_tool_contracts/sqlite"; | ||
| 44 | import { | ||
| 39 | storageCloseInputJsonSchema, | 45 | storageCloseInputJsonSchema, |
| 40 | storageCloseOutputJsonSchema, | 46 | storageCloseOutputJsonSchema, |
| 41 | storageDeleteInputJsonSchema, | 47 | storageDeleteInputJsonSchema, |
| @@ -44,7 +50,7 @@ import { · 2 unmodified lines | |||
| 44 | storageListOutputJsonSchema, | 50 | storageListOutputJsonSchema, |
| 45 | storageOpenInputJsonSchema, | 51 | storageOpenInputJsonSchema, |
| 46 | storageOpenOutputJsonSchema, | 52 | storageOpenOutputJsonSchema, |
| 47 | } from "./storage_tool_contracts"; | 53 | } from "./storage_tool_contracts/storage"; |
| 48 | 54 | ||
| 49 | export const baseTools = new Map<string, ToolDefinition>(); | 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 | expect(decision).toBe("deny"); | 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 | class AgentFsLocalStorageProvider implements StorageProvider { | 168 | class AgentFsLocalStorageProvider implements StorageProvider { |
| 169 | private readonly baseDir: string; | 169 | private readonly baseDir: string; |
| 170 | private readonly instanceWriteLocks = new Map<string, Promise<void>>(); | ||
| 170 | 171 | ||
| 171 | constructor(baseDir: string) { | 172 | constructor(baseDir: string) { |
| 172 | this.baseDir = baseDir; | 173 | this.baseDir = baseDir; |
| @@ -196,7 +197,27 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 23 unmodified lines | |||
| 196 | return fullPath; | 197 | return fullPath; |
| 197 | } | 198 | } |
| 198 | 199 | ||
| 199 | private async withDatabase<T>( | 200 | private async withInstanceWriteLock<T>(instance: StorageInstanceRecord, callback: () => Promise<T>): Promise<T> { |
| 201 | const key = instance.id; | ||
| 202 | const previous = this.instanceWriteLocks.get(key) ?? Promise.resolve(); | ||
| 203 | let releaseLock!: () => void; | ||
| 204 | const current = new Promise<void>((resolve) => { | ||
| 205 | releaseLock = resolve; | ||
| 206 | }); | ||
| 207 | this.instanceWriteLocks.set(key, current); | ||
| 208 | |||
| 209 | await previous; | ||
| 210 | try { | ||
| 211 | return await callback(); | ||
| 212 | } finally { | ||
| 213 | releaseLock(); | ||
| 214 | if (this.instanceWriteLocks.get(key) === current) { | ||
| 215 | this.instanceWriteLocks.delete(key); | ||
| 216 | } | ||
| 217 | } | ||
| 218 | } | ||
| 219 | |||
| 220 | private async withDatabaseUnlocked<T>( | ||
| 200 | instance: StorageInstanceRecord, | 221 | instance: StorageInstanceRecord, |
| 201 | writeMode: boolean, | 222 | writeMode: boolean, |
| 202 | callback: (db: SqlJsDatabase) => Promise<T> | T, | 223 | callback: (db: SqlJsDatabase) => Promise<T> | T, |
| @@ -228,6 +249,20 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 25 unmodified lines | |||
| 228 | } | 249 | } |
| 229 | } | 250 | } |
| 230 | 251 | ||
| 252 | private async withDatabase<T>( | ||
| 253 | instance: StorageInstanceRecord, | ||
| 254 | writeMode: boolean, | ||
| 255 | callback: (db: SqlJsDatabase) => Promise<T> | T, | ||
| 256 | ): Promise<T> { | ||
| 257 | if (!writeMode) { | ||
| 258 | return await this.withDatabaseUnlocked(instance, false, callback); | ||
| 259 | } | ||
| 260 | |||
| 261 | return await this.withInstanceWriteLock(instance, async () => { | ||
| 262 | return await this.withDatabaseUnlocked(instance, true, callback); | ||
| 263 | }); | ||
| 264 | } | ||
| 265 | |||
| 231 | private async ensureKvTable(db: SqlJsDatabase) { | 266 | private async ensureKvTable(db: SqlJsDatabase) { |
| 232 | db.run(` | 267 | db.run(` |
| 233 | CREATE TABLE IF NOT EXISTS kv_store ( | 268 | CREATE TABLE IF NOT EXISTS kv_store ( |
| @@ -261,15 +296,17 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 27 unmodified lines | |||
| 261 | content: string, | 296 | content: string, |
| 262 | encoding: StorageEncoding, | 297 | encoding: StorageEncoding, |
| 263 | ): Promise<{ bytesWritten: number }> { | 298 | ): Promise<{ bytesWritten: number }> { |
| 264 | const fullPath = this.resolveFsPath(instance, path); | 299 | return await this.withInstanceWriteLock(instance, async () => { |
| 265 | await mkdir(dirname(fullPath), { recursive: true }); | 300 | const fullPath = this.resolveFsPath(instance, path); |
| 266 | const payload = encoding === "base64" | 301 | await mkdir(dirname(fullPath), { recursive: true }); |
| 267 | ? Buffer.from(content, "base64") | 302 | const payload = encoding === "base64" |
| 268 | : Buffer.from(content, "utf8"); | 303 | ? Buffer.from(content, "base64") |
| 269 | await writeFile(fullPath, payload); | 304 | : Buffer.from(content, "utf8"); |
| 270 | return { | 305 | await writeFile(fullPath, payload); |
| 271 | bytesWritten: payload.length, | 306 | return { |
| 272 | }; | 307 | bytesWritten: payload.length, |
| 308 | }; | ||
| 309 | }); | ||
| 273 | } | 310 | } |
| 274 | 311 | ||
| 275 | async readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]> { | 312 | async readdir(instance: StorageInstanceRecord, path: string): Promise<StorageDirectoryEntry[]> { |
| @@ -314,15 +351,19 @@ class AgentFsLocalStorageProvider implements StorageProvider { · 38 unmodified lines | |||
| 314 | } | 351 | } |
| 315 | 352 | ||
| 316 | async mkdir(instance: StorageInstanceRecord, path: string): Promise<void> { | 353 | async mkdir(instance: StorageInstanceRecord, path: string): Promise<void> { |
| 317 | const fullPath = this.resolveFsPath(instance, path); | 354 | await this.withInstanceWriteLock(instance, async () => { |
| 318 | await mkdir(fullPath, { recursive: true }); | 355 | const fullPath = this.resolveFsPath(instance, path); |
| 356 | await mkdir(fullPath, { recursive: true }); | ||
| 357 | }); | ||
| 319 | } | 358 | } |
| 320 | 359 | ||
| 321 | async remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void> { | 360 | async remove(instance: StorageInstanceRecord, path: string, options: { recursive?: boolean; force?: boolean }): Promise<void> { |
| 322 | const fullPath = this.resolveFsPath(instance, path); | 361 | await this.withInstanceWriteLock(instance, async () => { |
| 323 | await rm(fullPath, { | 362 | const fullPath = this.resolveFsPath(instance, path); |
| 324 | recursive: options.recursive ?? false, | 363 | await rm(fullPath, { |
| 325 | force: options.force ?? false, | 364 | recursive: options.recursive ?? false, |
| 365 | force: options.force ?? false, | ||
| 366 | }); | ||
| 326 | }); | 367 | }); |
| 327 | } | 368 | } |
| 328 | 369 | ||
| @@ -679,3 +720,9 @@ export function getStorageProvider(provider: StorageProviderId): StorageProvider · 350 unmodified lines | |||
| 679 | 720 | ||
| 680 | return getLocalProvider(); | 721 | return getLocalProvider(); |
| 681 | } | 722 | } |
| 723 | |||
| 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"; | 1 | export * from "./storage_tool_contracts/shared"; |
| 2 | 2 | export * from "./storage_tool_contracts/storage"; | |
| 3 | type JsonSchema = Record<string, unknown>; | 3 | export * from "./storage_tool_contracts/fs"; |
| 4 | type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; | 4 | export * from "./storage_tool_contracts/kv"; |
| 5 | 5 | export * from "./storage_tool_contracts/sqlite"; | |
| 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 | }); | ||
| @@ -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 | import type { ActionCtx } from "../../convex/_generated/server"; | 1 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 2 | import { internal } from "../../convex/_generated/api"; | 2 | import type { TaskRecord } from "../../../core/src/types"; |
| 3 | import type { TaskRecord, StorageInstanceRecord } from "../../../core/src/types"; | 3 | import { normalizeInputPayload } from "./storage_tooling/context"; |
| 4 | import { getStorageProvider, type StorageEncoding, type StorageProvider } from "./storage_provider"; | 4 | import { runFsHandler } from "./storage_tooling/handlers_fs"; |
| 5 | import { | 5 | import { runKvHandler } from "./storage_tooling/handlers_kv"; |
| 6 | fsMkdirInputSchema, | 6 | import { runSqliteHandler } from "./storage_tooling/handlers_sqlite"; |
| 7 | fsMkdirOutputSchema, | 7 | import { runStorageInstanceHandler } from "./storage_tooling/handlers_storage"; |
| 8 | fsReadInputSchema, | 8 | |
| 9 | fsReadOutputSchema, | 9 | const STORAGE_TOOL_ALIASES: Record<string, string> = { |
| 10 | fsReaddirInputSchema, | 10 | "kv.put": "kv.set", |
| 11 | fsReaddirOutputSchema, | 11 | "kv.create": "kv.set", |
| 12 | fsRemoveInputSchema, | 12 | "kv.update": "kv.set", |
| 13 | fsRemoveOutputSchema, | 13 | "kv.del": "kv.delete", |
| 14 | fsStatInputSchema, | 14 | "kv.has": "kv.get", |
| 15 | fsStatOutputSchema, | 15 | "kv.exists": "kv.get", |
| 16 | fsWriteInputSchema, | 16 | "kv.value": "kv.get", |
| 17 | fsWriteOutputSchema, | 17 | "kv.keys": "kv.list", |
| 18 | kvDeleteInputSchema, | 18 | "sqlite.exec": "sqlite.query", |
| 19 | kvDeleteOutputSchema, | 19 | "sqlite.bulk_insert": "sqlite.insert_rows", |
| 20 | kvGetInputSchema, | ||
| 21 | kvGetOutputSchema, | ||
| 22 | kvIncrInputSchema, | ||
| 23 | kvIncrOutputSchema, | ||
| 24 | kvListInputSchema, | ||
| 25 | kvListOutputSchema, | ||
| 26 | kvSetInputSchema, | ||
| 27 | kvSetOutputSchema, | ||
| 28 | sqliteCapabilitiesInputSchema, | ||
| 29 | sqliteCapabilitiesOutputSchema, | ||
| 30 | sqliteInsertRowsInputSchema, | ||
| 31 | sqliteInsertRowsOutputSchema, | ||
| 32 | sqliteQueryInputSchema, | ||
| 33 | sqliteQueryOutputSchema, | ||
| 34 | storageCloseInputSchema, | ||
| 35 | storageCloseOutputSchema, | ||
| 36 | storageDeleteInputSchema, | ||
| 37 | storageDeleteOutputSchema, | ||
| 38 | storageListInputSchema, | ||
| 39 | storageListOutputSchema, | ||
| 40 | storageOpenInputSchema, | ||
| 41 | storageOpenOutputSchema, | ||
| 42 | } from "./storage_tool_contracts"; | ||
| 43 | |||
| 44 | type StorageScopeType = "scratch" | "account" | "workspace" | "organization"; | ||
| 45 | |||
| 46 | type TaskStorageDefaults = { | ||
| 47 | currentInstanceId?: string; | ||
| 48 | currentScopeType?: StorageScopeType; | ||
| 49 | byScope: Partial<Record<StorageScopeType, string>>; | ||
| 50 | }; | 20 | }; |
| 51 | 21 | ||
| 52 | const STORAGE_SYSTEM_TOOLS = new Set([ | 22 | const STORAGE_SYSTEM_TOOLS = new Set<string>([ |
| 53 | "storage.open", | 23 | "storage.open", |
| 54 | "storage.list", | 24 | "storage.list", |
| 55 | "storage.close", | 25 | "storage.close", |
| @@ -81,330 +51,8 @@ const STORAGE_SYSTEM_TOOLS = new Set([ · 25 unmodified lines | |||
| 81 | "sqlite.bulk_insert", | 51 | "sqlite.bulk_insert", |
| 82 | ]); | 52 | ]); |
| 83 | 53 | ||
| 84 | const SQLITE_MAX_BIND_VARIABLES = 100; | 54 | function normalizeStorageToolPath(toolPath: string): string { |
| 85 | 55 | return STORAGE_TOOL_ALIASES[toolPath] ?? toolPath; | |
| 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 | }); | ||
| 408 | } | 56 | } |
| 409 | 57 | ||
| 410 | export function isStorageSystemToolPath(path: string): boolean { | 58 | export function isStorageSystemToolPath(path: string): boolean { |
| @@ -418,396 +66,32 @@ export async function runStorageSystemTool( · 7 unmodified lines | |||
| 418 | input: unknown, | 66 | input: unknown, |
| 419 | ): Promise<unknown> { | 67 | ): Promise<unknown> { |
| 420 | const payload = normalizeInputPayload(input); | 68 | const payload = normalizeInputPayload(input); |
| 421 | const normalizedToolPath = toolPath === "kv.put" | 69 | const normalizedToolPath = normalizeStorageToolPath(toolPath); |
| 422 | ? "kv.set" | 70 | const handlerArgs = { |
| 423 | : toolPath === "kv.create" | 71 | ctx, |
| 424 | ? "kv.set" | 72 | task, |
| 425 | : toolPath === "kv.update" | 73 | payload, |
| 426 | ? "kv.set" | 74 | normalizedToolPath, |
| 427 | : toolPath === "kv.del" | 75 | }; |
| 428 | ? "kv.delete" | ||
| 429 | : toolPath === "kv.has" | ||
| 430 | ? "kv.get" | ||
| 431 | : toolPath === "kv.exists" | ||
| 432 | ? "kv.get" | ||
| 433 | : toolPath === "kv.value" | ||
| 434 | ? "kv.get" | ||
| 435 | : toolPath === "kv.keys" | ||
| 436 | ? "kv.list" | ||
| 437 | : toolPath === "sqlite.exec" | ||
| 438 | ? "sqlite.query" | ||
| 439 | : toolPath === "sqlite.bulk_insert" | ||
| 440 | ? "sqlite.insert_rows" | ||
| 441 | : toolPath; | ||
| 442 | |||
| 443 | if (normalizedToolPath === "storage.open") { | ||
| 444 | const parsed = storageOpenInputSchema.parse(payload); | ||
| 445 | const instance = await openStorageInstanceForTask(ctx, task, parsed); | ||
| 446 | await saveTaskStorageDefault( | ||
| 447 | ctx, | ||
| 448 | task, | ||
| 449 | instance.scopeType, | ||
| 450 | instance.id, | ||
| 451 | true, | ||
| 452 | parsed.instanceId ? "provided" : "opened", | ||
| 453 | ); | ||
| 454 | return storageOpenOutputSchema.parse({ instance }); | ||
| 455 | } | ||
| 456 | |||
| 457 | if (normalizedToolPath === "storage.list") { | ||
| 458 | const parsed = storageListInputSchema.parse(payload); | ||
| 459 | const instances = await ctx.runQuery(internal.database.listStorageInstances, { | ||
| 460 | workspaceId: task.workspaceId, | ||
| 461 | accountId: task.accountId, | ||
| 462 | scopeType: parsed.scopeType, | ||
| 463 | includeDeleted: parsed.includeDeleted, | ||
| 464 | }); | ||
| 465 | |||
| 466 | return storageListOutputSchema.parse({ | ||
| 467 | instances, | ||
| 468 | total: instances.length, | ||
| 469 | }); | ||
| 470 | } | ||
| 471 | |||
| 472 | if (normalizedToolPath === "storage.close") { | ||
| 473 | const parsed = storageCloseInputSchema.parse(payload); | ||
| 474 | const instance = await ctx.runMutation(internal.database.closeStorageInstance, { | ||
| 475 | workspaceId: task.workspaceId, | ||
| 476 | accountId: task.accountId, | ||
| 477 | instanceId: parsed.instanceId, | ||
| 478 | }); | ||
| 479 | |||
| 480 | await trackTaskStorageAccess(ctx, task, { | ||
| 481 | instanceId: parsed.instanceId, | ||
| 482 | scopeType: instance?.scopeType, | ||
| 483 | accessType: "provided", | ||
| 484 | }); | ||
| 485 | |||
| 486 | return storageCloseOutputSchema.parse({ instance }); | ||
| 487 | } | ||
| 488 | |||
| 489 | if (normalizedToolPath === "storage.delete") { | ||
| 490 | const parsed = storageDeleteInputSchema.parse(payload); | ||
| 491 | const existing = await ctx.runQuery(internal.database.getStorageInstance, { | ||
| 492 | workspaceId: task.workspaceId, | ||
| 493 | accountId: task.accountId, | ||
| 494 | instanceId: parsed.instanceId, | ||
| 495 | }) as StorageInstanceRecord | null; | ||
| 496 | |||
| 497 | if (existing) { | ||
| 498 | const provider = getStorageProvider(existing.provider); | ||
| 499 | try { | ||
| 500 | await provider.deleteInstance(existing); | ||
| 501 | } catch { | ||
| 502 | // Continue marking the instance deleted even if backend cleanup fails. | ||
| 503 | } | ||
| 504 | } | ||
| 505 | |||
| 506 | const instance = await ctx.runMutation(internal.database.deleteStorageInstance, { | ||
| 507 | workspaceId: task.workspaceId, | ||
| 508 | accountId: task.accountId, | ||
| 509 | instanceId: parsed.instanceId, | ||
| 510 | }); | ||
| 511 | |||
| 512 | await trackTaskStorageAccess(ctx, task, { | ||
| 513 | instanceId: parsed.instanceId, | ||
| 514 | scopeType: existing?.scopeType, | ||
| 515 | accessType: "provided", | ||
| 516 | }); | ||
| 517 | |||
| 518 | return storageDeleteOutputSchema.parse({ instance }); | ||
| 519 | } | ||
| 520 | |||
| 521 | if (normalizedToolPath === "fs.read") { | ||
| 522 | const parsed = fsReadInputSchema.parse(payload); | ||
| 523 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 524 | const provider = getStorageProvider(instance.provider); | ||
| 525 | const encoding = parsed.encoding ?? "utf8"; | ||
| 526 | const file = await provider.readFile(instance, parsed.path, encoding as StorageEncoding); | ||
| 527 | await touchInstance(ctx, task, instance, provider, false); | ||
| 528 | return fsReadOutputSchema.parse({ | ||
| 529 | instanceId: instance.id, | ||
| 530 | path: parsed.path, | ||
| 531 | encoding, | ||
| 532 | content: file.content, | ||
| 533 | bytes: file.bytes, | ||
| 534 | }); | ||
| 535 | } | ||
| 536 | |||
| 537 | if (normalizedToolPath === "fs.write") { | ||
| 538 | const parsed = fsWriteInputSchema.parse(payload); | ||
| 539 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 540 | const provider = getStorageProvider(instance.provider); | ||
| 541 | const encoding = parsed.encoding ?? "utf8"; | ||
| 542 | const result = await provider.writeFile(instance, parsed.path, parsed.content, encoding as StorageEncoding); | ||
| 543 | await touchInstance(ctx, task, instance, provider, true); | ||
| 544 | return fsWriteOutputSchema.parse({ | ||
| 545 | instanceId: instance.id, | ||
| 546 | path: parsed.path, | ||
| 547 | bytesWritten: result.bytesWritten, | ||
| 548 | }); | ||
| 549 | } | ||
| 550 | |||
| 551 | if (normalizedToolPath === "fs.readdir") { | ||
| 552 | const parsed = fsReaddirInputSchema.parse(payload); | ||
| 553 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 554 | const provider = getStorageProvider(instance.provider); | ||
| 555 | const path = parsed.path ?? "/"; | ||
| 556 | const entries = await provider.readdir(instance, path); | ||
| 557 | await touchInstance(ctx, task, instance, provider, false); | ||
| 558 | return fsReaddirOutputSchema.parse({ | ||
| 559 | instanceId: instance.id, | ||
| 560 | path, | ||
| 561 | entries, | ||
| 562 | }); | ||
| 563 | } | ||
| 564 | |||
| 565 | if (normalizedToolPath === "fs.stat") { | ||
| 566 | const parsed = fsStatInputSchema.parse(payload); | ||
| 567 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 568 | const provider = getStorageProvider(instance.provider); | ||
| 569 | const stat = await provider.stat(instance, parsed.path); | ||
| 570 | await touchInstance(ctx, task, instance, provider, false); | ||
| 571 | return fsStatOutputSchema.parse({ | ||
| 572 | instanceId: instance.id, | ||
| 573 | path: parsed.path, | ||
| 574 | ...stat, | ||
| 575 | }); | ||
| 576 | } | ||
| 577 | |||
| 578 | if (normalizedToolPath === "fs.mkdir") { | ||
| 579 | const parsed = fsMkdirInputSchema.parse(payload); | ||
| 580 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 581 | const provider = getStorageProvider(instance.provider); | ||
| 582 | await provider.mkdir(instance, parsed.path); | ||
| 583 | await touchInstance(ctx, task, instance, provider, true); | ||
| 584 | return fsMkdirOutputSchema.parse({ | ||
| 585 | instanceId: instance.id, | ||
| 586 | path: parsed.path, | ||
| 587 | ok: true, | ||
| 588 | }); | ||
| 589 | } | ||
| 590 | |||
| 591 | if (normalizedToolPath === "fs.remove") { | ||
| 592 | const parsed = fsRemoveInputSchema.parse(payload); | ||
| 593 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 594 | const provider = getStorageProvider(instance.provider); | ||
| 595 | await provider.remove(instance, parsed.path, { | ||
| 596 | recursive: parsed.recursive, | ||
| 597 | force: parsed.force, | ||
| 598 | }); | ||
| 599 | await touchInstance(ctx, task, instance, provider, true); | ||
| 600 | return fsRemoveOutputSchema.parse({ | ||
| 601 | instanceId: instance.id, | ||
| 602 | path: parsed.path, | ||
| 603 | ok: true, | ||
| 604 | }); | ||
| 605 | } | ||
| 606 | |||
| 607 | if (normalizedToolPath === "kv.get") { | ||
| 608 | const parsed = kvGetInputSchema.parse(payload); | ||
| 609 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 610 | const provider = getStorageProvider(instance.provider); | ||
| 611 | const value = await provider.kvGet(instance, parsed.key); | ||
| 612 | await touchInstance(ctx, task, instance, provider, false); | ||
| 613 | return kvGetOutputSchema.parse({ | ||
| 614 | instanceId: instance.id, | ||
| 615 | key: parsed.key, | ||
| 616 | found: value !== undefined, | ||
| 617 | ...(value !== undefined ? { value } : {}), | ||
| 618 | }); | ||
| 619 | } | ||
| 620 | |||
| 621 | if (normalizedToolPath === "kv.set") { | ||
| 622 | const parsed = kvSetInputSchema.parse(payload); | ||
| 623 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 624 | const provider = getStorageProvider(instance.provider); | ||
| 625 | await provider.kvSet(instance, parsed.key, parsed.value); | ||
| 626 | await touchInstance(ctx, task, instance, provider, true); | ||
| 627 | return kvSetOutputSchema.parse({ | ||
| 628 | instanceId: instance.id, | ||
| 629 | key: parsed.key, | ||
| 630 | ok: true, | ||
| 631 | }); | ||
| 632 | } | ||
| 633 | |||
| 634 | if (normalizedToolPath === "kv.list") { | ||
| 635 | const parsed = kvListInputSchema.parse(payload); | ||
| 636 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 637 | const provider = getStorageProvider(instance.provider); | ||
| 638 | const limit = Math.max(1, Math.min(500, Math.floor(parsed.limit ?? 100))); | ||
| 639 | const items = await provider.kvList(instance, parsed.prefix ?? "", limit); | ||
| 640 | await touchInstance(ctx, task, instance, provider, false); | ||
| 641 | return kvListOutputSchema.parse({ | ||
| 642 | instanceId: instance.id, | ||
| 643 | items, | ||
| 644 | total: items.length, | ||
| 645 | }); | ||
| 646 | } | ||
| 647 | |||
| 648 | if (normalizedToolPath === "kv.delete") { | ||
| 649 | const parsed = kvDeleteInputSchema.parse(payload); | ||
| 650 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 651 | const provider = getStorageProvider(instance.provider); | ||
| 652 | await provider.kvDelete(instance, parsed.key); | ||
| 653 | await touchInstance(ctx, task, instance, provider, true); | ||
| 654 | return kvDeleteOutputSchema.parse({ | ||
| 655 | instanceId: instance.id, | ||
| 656 | key: parsed.key, | ||
| 657 | ok: true, | ||
| 658 | }); | ||
| 659 | } | ||
| 660 | |||
| 661 | if (normalizedToolPath === "kv.incr" || normalizedToolPath === "kv.decr") { | ||
| 662 | const parsed = kvIncrInputSchema.parse(payload); | ||
| 663 | const instance = await resolveStorageInstance(ctx, task, payload); | ||
| 664 | const provider = getStorageProvider(instance.provider); | ||
| 665 | |||
| 666 | const existing = await provider.kvGet(instance, parsed.key); | ||
| 667 | const initial = assertFiniteNumber(parsed.initial ?? 0, "initial"); | ||
| 668 | const previous = existing === undefined | ||
| 669 | ? initial | ||
| 670 | : assertFiniteNumber(existing, `kv value at '${parsed.key}'`); | ||
| 671 | |||
| 672 | const rawBy = assertFiniteNumber(parsed.by ?? 1, "by"); | ||
| 673 | const by = normalizedToolPath === "kv.decr" ? -Math.abs(rawBy) : rawBy; | ||
| 674 | const value = previous + by; | ||
| 675 | |||
| 676 | await provider.kvSet(instance, parsed.key, value); | ||
| 677 | await touchInstance(ctx, task, instance, provider, true); | ||
| 678 | 76 | ||
| 679 | return kvIncrOutputSchema.parse({ | 77 | const instanceResult = await runStorageInstanceHandler(handlerArgs); |
| 680 | instanceId: instance.id, | 78 | if (instanceResult !== undefined) { |
| 681 | key: parsed.key, | 79 | return instanceResult; |
| 682 | by, | ||
| 683 | previous, | ||
| 684 | value, | ||
| 685 | }); | ||
| 686 | } | 80 | } |
| 687 | 81 | ||
| 688 | if (normalizedToolPath === "sqlite.capabilities") { | 82 | const fsResult = await runFsHandler(handlerArgs); |
| 689 | sqliteCapabilitiesInputSchema.parse(payload); | 83 | if (fsResult !== undefined) { |
| 690 | const instance = await resolveStorageInstance(ctx, task, payload); | 84 | return fsResult; |
| 691 | const provider = getStorageProvider(instance.provider); | ||
| 692 | await touchInstance(ctx, task, instance, provider, false); | ||
| 693 | return sqliteCapabilitiesOutputSchema.parse({ | ||
| 694 | instanceId: instance.id, | ||
| 695 | provider: instance.provider, | ||
| 696 | maxBindVariables: SQLITE_MAX_BIND_VARIABLES, | ||
| 697 | supportsJsonEach: true, | ||
| 698 | supportsInsertRowsTool: true, | ||
| 699 | guidance: [ | ||
| 700 | "Prefer sqlite.insert_rows for bulk tabular inserts.", | ||
| 701 | "Keep bind params per statement under maxBindVariables.", | ||
| 702 | "For very large payloads, use one JSON payload param and expand with json_each(?).", | ||
| 703 | "Pass instanceId explicitly to reuse the same database across task runs.", | ||
| 704 | ], | ||
| 705 | }); | ||
| 706 | } | 85 | } |
| 707 | 86 | ||
| 708 | if (normalizedToolPath === "sqlite.insert_rows") { | 87 | const kvResult = await runKvHandler(handlerArgs); |
| 709 | const parsed = sqliteInsertRowsInputSchema.parse(payload); | 88 | if (kvResult !== undefined) { |
| 710 | const instance = await resolveStorageInstance(ctx, task, payload); | 89 | return kvResult; |
| 711 | const provider = getStorageProvider(instance.provider); | ||
| 712 | |||
| 713 | const table = assertSqlIdentifier(parsed.table, "table"); | ||
| 714 | const columns = parsed.columns.map((column, index) => assertSqlIdentifier(column, `columns[${index}]`)); | ||
| 715 | if (new Set(columns).size !== columns.length) { | ||
| 716 | throw new Error("columns must be unique"); | ||
| 717 | } | ||
| 718 | |||
| 719 | const rows = parsed.rows; | ||
| 720 | for (let index = 0; index < rows.length; index += 1) { | ||
| 721 | if (rows[index].length !== columns.length) { | ||
| 722 | throw new Error(`rows[${index}] has ${rows[index].length} values but expected ${columns.length}`); | ||
| 723 | } | ||
| 724 | } | ||
| 725 | |||
| 726 | const maxRowsPerChunk = Math.max(1, Math.floor(SQLITE_MAX_BIND_VARIABLES / Math.max(1, columns.length))); | ||
| 727 | const requestedChunkSize = typeof parsed.chunkSize === "number" && Number.isFinite(parsed.chunkSize) | ||
| 728 | ? Math.max(1, Math.floor(parsed.chunkSize)) | ||
| 729 | : maxRowsPerChunk; | ||
| 730 | const rowsPerChunk = Math.max(1, Math.min(maxRowsPerChunk, requestedChunkSize)); | ||
| 731 | |||
| 732 | const conflictClause = parsed.onConflict === "ignore" | ||
| 733 | ? " OR IGNORE" | ||
| 734 | : parsed.onConflict === "replace" | ||
| 735 | ? " OR REPLACE" | ||
| 736 | : ""; | ||
| 737 | |||
| 738 | const quotedTable = quoteSqlIdentifier(table); | ||
| 739 | const quotedColumns = columns.map(quoteSqlIdentifier).join(", "); | ||
| 740 | const placeholderRow = `(${columns.map(() => "?").join(", ")})`; | ||
| 741 | |||
| 742 | let totalChanges = 0; | ||
| 743 | let chunkCount = 0; | ||
| 744 | for (let start = 0; start < rows.length; start += rowsPerChunk) { | ||
| 745 | const chunk = rows.slice(start, start + rowsPerChunk); | ||
| 746 | const values = chunk.map(() => placeholderRow).join(", "); | ||
| 747 | const params = chunk.flat(); | ||
| 748 | const sql = `INSERT${conflictClause} INTO ${quotedTable} (${quotedColumns}) VALUES ${values}`; | ||
| 749 | |||
| 750 | let writeResult; | ||
| 751 | try { | ||
| 752 | writeResult = await provider.sqliteQuery(instance, { | ||
| 753 | sql, | ||
| 754 | params, | ||
| 755 | mode: "write", | ||
| 756 | maxRows: 1, | ||
| 757 | }); | ||
| 758 | } catch (error) { | ||
| 759 | throw decorateSqliteError(error, { | ||
| 760 | sql, | ||
| 761 | params, | ||
| 762 | instanceId: instance.id, | ||
| 763 | }); | ||
| 764 | } | ||
| 765 | |||
| 766 | totalChanges += Number(writeResult.changes ?? 0); | ||
| 767 | chunkCount += 1; | ||
| 768 | } | ||
| 769 | |||
| 770 | await touchInstance(ctx, task, instance, provider, true); | ||
| 771 | return sqliteInsertRowsOutputSchema.parse({ | ||
| 772 | instanceId: instance.id, | ||
| 773 | table, | ||
| 774 | columns, | ||
| 775 | rowsReceived: rows.length, | ||
| 776 | rowsProcessed: rows.length, | ||
| 777 | chunkCount, | ||
| 778 | rowsPerChunk, | ||
| 779 | maxBindVariables: SQLITE_MAX_BIND_VARIABLES, | ||
| 780 | changes: totalChanges, | ||
| 781 | }); | ||
| 782 | } | 90 | } |
| 783 | 91 | ||
| 784 | if (normalizedToolPath === "sqlite.query") { | 92 | const sqliteResult = await runSqliteHandler(handlerArgs); |
| 785 | const parsed = sqliteQueryInputSchema.parse(payload); | 93 | if (sqliteResult !== undefined) { |
| 786 | const instance = await resolveStorageInstance(ctx, task, payload); | 94 | return sqliteResult; |
| 787 | const provider = getStorageProvider(instance.provider); | ||
| 788 | const mode = parsed.mode ?? (isReadOnlySql(parsed.sql) ? "read" : "write"); | ||
| 789 | const maxRows = Math.max(1, Math.min(1_000, Math.floor(parsed.maxRows ?? 200))); | ||
| 790 | const params = parsed.params ?? []; | ||
| 791 | let result; | ||
| 792 | try { | ||
| 793 | result = await provider.sqliteQuery(instance, { | ||
| 794 | sql: parsed.sql, | ||
| 795 | params, | ||
| 796 | mode, | ||
| 797 | maxRows, | ||
| 798 | }); | ||
| 799 | } catch (error) { | ||
| 800 | throw decorateSqliteError(error, { | ||
| 801 | sql: parsed.sql, | ||
| 802 | params, | ||
| 803 | instanceId: instance.id, | ||
| 804 | }); | ||
| 805 | } | ||
| 806 | await touchInstance(ctx, task, instance, provider, mode === "write"); | ||
| 807 | return sqliteQueryOutputSchema.parse({ | ||
| 808 | instanceId: instance.id, | ||
| 809 | ...result, | ||
| 810 | }); | ||
| 811 | } | 95 | } |
| 812 | 96 | ||
| 813 | throw new Error(`Unsupported storage system tool: ${toolPath}`); | 97 | throw new Error(`Unsupported storage system tool: ${toolPath}`); |
| @@ -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 | export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> { | 477 | export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> { |
| 389 | const { toolPath, input, callId } = call; | 478 | const { toolPath, input, callId } = call; |
| 390 | const persistedCall = await upsertRequestedToolCall(ctx, { | 479 | const persistedCall = await upsertRequestedToolCall(ctx, { |
| @@ -445,8 +534,31 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 54 unmodified lines | |||
| 445 | } | 534 | } |
| 446 | 535 | ||
| 447 | if (isStorageSystemToolPath(toolPath)) { | 536 | if (isStorageSystemToolPath(toolPath)) { |
| 537 | if (persistedCall.status === "requested") { | ||
| 538 | await publishTaskEvent(ctx, task.id, "task", "tool.call.started", { | ||
| 539 | taskId: task.id, | ||
| 540 | callId, | ||
| 541 | toolPath, | ||
| 542 | approval: systemToolDecision === "require_approval" ? "required" : "auto", | ||
| 543 | }); | ||
| 544 | } | ||
| 545 | |||
| 546 | await enforceToolApproval(ctx, { | ||
| 547 | task, | ||
| 548 | callId, | ||
| 549 | toolPath, | ||
| 550 | input, | ||
| 551 | requireApproval: systemToolDecision === "require_approval", | ||
| 552 | existingApprovalId: persistedCall.approvalId, | ||
| 553 | }); | ||
| 554 | |||
| 448 | const output = await runStorageSystemTool(ctx, task, toolPath, input); | 555 | const output = await runStorageSystemTool(ctx, task, toolPath, input); |
| 449 | return await finalizeImmediateTool(output); | 556 | await completeToolCall(ctx, { |
| 557 | taskId: task.id, | ||
| 558 | callId, | ||
| 559 | toolPath, | ||
| 560 | }); | ||
| 561 | return output; | ||
| 450 | } | 562 | } |
| 451 | 563 | ||
| 452 | const buildIdResult = await getReadyRegistryBuildIdResult(ctx, { | 564 | const buildIdResult = await getReadyRegistryBuildIdResult(ctx, { |
| @@ -799,80 +911,14 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 346 unmodified lines | |||
| 799 | }); | 911 | }); |
| 800 | } | 912 | } |
| 801 | 913 | ||
| 802 | let approvalSatisfied = false; | 914 | await enforceToolApproval(ctx, { |
| 803 | if (persistedCall.approvalId) { | 915 | task, |
| 804 | const existingApproval = await ctx.runQuery(internal.database.getApproval, { | 916 | callId, |
| 805 | approvalId: persistedCall.approvalId, | 917 | toolPath: effectiveToolPath, |
| 806 | }); | 918 | input, |
| 807 | if (!existingApproval) { | 919 | requireApproval: decision === "require_approval", |
| 808 | throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`); | 920 | existingApprovalId: persistedCall.approvalId, |
| 809 | } | 921 | }); |
| 810 | |||
| 811 | if (existingApproval.status === "pending") { | ||
| 812 | throw new ToolCallControlError({ | ||
| 813 | kind: "approval_pending", | ||
| 814 | approvalId: existingApproval.id, | ||
| 815 | }); | ||
| 816 | } | ||
| 817 | |||
| 818 | if (existingApproval.status === "denied") { | ||
| 819 | await denyToolCallForApproval(ctx, { | ||
| 820 | task, | ||
| 821 | callId, | ||
| 822 | toolPath: effectiveToolPath, | ||
| 823 | approvalId: existingApproval.id, | ||
| 824 | }); | ||
| 825 | } | ||
| 826 | |||
| 827 | approvalSatisfied = existingApproval.status === "approved"; | ||
| 828 | } | ||
| 829 | |||
| 830 | if (decision === "require_approval" && !approvalSatisfied) { | ||
| 831 | const approvalId = persistedCall.approvalId ?? createApprovalId(); | ||
| 832 | let approval = await ctx.runQuery(internal.database.getApproval, { | ||
| 833 | approvalId, | ||
| 834 | }); | ||
| 835 | |||
| 836 | if (!approval) { | ||
| 837 | approval = await ctx.runMutation(internal.database.createApproval, { | ||
| 838 | id: approvalId, | ||
| 839 | taskId: task.id, | ||
| 840 | toolPath: effectiveToolPath, | ||
| 841 | input: toInputPayload(input), | ||
| 842 | }); | ||
| 843 | |||
| 844 | await publishTaskEvent(ctx, task.id, "approval", "approval.requested", { | ||
| 845 | approvalId: approval.id, | ||
| 846 | taskId: task.id, | ||
| 847 | callId, | ||
| 848 | toolPath: approval.toolPath, | ||
| 849 | input: toInputPayload(approval.input), | ||
| 850 | createdAt: approval.createdAt, | ||
| 851 | }); | ||
| 852 | } | ||
| 853 | |||
| 854 | await ctx.runMutation(internal.database.setToolCallPendingApproval, { | ||
| 855 | taskId: task.id, | ||
| 856 | callId, | ||
| 857 | approvalId: approval.id, | ||
| 858 | }); | ||
| 859 | |||
| 860 | if (approval.status === "pending") { | ||
| 861 | throw new ToolCallControlError({ | ||
| 862 | kind: "approval_pending", | ||
| 863 | approvalId: approval.id, | ||
| 864 | }); | ||
| 865 | } | ||
| 866 | |||
| 867 | if (approval.status === "denied") { | ||
| 868 | await denyToolCallForApproval(ctx, { | ||
| 869 | task, | ||
| 870 | callId, | ||
| 871 | toolPath: effectiveToolPath, | ||
| 872 | approvalId: approval.id, | ||
| 873 | }); | ||
| 874 | } | ||
| 875 | } | ||
| 876 | 922 | ||
| 877 | const context: ToolRunContext = { | 923 | const context: ToolRunContext = { |
| 878 | taskId: task.id, | 924 | taskId: task.id, |