From 7798e83489c2198eba85120f0dcbe57a8d6c7c8f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 30 Jul 2026 16:33:35 -0700 Subject: [PATCH] feat(function): custom sandboxes (#6071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sandboxes): workspace dependency sets for Function blocks Named package sets a Function block can import from. The server canonicalizes and hashes the list; E2B prebuilds a content-addressed template per set, Daytona installs per execution. Create/edit is gated to Max or Enterprise via the shared workspace entitlement check; execution is deliberately ungated, so a downgraded workspace keeps running what it already built. Also on this branch: - Extract the duplicated dropdown/combobox option-fetch lifecycle into use-fetched-options. Only combobox had the dependency-change reset, so every dropdown with dependsOn + fetchOptions cleared its list and never repopulated until reopened. - Collapse the repeated Max-tier entitlement check onto one hasMaxTierWorkspaceAccess, shared by inbox, live sync, and sandboxes. - Resolve a personal payer's block state through getEffectiveBillingStatus in getBillingEntityBlockStatus, so the client-side Max gates agree with the server-side ones when blockOrgMembers' fan-out is stale. - Carve the Daytona dependency install out of the caller's execution budget instead of stacking on top of it. Co-Authored-By: Claude * chore(db): regenerate the sandboxes migration as 0273 Staging claimed 0271 and 0272 while this branch was out, so the hand-authored 0271_workspace_sandboxes was dropped before the merge and regenerated on top of the merged schema. Same DDL; drizzle emits plain CREATE TABLE/INDEX rather than the hand-added IF NOT EXISTS, which matches the repo default — that idempotent form is only needed for files with CONCURRENTLY ops below an embedded COMMIT. Regenerating also restores the meta snapshot the hand-authored migration never had. Co-Authored-By: Claude * chore(db): drop the sandboxes migration ahead of the staging merge Staging independently claims idx 0273, so remove ours before merging to avoid an add/add conflict on the drizzle migration index. Regenerated at the next free index once the merge lands. Co-Authored-By: Claude * chore(db): regenerate the sandboxes migration as 0275 Staging took 0273 and 0274, so the sandboxes DDL lands at the next free index. The emitted SQL is byte-identical to the dropped 0273. Co-Authored-By: Claude * fix(billing): consolidate the Max-tier entitlement onto one predicate The Max tier was spelled five ways. The odd one out — `isMax`, defined as `isPro(plan) && credits >= 25000` — excluded both `team_25000` and `enterprise`, and it was the sole input to the personal-workspace cap. A delinquent Max-for-Teams org admin got 1 personal workspace while a delinquent Max individual got 10. Only free/pro_6000/pro_25000 were tested, so the two broken tiers were unpinned. Separately, the server gate and the client `hasUsableMaxAccess` were independent copies of the same rule. The settings sidebar renders Sandboxes and Sim Mailer from the client one while the API answers 403 from the server one, so any drift renders a feature unlocked that the API refuses. - `MAX_TIER_CREDITS` is derived from the `CREDIT_TIERS` table; `isMaxTier` in plan-helpers is now the single definition, shared by the server gates, the client derivation, `getPlanTypeForLimits`, `plan-view`, and the cap - `hasWorkspaceTierAccess(id, predicate, { intent, onMissingWorkspace })` becomes the one org-vs-personal payer fork. `intent: 'active-use'` means active and not billing-blocked; `'retention'` means active/past_due with block state ignored, so the inbox teardown guard keeps its fail-open semantics instead of implying them through a duplicated fork - `isWorkspaceOnEnterprisePlan`'s personal branch now applies the status and block checks its own org branch always had, and its TSDoc names its real consumer (copilot BYOK, not Access Control) - the client live-sync gate gained the server's `isHosted` branch, so a self-hosted deploy with billing on no longer locks an interval the API accepts. It reads both flags directly rather than taking one as a parameter the callers sourced from the same module - `sqlIsPro`/`sqlIsTeam` escape the `_` LIKE wildcard, matching the already correct hand-rolled filter in seat-drift - deletes the `TERMINAL_SUBSCRIPTION_STATUSES` and `ENTITLED_STATUSES` shadow constants, and corrects three test mocks that asserted `trialing` was entitled or usable `max-tier-parity.test.ts` asserts the client and server answers match for every plan name. Both new guards were checked against the old code: the parity test fails 3 assertions with the previous predicate, and the self-hosted test fails without the `isHosted` branch. Co-Authored-By: Claude * chore(db): drop the sandboxes migration ahead of the staging merge Staging has claimed 0275 (table_views) and 0276 (drop_legacy_folder_tables) since the last merge, so our 0275_workspace_sandboxes collides on the index. Dropping ours first — the .sql, meta/0275_snapshot.json, and the journal entry — leaves packages/db/migrations byte-identical to the merge-base, so the merge sees no add/add conflict at all. Regenerated on the far side. Ours is the droppable side: plain additive DDL with no hand edits, which drizzle reproduces exactly. Staging's migrations are hand-written and must survive. Co-Authored-By: Claude * chore(db): regenerate the sandboxes migration as 0277 Staging claimed 0275 (table_views) and 0276 (drop_legacy_folder_tables), so the sandboxes migration dropped before the merge comes back on top as 0277. The emitted SQL is byte-identical to what was dropped — the original had no hand edits, so there is nothing to reapply. It is purely additive: two enums, sandbox_image and workspace_sandbox, their two FKs and six indexes. That it regenerated unchanged also confirms the schema.ts auto-merge was correct — had it lost staging's legacy-folder-table drops, drizzle would have emitted CREATE TABLE for them here. Snapshot chain is continuous (0273 -> 0277, each prevId matching the previous id) and the table counts track the DDL: 100 -> 101 (table_views) -> 99 (legacy folder tables dropped) -> 101 (the two sandbox tables). Co-Authored-By: Claude * feat(sandboxes): gate on the enterprise feature flags, drop the rollout switch Sandboxes shipped behind `custom-sandboxes`, an AppConfig rollout flag falling back to a `CUSTOM_SANDBOXES` secret. That made it the only Max-gated surface with no self-hosted path: `INBOX_ENABLED` can force Sim Mailer on for an operator running their own billing, and `ENTERPRISE_ENABLED` turns on the other nine features at once, but neither reached sandboxes. A self-hoster had to find a separately-named variable that was not part of that family, and one running with billing enabled could not enable it at all. Sandboxes now joins the enterprise feature set and the rollout flag is gone: - `sandboxes` is an `EnterpriseFeature` with `SANDBOXES_ENABLED` and its `NEXT_PUBLIC_` twin, so the master switch and the per-feature override both reach it like every sibling - `hasWorkspaceSandboxAccess` takes the inbox's shape exactly — the override wins, then a deployment without billing is unrestricted, then the workspace payer needs usable Max or Enterprise - the settings nav gains `selfHostedOverride`, so the section resolves through the same path as Sim Mailer instead of a second entitlement AND-ed in - `custom-sandboxes`, the `CUSTOM_SANDBOXES` secret, the now-unreachable `SANDBOXES_UNAVAILABLE` 403 copy, and the route's kill-switch branch are deleted Its legacy default is `true`, matching `inbox`: the gate already returns true whenever billing is off, so `false` would leave the nav override disagreeing with the gate that answers the request. Self-hosted builds run on the operator's own E2B/Daytona credentials, so there is no Sim-side cost to withhold — the docs now say so, since enabling the feature without a provider configured is the obvious trap. The new gate tests run with billing enabled on purpose; the `!isBillingEnabled` bail would otherwise answer every case and hide whether the override is wired. Verified by deleting the override line — exactly the one assertion fails. Co-Authored-By: Claude * fix(sandboxes): let the language menu match its trigger width `matchTriggerWidth={false}` exists for the opposite case — a narrow trigger whose option labels would truncate, letting the menu grow past it. The language field is a full-width form control with two short labels, so the override shrank the menu to "JavaScript" and pinned it to the right edge instead. The default (`true`) is correct here. Every other consumer passing `false` is a genuinely narrow trigger — a role picker in a member row, a table filter chip. Co-Authored-By: Claude * fix(sandboxes): re-queue a build when resolution finds the image unusable `ensureSandboxImage` only ran when a sandbox was saved, so resolution treated an unusable image as terminal and told the user to go fix a definition that was never wrong. Three states stuck permanently until someone re-saved in Settings: - a build that failed - a build whose worker died mid-flight, stranding the row in `building` - every sandbox created while the deployment ran a `runtime` provider, after a switch to a `prebuilt` one — `runtime` writes no image rows at all, so the whole fleet resolved to "no completed build" with nothing to repair it Resolution now re-queues through the registry's existing idempotent entry point before failing, and says a build is on its way instead of pointing at Settings. The conflict guard already claims only a `failed` row or a stale `pending`/ `building` one, so executions arriving during a healthy build enqueue nothing — no thundering herd from a hot workflow. The registry is imported dynamically for the same reason `sandboxDb` is: it pulls `@sim/db` into the static graph, which this module keeps out of the executor bundle. That also avoids a cycle, since the registry imports `invalidateSandboxResolution` from here. A repair that itself fails is logged and swallowed — it must never replace the build error naming the sandbox. Verified by deleting the repair call: exactly the three new assertions fail. Co-Authored-By: Claude * improvement(sandboxes): let the picker show just the sandbox name The label read "Test · Python · 1 package". The block's own list is already scoped to the language its sibling `language` subblock selects, so the language repeated on every row said nothing, and the package count is decoration next to the name that identifies the sandbox. The language stays for the one caller that cannot filter — agent tool-input renders this field under a synthetic id where the sibling `language` value is unreachable, so its list spans both languages and the name alone is ambiguous. That is the same missing value which disables filtering, so `showLanguage` is derived from it directly rather than passed independently and left to drift. A failed build is still marked: that suffix is the difference between a selection that runs and one that does not. Passing the flag also means dropping `.map(toSandboxOption)` for an explicit arrow — `Array.map` hands the index to the second parameter. Co-Authored-By: Claude * fix(sandboxes): show the sandbox name on the block card, not its uuid The card printed "443f4934-26ab-44ab-8...". `resolveDropdownLabel` only reads a subblock's static `options` array, and the sandbox picker is a `combobox` whose options load asynchronously, so its array is empty and the raw stored id fell through to the label. Resolved the same way skills and tools already are: a `resolveSandboxLabel` in the display layer, fed from the shared sandbox list query — the same cache entry the picker reads, so this adds no request. Two deliberate scopings: - the query is subscribed only for the sandbox row. `SubBlockRow` is memoized per subblock, and the list query polls while a build is in flight, so an unconditional hook would re-render every row on the canvas on each poll tick - the resolver matches the field id, not just the type. There is no dedicated subblock type for it, and matching `combobox` alone would relabel unrelated pickers An id with no matching sandbox resolves to null rather than a guess, so a deleted sandbox falls through to the caller's placeholder. The template preview surface is left alone: it is explicitly hook-free and passes empty lists for tools and skills too. Co-Authored-By: Claude * fix(sandboxes): hide the Sandboxes section with no provider configured Entitlement decides whether a workspace may author sandboxes; nothing decided whether anything could run one. A self-hosted deployment with SANDBOXES_ENABLED but no E2B or Daytona credentials got a fully functional tab whose output no Function block could select — the picker is gated on the provider vars, the tab was not. Both navigation planes now drop the section when neither NEXT_PUBLIC_SANDBOX_ENABLED nor the pre-Daytona NEXT_PUBLIC_E2B_ENABLED is set — the same pair the picker's `showWhenEnvSet` reads, so the two cannot disagree. Dropped rather than locked: an upgrade does not conjure a provider. The unified plane drops it in `buildUnifiedSettingsNavigation` rather than in the sidebar's filter, because the sidebar's `selfHostedOverride` short-circuit runs before its `requiresMax` check and would have revealed the tab anyway. It reads the browser twins, not the server's `isRemoteSandboxEnabled`, since this module renders on both sides. The predicate is a function, not a module constant, because the constant form was untestable and ambient: the env mock falls through to `process.env`, and `apps/sim/.env` (gitignored, so absent on CI) sets NEXT_PUBLIC_E2B_ENABLED=true. The nav tests passed locally and failed 6 assertions with the flag cleared. They now pin both flags, so the suite is identical with and without a local env file — verified by running it both ways. Co-Authored-By: Claude * docs(sandboxes): correct three claims the code no longer makes The Sandboxes section described behavior two commits on this branch changed, and led with an internal detail no reader needs. - entitlement is no longer Max/Enterprise only: self-hosted deployments unlock sandboxes with SANDBOXES_ENABLED, and the section is hidden outright when a deployment has no sandbox provider, which is the state a self-hoster is most likely to hit and least likely to diagnose - a build that is not Ready is no longer terminal. It is queued again on the next run, so the advice is to wait and re-run, not to go edit a package list that was never wrong - deleting a sandbox frees its build once nothing else references it. Builds are shared by content, so this is the one place a reader could reasonably assume deletion is immediate Dropped the `ModuleNotFoundError` aside: what the old code did instead is not something a reader needs to know to use the feature. The page is hand-written — `function` has category 'blocks' and is absent from `NATIVE_RESOURCE_BLOCK_TYPES`, so generate-docs skips it and these edits will not be overwritten. Co-Authored-By: Claude * feat(sandboxes): release the provider image when nothing references it Deleting a sandbox only removed its row, leaving the built template in E2B until the 30-day retention sweep — up to a month of paying to store an image nothing could select. Editing a package list had the same effect on the old content address, which is the more common case since every edit re-points the sandbox. `releaseSandboxImage(specHash)` now deletes the provider image and its row from both paths. It reuses the sweep's provider call and its ordering: image first, row second, so a refused delete leaves the row for the sweep to retry rather than orphaning a remote template nothing points at. Two guards make eager deletion safe: - builds are keyed by content, not by workspace, so two workspaces declaring the same package list share one image. The release no-ops while any sandbox still references the hash — otherwise one workspace's delete would break the other's - an in-flight build is left alone rather than raced; the sweep collects it once it settles Called detached from both routes. The row is already committed by then, so the user's action has succeeded whatever the provider says, and awaiting would hold a UI delete open on a remote call the sweep would retry anyway. Every failure inside is logged and swallowed for the same reason. E2B's delete verified against their API reference: DELETE /templates/{templateID} with X-API-Key, 204 on success. The existing implementation already matched, so this commit only adds the call sites and the guards. Co-Authored-By: Claude * fix(sandboxes): rate-limit the automatic rebuild, drop the one-off status dot Two follow-ups to the resolution repair. The repair had no rate limit. `ensureSandboxImage` re-claims a `failed` row on sight, and a bad package name fails in seconds, so the in-flight guard never closed the window: a workflow on a one-minute schedule would enqueue a build a minute against a package list that will never resolve, each one real provider build compute. Before the repair existed resolution simply threw, so this was introduced with it. The two callers want different things, so the cooldown is opt-in. A save is a person explicitly asking for another attempt and still retries immediately; resolution passes `FAILED_BUILD_RETRY_COOLDOWN_MS` and gets at most one attempt per window no matter how often the workflow runs. Ten minutes: long enough that per-minute runs cannot drive per-minute builds, short enough that a transient registry outage clears within the hour. The status line loses its colour dot. `size-[6px] rounded-full` appeared in exactly one file in the repo, so it was a new primitive rather than a pattern, and it duplicated state the text colour already carries — the label now turns `--text-error` on a failed build, which is what every other status row in settings does. `ChipTag` was the wrong home for this: its variants are `mono`/`invite`, with no semantic tone, so a status version would have meant overriding its chrome from the consumer. Also corrects the docs line this changes: a failed build is retried periodically, and saving is the way to retry now, so "wait a moment and run again" no longer describes it. Co-Authored-By: Claude * fix(sandboxes): claim the image row and its reference check in one statement Greptile P1. Reading references in one statement and deleting in another left a window — a wide one, since a provider delete is a network call — where a second workspace could declare the same package list, inherit the `ready` row, and have its next run fail against a template already on its way out. Content addressing is what makes that reachable: the image is shared, so one workspace's delete can strand another's sandbox. The reference check now lives in the conditional DELETE itself, so winning the delete is the proof that nothing referenced the hash. A workspace that adopts the hash first makes the delete match nothing and the release becomes a no-op. Claiming the row before the provider call would otherwise strand a template nothing points at if the provider then refused, so that path puts the row back and the retention sweep inherits the retry — the same property the previous ordering had. The sweep is deliberately left as it is: its equivalent window needs a hash unreferenced AND unused for 30 days, and its provider-first ordering encodes the documented retry-on-refusal behaviour this path now reproduces explicitly. No transaction is opened. The provider call sits between discrete statements rather than inside one, so no pooled connection is held across it — which is why this uses a conditional delete instead of the repo's `pg_advisory_xact_lock` pattern, whose lock only releases at commit. Co-Authored-By: Claude * fix(sandboxes): route the retention sweep through the same image claim Cursor and Greptile both flagged the sweep as still carrying the interleaving just fixed in releaseSandboxImage, and they are right — the reason given for leaving it alone last round does not survive scrutiny. That reason was that provider-first ordering encodes retry-on-refusal, so making the claim atomic would trade a race for an orphaned template. The release path already answers that: claim the row, and put it back if the provider refuses. The sweep can have both properties too. The rarity argument was also weaker than stated. The sweep nominates up to 200 candidates and then works through them eight network deletes at a time, so its check-to-delete gap is seconds to minutes — wider than the window that was just closed, not narrower. Both callers now share `claimAndDeleteImage`, which owns the whole contract: the unreferenced check lives inside the DELETE, the provider call runs only after the claim succeeds, and a refusal restores the row. Having written that ordering twice is what let the two paths drift, so it exists once now. The sweep's query becomes a nomination step only. Its retention cutoff is passed into the claim rather than trusted from the earlier read, so a candidate that stops qualifying mid-sweep fails its claim and is skipped instead of losing its image. Co-Authored-By: Claude * fix(sandboxes): rebuild a hash adopted while its image was being deleted Greptile's third pass on this path, and a case the previous two did not cover: the adopter starting a *fresh build* rather than inheriting a ready row. Claiming removes the registry row, so between that and the provider delete finishing, a workspace can declare the same package list, get a new row, and start a build under the same content-derived imageRef — which the in-flight delete then removes. The window itself is inherent. The registry row and the provider template are two systems with no shared transaction, so it can be narrowed but not closed. A Redis lock would not close it either: acquireLock returns true when Redis is absent, so it cannot be a correctness guarantee for self-hosted. Holding a Postgres advisory lock would, but only by pinning a pooled connection for the length of a provider call, which is a worse trade. What was avoidable is the adopter finding out the slow way. Its row is new and healthy-looking, so nothing noticed: resolution only repairs a row that is missing or failed, and a failed one waits out the retry cooldown first. The release path now re-checks after the delete and re-enqueues, so the rebuild starts immediately instead of one failed run plus a cooldown later. A build already in flight is left to the conflict guard, since it may still outlive the delete. Co-Authored-By: Claude * fix(sandboxes): reclaim a ready row whose image was deleted underneath it Greptile found the hole the previous commit left, and it is the case that made the claim in that commit's message wrong: this one is permanent, not transient. If a re-adopted hash reaches `ready` before the in-flight provider delete lands — plausible, since E2B layer caching can rebuild an identical spec in seconds — the row looks healthy while its imageRef points at nothing. Resolution repairs a row that is missing or failed, never one claiming to be ready, so nothing recovers it. The sandbox stays broken until someone re-saves it by hand. `rebuildIfReadopted` called `ensureSandboxImage` with no options, whose conflict guard reclaims only a failed or stale in-flight row, so it silently did nothing in exactly that case. The release path now passes `imageKnownGone`, which widens the re-claim to any settled row rather than only a failed one. It is the one caller that knows the image is gone regardless of what the row says. An in-flight build is still left alone: it either recreates the template it was building or fails into the normal repair path, and resetting it would only add a duplicate build. The three ways a settled row may be re-claimed now sit in one `settledRebuildBranch` helper — any settled row when the image is known gone, a failed one after the cooldown for an automatic caller, a failed one immediately for a person — because inlining the third case is what hid the gap. Co-Authored-By: Claude * fix(sandboxes): let a same-spec save retry a failed build Cursor Bugbot. `scheduleSandboxBuild` sat inside the changed-hash branch, so a save that did not alter the package list never reached the registry. The comment above it described the opposite — that an unchanged spec finds a ready row and enqueues nothing — which is what `ensureSandboxImage` does, but only if it is called. That made the docs wrong too. They tell a reader to save the sandbox again to retry a failed build immediately, and this branch is exactly why that did nothing: the only way to retry was to edit the package list into a different hash, which is not what someone recovering from a transient registry failure wants to do. The call is now unconditional and the registry decides what a save costs, which is what its conflict guard is for: a ready or in-flight row is left alone, a failed one is re-claimed at once. Releasing the previous image stays behind the hash check, since only a changed hash orphans one. Cache invalidation is unchanged — `scheduleSandboxBuild` already does it, which is why the else branch existed. Co-Authored-By: Claude * docs(sandboxes): correct the image cache's staleness invariant Cursor Bugbot found that a released image can still be served from another replica's cache. The finding is real, and the reason it went unnoticed is that the cache documented an invariant which eager release quietly broke. It claimed a `ready` row is terminal for its spec hash, so a cached hit could not go stale in a way that matters. That held while the only ways a row changed were an edit (new hash) or a delete (caught by the `workspace_sandbox` read). Releasing an image eagerly made a `ready` row disappear with the hash unchanged, so the premise no longer holds and the comment was actively misleading to the next reader. No behaviour change here — the exposure is bounded at IMAGE_TTL_MS on replicas other than the one that ran the release, and it self-heals once the entry expires and the row read finds nothing. Closing it properly needs cross-replica invalidation or a provider-error path that invalidates on "template not found", both of which are larger than a review fix; the comment now says so instead of implying the problem cannot exist. Co-Authored-By: Claude * docs(sandboxes): note that a JavaScript sandbox needs an import to apply Cursor Bugbot pointed out that `useRemoteSandbox` keys on detected static import/require and never on the selected sandbox, so JavaScript without one runs locally and the selection has no effect. Keeping the behaviour: honouring the selection would force those blocks remote, and the large-value-ref guard immediately below would then reject code that runs fine today. Documenting it instead, next to the picker, since a selection that silently does nothing is only surprising if nothing says so. Python is unaffected — it always runs remotely, so its sandbox always applies. Co-Authored-By: Claude * fix(sandboxes): stop create mode surviving a return to an open sandbox Cursor Bugbot. Create mode and having a sandbox open are mutually exclusive, but nothing enforced it, so both could be set at once — and the screen then lied about which sandbox its Delete pointed at. With `isCreating` true and `selectedId` restored, `baseline` is null, so the editor renders an empty "New sandbox" form, while the Delete action is built from `selected` and still targets the restored sandbox. An admin looking at a blank create form could delete a sandbox it never named. Two ways in, both closed: - Browser Forward after starting a new sandbox restores `selectedId` without going through `closeEditor`. The render-time sync that already drops a stale draft now also leaves create mode, which is the same class of correction and the reason that block exists. - "New sandbox" set `isCreating` without clearing `selectedId`, so the same contradiction was reachable without touching history at all. It now clears the selection, with `history: 'replace'` because switching mode is not a destination. Co-Authored-By: Claude * fix(ci): pin the sandbox flag in the second nav catalog test, bump the chart Two CI failures, both mine. `app/workspace/[workspaceId]/settings/navigation.test.ts` asserts the unified catalog and was left on ambient env. Dropping the Sandboxes section without a sandbox provider made it 26 items instead of 27 on CI, which has no `apps/sim/.env` — the same trap already fixed in the sibling `components/settings/navigation.test.ts`, in the one file that was missed. Fixing it needs `vi.hoisted` rather than the sibling's `beforeEach`, because this file reads `allNavigationItems`, built once at module load; a hook would run after the value it is trying to influence already exists. The chart gate is separate: this branch adds sandbox settings to `helm/sim/values.yaml`, and the workflow requires a Chart.yaml bump whenever `helm/sim/**` changes. Additive config, so 1.3.0 -> 1.4.0 by SemVer. Verified by running the whole suite with the flags forced off, not just the two navigation files — no other test depends on a local env file. Co-Authored-By: Claude * fix(sandboxes): keep the row restore to a refused delete only Cursor and Greptile, independently, on the same code. `deleteImage` and `rebuildIfReadopted` shared one try/catch, so a rebuild failure after a *successful* provider delete was handled as if the provider had refused: the catch put the claimed row back, `ready` status and all, pointing at a template that no longer exists. That is the one state resolution cannot repair — it fixes a row that is missing or failed, never one claiming to be ready — so it reintroduced the permanent breakage an earlier commit had just closed, through the error path rather than the happy one. Restoring now belongs strictly to a refused delete. Once the template is gone the row stays gone, and the rebuild runs past that catch. The rebuild also swallows its own failures: it follows a delete that already succeeded, so it must not be reported as a failed release, and inside the sweep it must not reject the rest of its chunk. The adopter's next run still reaches the normal repair path. The regression test drives a rebuild failure and asserts no row is restored. It fails against the original shape — rebuild inside the shared try, no inner catch — which is what the two reviewers were describing. Co-Authored-By: Claude * fix(sandboxes): drop the dead row when a re-adopt rebuild cannot be scheduled Greptile, one layer under the previous fix. Making the post-delete rebuild swallow its own failures kept it from being reported as a failed release, but left the adopter's row claiming a `ready` image whose template is already deleted — the one state resolution cannot repair, since it rebuilds a row that is missing or failed and never one that says ready. So the row is now dropped when the rebuild does not take. That turns the adopter into the missing-row case, which the next execution repairs on its own, instead of a sandbox that stays broken until someone re-saves it by hand. A failure to drop it is logged at error, because at that point two writes in a row have failed and there is nothing further this path can do. Also gives the release tests a default "nothing re-adopted" select. Without it the rebuild threw on an unstubbed mock and the cleanup delete overwrote the predicate the claim assertions read, so two of them were passing on the wrong statement. Co-Authored-By: Claude * feat(sandboxes): repair a missing image at create, where the truth is observable Six review rounds narrowed the window between deleting a shared template and another workspace adopting its content hash, and each fix exposed the next facet. They all share a cause: the registry row and the provider template are two systems with no shared transaction, so any scheme that keeps them in step is guessing. Create is the one step that does not have to guess. It either gets a sandbox or it does not, so a `ready` row pointing at a deleted template now corrects itself the first time it is used, rather than needing someone to re-save the sandbox. - `SandboxImageBuilder.isMissingImage` asks the provider to classify its own failure. Prebuilt-only, because a runtime provider has no image to miss - E2B answers it off `NotFoundError`, which the SDK maps from a 404. The only resource a create names is the template, and the two subclasses that describe other calls — a missing file, an exited sandbox — are excluded. The classifier stays deliberately narrow: treating auth or rate-limit failures as a missing image would turn a provider outage into a build storm - `repairMissingSandboxImage` invalidates the cache, rebuilds with `imageKnownGone` (no cooldown, since this observed the image is gone rather than inferring it), and returns copy telling the author to run again - `ResolvedSandbox` carries `specHash` so the failing execution can name what to rebuild This subsumes the open facets rather than adding another guard beside them: the stale per-replica cache, an adopter left `ready` against a deleted ref, and a rebuild that never took all end at the same place — the next run repairs itself. Co-Authored-By: Claude * fix(sandboxes): key the build trigger by attempt, not by spec Cursor Bugbot. The Trigger.dev idempotency key was the content address alone, so a second attempt at the same spec was deduped against the first: the SDK returns the finished run instead of starting one, and the row that `ensureSandboxImage` just flipped to `pending` sits there with no worker. Nothing can re-claim a `pending` row until it goes stale, so a retry inside the 5-minute TTL did nothing for the next half hour. That silently disabled every repair path — save-to-retry, which the docs name explicitly, and both the resolution and create-time rebuilds. The key's own comment already said it exists "to collapse concurrent saves of the same spec into one build, not to suppress a retry after one failed". The conditional update above it is what actually collapses concurrent saves: only one caller gets a row back, so only one ever reaches the trigger. Keying by the claim's `updatedAt` keeps that property and makes each genuine attempt distinct, while a duplicate delivery of one attempt still collapses. Co-Authored-By: Claude * feat(sandboxes): create a sandbox from the picker, and fix three UI papercuts The Function block's sandbox field now pins a "Create Sandbox" row above its options, matching the "Create Skill" / "Create Tool" rows it sits beside, so authoring a package list no longer means leaving the workflow for Settings. The row is declared by the field (`createAction`) rather than hardcoded by id; block configs are read by the serializer and executor, so the name maps to a modal in the picker rather than carrying a component. Two things the modal has to get right. It seeds the new sandbox's language from the sibling the list is scoped by, or a sandbox created off a JavaScript block would land in the Python list and vanish. And the created option is held locally until a real fetch carries it, or the field would sit on a raw uuid until hydration answered. Also: - The Sandboxes icon was the Logs block's icon (`blocks/blocks/logs.ts`), in both the settings nav and the list rows. It is the Function block's now. - "Default image (no extra packages)" claimed something untrue: E2B and Daytona base images both ship with packages installed. - A new sandbox opened in Python while the Function block defaults to JavaScript. The test pins the two together rather than the literal. Draft shape and helpers moved out of the editor component into `utils.ts` — three consumers now, and it makes the defaults testable without a DOM. * feat(settings): one Max-plan wall, and give the create modal the same one The create-sandbox modal answered a non-Max workspace with a red line under a form it could never submit, and no way to act on it. It now renders the same wall the Settings > Sandboxes tab does — heading, one sentence on what the plan unlocks, and an Upgrade to Max chip — instead of the fields. That wall existed twice already (sandboxes and Sim Mailer), so this extracts it rather than adding a third copy. `SettingsUpgradeNotice` owns the copy rhythm and the route, and `compact` trades the page's full-height centering for a modal's. Both settings consumers now compose it; neither keeps its own markup. The action lands on billing, which `resolveSettingsHref` already redirects to the plan-comparison page for a member who cannot manage billing — so it is a route to explore plans, never a dead end. The chip stays hidden for non-admins, exactly as the settings pages had it. A non-admin on an entitled workspace gets the muted reason rather than the upgrade wall: buying a plan is not what is in their way. --------- Co-authored-by: Claude --- .../en/platform/enterprise/self-hosted.mdx | 5 +- .../docs/en/workflows/blocks/function.mdx | 78 +- .../api/cron/cleanup-sandbox-images/route.ts | 31 + apps/sim/app/api/function/execute/route.ts | 51 +- apps/sim/app/api/organizations/route.test.ts | 5 +- apps/sim/app/api/wand/route.ts | 4 + .../[id]/sandboxes/[sandboxId]/route.ts | 139 + .../workspaces/[id]/sandboxes/authorize.ts | 124 + .../api/workspaces/[id]/sandboxes/route.ts | 101 + .../add-connector-modal.tsx | 3 +- .../components/connector-entitlements.test.ts | 76 +- .../[id]/components/connector-entitlements.ts | 16 +- .../edit-connector-modal.tsx | 3 +- .../[workspaceId]/settings/[section]/page.tsx | 7 +- .../settings/[section]/settings.tsx | 6 + .../settings/components/inbox/inbox.tsx | 30 +- .../components/sandbox-create-modal.tsx | 201 + .../sandboxes/components/sandbox-editor.tsx | 177 + .../settings/components/sandboxes/index.ts | 1 + .../components/sandboxes/sandboxes.tsx | 303 + .../components/sandboxes/search-params.ts | 17 + .../components/sandboxes/utils.test.ts | 57 + .../settings/components/sandboxes/utils.ts | 58 + .../settings-upgrade-notice/index.ts | 1 + .../settings-upgrade-notice.tsx | 58 + .../[workspaceId]/settings/navigation.test.ts | 15 +- .../sub-block/components/code/code.tsx | 2 + .../components/combobox/combobox.tsx | 244 +- .../components/dropdown/dropdown.tsx | 179 +- .../components/tools/sub-block-renderer.tsx | 12 +- .../sub-block/hooks/use-fetched-options.ts | 197 + .../hooks/use-editor-subblock-layout.ts | 4 + .../workflow-block/workflow-block.tsx | 20 + .../w/[workflowId]/hooks/use-wand.ts | 27 +- .../preview-editor/preview-editor.tsx | 4 + .../components/block/block.tsx | 4 + apps/sim/background/cleanup-sandbox-images.ts | 20 + apps/sim/background/sandbox-image-build.ts | 28 + apps/sim/blocks/blocks/function.ts | 70 +- apps/sim/blocks/types.ts | 25 +- .../components/settings/navigation.test.ts | 53 +- apps/sim/components/settings/navigation.ts | 81 +- .../handlers/function/function-handler.ts | 1 + apps/sim/hooks/queries/sandboxes.ts | 136 + apps/sim/lib/api/contracts/hotspots.ts | 6 + apps/sim/lib/api/contracts/index.ts | 1 + apps/sim/lib/api/contracts/sandboxes.ts | 151 + apps/sim/lib/billing/client/plan-view.ts | 4 +- apps/sim/lib/billing/client/utils.ts | 7 +- apps/sim/lib/billing/constants.ts | 19 +- apps/sim/lib/billing/core/access.test.ts | 160 + apps/sim/lib/billing/core/access.ts | 30 +- .../sim/lib/billing/core/subscription.test.ts | 141 +- apps/sim/lib/billing/core/subscription.ts | 207 +- apps/sim/lib/billing/core/workspace-access.ts | 4 + .../lib/billing/enterprise-provisioning.ts | 5 +- apps/sim/lib/billing/max-tier-parity.test.ts | 108 + apps/sim/lib/billing/plan-helpers.ts | 26 +- .../sim/lib/billing/webhooks/invoices.test.ts | 2 +- .../core/config/enterprise-entitlements.ts | 8 + apps/sim/lib/core/config/env-flags.ts | 21 + apps/sim/lib/core/config/env.ts | 6 + .../lib/core/rate-limiter/route-helpers.ts | 17 + .../execution/remote-sandbox/build-errors.ts | 154 + .../remote-sandbox/conformance.test.ts | 157 + .../lib/execution/remote-sandbox/daytona.ts | 32 +- apps/sim/lib/execution/remote-sandbox/e2b.ts | 196 +- .../remote-sandbox/image-registry.test.ts | 438 + .../remote-sandbox/image-registry.ts | 592 + .../sim/lib/execution/remote-sandbox/index.ts | 168 +- .../lib/execution/remote-sandbox/provider.ts | 41 + .../execution/remote-sandbox/resolve.test.ts | 433 + .../lib/execution/remote-sandbox/resolve.ts | 464 + .../remote-sandbox/sandbox-spec.test.ts | 190 + .../execution/remote-sandbox/sandbox-spec.ts | 294 + .../sim/lib/execution/remote-sandbox/types.ts | 76 +- .../execution/remote-sandbox/wand-enricher.ts | 170 + .../remote-sandbox/workspace-sandboxes.ts | 206 + apps/sim/lib/workflows/autolayout/utils.ts | 5 + .../lib/workflows/subblocks/display.test.ts | 24 + apps/sim/lib/workflows/subblocks/display.ts | 23 + apps/sim/lib/workflows/subblocks/options.ts | 118 + .../sim/lib/workflows/subblocks/visibility.ts | 31 +- apps/sim/lib/workspaces/admin-move.ts | 10 +- apps/sim/lib/workspaces/policy.test.ts | 70 + apps/sim/lib/workspaces/policy.ts | 14 +- apps/sim/providers/types.ts | 7 + apps/sim/providers/utils.ts | 71 +- apps/sim/scripts/verify-sandbox-parity.ts | 55 + apps/sim/serializer/index.ts | 6 + apps/sim/tools/function/execute.ts | 21 + apps/sim/tools/function/types.ts | 9 + apps/sim/tools/params.ts | 24 +- helm/sim/Chart.yaml | 2 +- helm/sim/values.yaml | 12 + .../migrations/0277_workspace_sandboxes.sql | 39 + .../db/migrations/meta/0277_snapshot.json | 18285 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 83 + packages/testing/src/mocks/env-flags.mock.ts | 2 + packages/testing/src/mocks/schema.mock.ts | 27 + scripts/check-api-validation-contracts.ts | 5 +- scripts/setup/checks.ts | 20 + 103 files changed, 25594 insertions(+), 584 deletions(-) create mode 100644 apps/sim/app/api/cron/cleanup-sandbox-images/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts create mode 100644 apps/sim/app/api/workspaces/[id]/sandboxes/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-create-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/settings-upgrade-notice.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts create mode 100644 apps/sim/background/cleanup-sandbox-images.ts create mode 100644 apps/sim/background/sandbox-image-build.ts create mode 100644 apps/sim/hooks/queries/sandboxes.ts create mode 100644 apps/sim/lib/api/contracts/sandboxes.ts create mode 100644 apps/sim/lib/billing/core/access.test.ts create mode 100644 apps/sim/lib/billing/max-tier-parity.test.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/build-errors.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/image-registry.test.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/image-registry.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/provider.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/resolve.test.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/resolve.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/sandbox-spec.test.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/wand-enricher.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts create mode 100644 packages/db/migrations/0277_workspace_sandboxes.sql create mode 100644 packages/db/migrations/meta/0277_snapshot.json diff --git a/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx b/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx index db0889249d..b54020b1c3 100644 --- a/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx +++ b/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx @@ -23,7 +23,7 @@ ENTERPRISE_ENABLED=true NEXT_PUBLIC_ENTERPRISE_ENABLED=true ``` -That turns on organizations, permission groups, SSO, whitelabeling, audit logs, session policies, data retention, data drains, workspace forks, and the inbox. +That turns on organizations, permission groups, SSO, whitelabeling, audit logs, session policies, data retention, data drains, workspace forks, sandboxes, and the inbox. ### Turning one feature off @@ -51,6 +51,9 @@ The individual flags also work on their own if you would rather opt in one at a | Data drains | `DATA_DRAINS_ENABLED` | `NEXT_PUBLIC_DATA_DRAINS_ENABLED` | | Workspace forks | `FORKING_ENABLED` | — | | Sim Mailer inbox | `INBOX_ENABLED` | `NEXT_PUBLIC_INBOX_ENABLED` | +| Sandboxes | `SANDBOXES_ENABLED` | `NEXT_PUBLIC_SANDBOXES_ENABLED` | + +Sandboxes also need a remote execution provider, since the deployment builds the images itself: set `E2B_API_KEY`, or `SANDBOX_PROVIDER=daytona` with a `DAYTONA_API_KEY` that has `write:snapshots` and `write:sandboxes`. Without one the settings section appears but builds fail. Data retention is the one feature that deletes data. Its flag controls the cleanup pass, not the settings screen — retention windows are always configurable. Nothing is ever deleted until you enable it, and even then only against windows you configured explicitly. Sim never applies the hosted plan defaults to a self-hosted deployment. diff --git a/apps/docs/content/docs/en/workflows/blocks/function.mdx b/apps/docs/content/docs/en/workflows/blocks/function.mdx index e89939df2e..2e555979bd 100644 --- a/apps/docs/content/docs/en/workflows/blocks/function.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/function.mdx @@ -74,6 +74,82 @@ Beyond the Python standard library, the sandbox ships E2B's data-science stack p - **Math and testing:** `sympy`, `pytest` - **Sim additions:** `awscli`, `yq`, `csvkit` +## Sandboxes + +A sandbox is a named dependency set your workspace maintains — a language plus a +list of pip or npm packages. Select one on a Function block and its code can +import everything on that list. Leave it empty and the block runs on the default +image, exactly as before. + +Create and edit sandboxes in **Settings → Sandboxes**. Only workspace admins can +create or edit them. On sim.ai they need an active Max or Enterprise plan; +self-hosted deployments turn them on with `SANDBOXES_ENABLED` (see +[self-hosted enterprise](/platform/enterprise/self-hosted)). The section is +hidden when a deployment has no sandbox provider configured. + +1. **Name** the sandbox — `bigquery-etl`, `scraping`, whatever the job is. +2. Pick the **language**. A sandbox is language-scoped, so a Python block only + ever lists Python sandboxes. +3. Paste your **dependencies**, one per line. Version pins are optional. + +``` +google-cloud-bigquery==3.25.0 +pyairtable>=3.0 +pandas +``` + +Then open the block's advanced options and choose the sandbox under **Sandbox**. + +In JavaScript the sandbox applies to code that uses `import` or `require` — that is +what sends the block to a remote sandbox in the first place, so a block without them +keeps running locally and ignores the selection. Python always runs remotely, so a +selected sandbox always applies. + + +Two sandboxes with the same language and the same package list share one build, +so duplicating a set costs nothing. Editing a package list starts a new build; +runs already in flight keep using the old one. Deleting a sandbox frees its build +once nothing else uses it. + + +### Build status + +On sim.ai, each dependency set is prebuilt into a reusable image, so runs pay no +install cost. The status row in Settings shows **Queued**, **Building**, +**Ready**, or **Failed**. A failed build reports what went wrong — a package that +does not exist, a version that has no match, a resolver conflict — with the +installer log behind a disclosure. + +Running a block before its sandbox is **Ready** stops the run and shows you the +status. A failed build is retried periodically on its own; to retry immediately, +save the sandbox again in Settings. + +On a self-hosted deployment using Daytona, dependencies install inside the +sandbox at the start of every run instead, adding roughly 10–30 seconds per +execution. Prebuilt images require E2B. + +### What is allowed + +Package names and version specifiers only. URLs, `git+` references, `-e`, local +paths, `--index-url`, and npm aliases are rejected, with the offending line +number reported. A sandbox may declare up to 50 packages. + +## Scoping secrets for agent tools + +When a Function block is used as an Agent tool, its code can read every workspace +secret by default — both `{{MY_SECRET}}` and `environmentVariables['MY_SECRET']`. + +To narrow that, set **Secret access** to *Selected secrets* in the block's +tool configuration and pick the names the code may read. Two things change: + +- Only those secrets are injected. `{{OTHER_SECRET}}` no longer resolves either. +- The selected **names** are added to the tool's description, so the model knows + what it can reference. Values are never sent to the model — they are injected + server-side at execution. + +Leaving the default (*All secrets*) resolves the list at run time, so a secret +added next month is included automatically. + ## Examples ### Reshape an API response @@ -170,6 +246,6 @@ The lazy `sim.files` and `sim.values` helpers are available only in JavaScript f { question: "When does code run locally vs. in a sandbox?", answer: "JavaScript without external imports runs in a local isolated sandbox for speed. JavaScript that uses import or require runs in E2B. Python always runs in the E2B sandbox, with or without imports." }, { question: "How do I reference outputs from other blocks inside my code?", answer: "Use angle-bracket syntax directly, like or , with no quotes around the tag — Sim replaces it with the real value before execution. For environment variables, use double curly braces: {{API_KEY}}." }, { question: "What does the Function block return?", answer: "Two outputs: result (the return value of your code, read as ) and stdout (anything logged with console.log or print, read as ). Include a return statement in JavaScript, or print JSON in Python, to pass data downstream." }, - { question: "Can I make HTTP requests from a Function block?", answer: "Yes. fetch() is available in JavaScript with async/await; libraries like axios are not, only the built-in fetch. In Python, use requests or httpx in the E2B sandbox." }, + { question: "Can I make HTTP requests from a Function block?", answer: "Yes. fetch() is available in JavaScript with async/await; libraries like axios are only available when the block has a sandbox selected. In Python, use requests or httpx in the E2B sandbox." }, { question: "Is there a timeout for Function block execution?", answer: "Yes, a configurable execution timeout. If your code exceeds it, the run is terminated and the block reports an error. Keep this in mind for external calls or heavy processing." }, ]} /> diff --git a/apps/sim/app/api/cron/cleanup-sandbox-images/route.ts b/apps/sim/app/api/cron/cleanup-sandbox-images/route.ts new file mode 100644 index 0000000000..0990d8ccdb --- /dev/null +++ b/apps/sim/app/api/cron/cleanup-sandbox-images/route.ts @@ -0,0 +1,31 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runCleanupSandboxImages } from '@/background/cleanup-sandbox-images' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('CleanupSandboxImagesAPI') + +/** + * Retention sweep for prebuilt sandbox images. A runtime-strategy deployment has + * nothing to collect, so the sweep is a no-op there rather than a special case + * here. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'sandbox image cleanup') + if (authError) return authError + + try { + const result = await runCleanupSandboxImages() + return NextResponse.json({ success: true, ...result }) + } catch (error) { + logger.error('Failed to sweep sandbox images', { error }) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to sweep sandbox images') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index e7c87cb645..59a295bc92 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -546,6 +546,42 @@ function resolveWorkflowVariables( return resolvedCode } +/** + * Narrows the secrets an execution can see, per the block's stored scope. + * + * | Stored value | Behavior | + * |-----------------------------|-------------------------------------------------| + * | unset (every block today) | all secrets — the regression-safe default | + * | `'all'` | all secrets, resolved now so later additions land | + * | `'selected'` + names | only those | + * | `'selected'` + empty list | none — an explicit deny | + * + * Unset and `'all'` must both inject everything: agent-authored code already + * reads `{{MY_SECRET}}` and `environmentVariables['MY_SECRET']` today, so a + * default-deny would silently break prompts that work right now. + */ +function scopeEnvironmentVariables( + envVars: Record, + scope: 'all' | 'selected' | undefined, + mountedSecrets: string[] | undefined +): Record { + if (scope !== 'selected') return envVars + + const allowed = new Set(mountedSecrets ?? []) + const scoped: Record = {} + const missing: string[] = [] + for (const name of allowed) { + if (name in envVars) scoped[name] = envVars[name] + else missing.push(name) + } + if (missing.length > 0) { + // A secret that was renamed or deleted since the block was configured. Drop + // it rather than failing: the code's own error is clearer than ours. + logger.warn('Mounted secrets no longer exist in this workspace', { missing }) + } + return scoped +} + function resolveEnvironmentVariables( code: string, params: Record, @@ -1401,7 +1437,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { outputSandboxPath, overwriteFileId, outputs, - envVars = {}, + envVars: rawEnvVars = {}, + secretScope, + mountedSecrets, + sandboxId: selectedSandboxId, blockData = {}, blockNameMapping = {}, blockOutputSchemas = {}, @@ -1417,6 +1456,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => { isCustomTool = false, _sandboxFiles, } = body + // Scoped before {{VAR}} resolution so the `{{NAME}}` path and the + // `environmentVariables[...]` dict narrow together — filtering only the dict + // would leave `{{OTHER_SECRET}}` resolving, which is a hole, not a scope. + const envVars = scopeEnvironmentVariables(rawEnvVars, secretScope, mountedSecrets) sourceCodeForErrors = sourceCode const outputFiles = getOutputFileDeclarations({ outputs, @@ -1547,6 +1590,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { sandboxFiles: _sandboxFiles, outputSandboxPath, outputSandboxPaths, + workspaceId, + sandboxId: selectedSandboxId, }) const executionTime = Date.now() - execStart @@ -1706,6 +1751,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { sandboxFiles: _sandboxFiles, outputSandboxPath, outputSandboxPaths, + workspaceId, + sandboxId: selectedSandboxId, }) const executionTime = Date.now() - execStart stdout += e2bStdout @@ -1794,6 +1841,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { sandboxFiles: _sandboxFiles, outputSandboxPath, outputSandboxPaths, + workspaceId, + sandboxId: selectedSandboxId, }) const executionTime = Date.now() - execStart stdout += e2bStdout diff --git a/apps/sim/app/api/organizations/route.test.ts b/apps/sim/app/api/organizations/route.test.ts index a0e076b22d..24fd78a66b 100644 --- a/apps/sim/app/api/organizations/route.test.ts +++ b/apps/sim/app/api/organizations/route.test.ts @@ -41,12 +41,13 @@ vi.mock('@/lib/billing/organizations/create-organization', () => ({ OrganizationSlugTakenError: class OrganizationSlugTakenError extends Error {}, })) +/** Mirrors the real predicate, which also admits the `team_*` credit tiers. */ vi.mock('@/lib/billing/plan-helpers', () => ({ - isOrgPlan: (plan: string) => plan === 'team' || plan === 'enterprise', + isOrgPlan: (plan: string) => plan === 'team' || plan.startsWith('team_') || plan === 'enterprise', })) vi.mock('@/lib/billing/subscriptions/utils', () => ({ - ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing'], + ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'], })) vi.mock('@/lib/workspaces/organization-workspaces', () => ({ diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 6222c75ad4..617629d4a0 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -20,6 +20,7 @@ import { env } from '@/lib/core/config/env' import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { enrichSandboxPackages } from '@/lib/execution/remote-sandbox/wand-enricher' import { enrichTableSchema } from '@/lib/table/llm/wand' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils' @@ -90,6 +91,9 @@ Use this context to calculate relative dates like "yesterday", "last week", "beg }, 'table-schema': enrichTableSchema, + // Both the JavaScript and Python code prompts generate under this type — the + // Python swap in `code.tsx` replaces the prompt text but keeps the type. + 'javascript-function-body': enrichSandboxPackages, } async function updateUserStatsForWand( diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts new file mode 100644 index 0000000000..98bdce0b87 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/[sandboxId]/route.ts @@ -0,0 +1,139 @@ +import { db } from '@sim/db' +import { workspaceSandbox } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { deleteSandboxContract, updateSandboxContract } from '@/lib/api/contracts/sandboxes' +import { parseRequest } from '@/lib/api/server' +import { runDetached } from '@/lib/core/utils/background' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { releaseSandboxImage } from '@/lib/execution/remote-sandbox/image-registry' +import { invalidateSandboxResolution } from '@/lib/execution/remote-sandbox/resolve' +import { + isSandboxNameTaken, + readWorkspaceSandbox, + scheduleSandboxBuild, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { + authorizeSandboxMutation, + buildSpecOrResponse, + isNameConflictError, + nameConflictResponse, +} from '@/app/api/workspaces/[id]/sandboxes/authorize' + +const logger = createLogger('WorkspaceSandboxAPI') + +type SandboxContext = { params: Promise<{ id: string; sandboxId: string }> } + +export const PATCH = withRouteHandler(async (request: NextRequest, context: SandboxContext) => { + const { id: workspaceId, sandboxId } = await context.params + + const authorized = await authorizeSandboxMutation(workspaceId) + if (!authorized.ok) return authorized.response + + const parsed = await parseRequest(updateSandboxContract, request, context) + if (!parsed.success) return parsed.response + const { name, language, dependencies } = parsed.data.body + + const [existing] = await db + .select({ + id: workspaceSandbox.id, + name: workspaceSandbox.name, + language: workspaceSandbox.language, + dependencies: workspaceSandbox.dependencies, + specHash: workspaceSandbox.specHash, + }) + .from(workspaceSandbox) + .where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId))) + .limit(1) + + if (!existing) { + return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 }) + } + + const nextName = name ?? existing.name + if (name && name !== existing.name && (await isSandboxNameTaken(workspaceId, name, sandboxId))) { + return nameConflictResponse(name) + } + + // Both halves are revalidated together even when only one changed: switching + // language has to re-check the existing list against the new language's rules, + // and editing dependencies has to check them against the stored language. + const nextLanguage = language ?? (existing.language as 'javascript' | 'python') + const nextDependencies = dependencies ?? existing.dependencies ?? [] + + const built = buildSpecOrResponse(nextLanguage, nextDependencies) + if (!built.ok) return built.response + const { spec } = built + + try { + await db + .update(workspaceSandbox) + .set({ + name: nextName, + language: spec.language, + dependencies: spec.dependencies, + specHash: spec.specHash, + updatedAt: new Date(), + }) + // Scoped by workspace as well as id: every other query here is, and relying on + // the SELECT above to have 404'd first makes authz an ordering invariant. + .where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId))) + } catch (error) { + // The pre-check above can lose a race with a concurrent rename; the unique + // index is the real arbiter, and losing it is a conflict, not a server fault. + if (isNameConflictError(error)) return nameConflictResponse(nextName) + throw error + } + + // Unconditional, because the registry decides what a save costs: a `ready` or + // in-flight row is left alone, so renaming or re-saving an unchanged spec + // enqueues nothing, while a failed one gets the immediate retry a person saving + // is asking for. Gating this on a changed hash meant a same-spec save silently + // did nothing, and the only way to retry a failed build was to edit the package + // list into a different hash. + await scheduleSandboxBuild(spec) + + if (spec.specHash !== existing.specHash) { + // The previous content address is unreferenced by this sandbox now. Release + // no-ops when another sandbox still declares the same package list. + runDetached('release-sandbox-image', () => releaseSandboxImage(existing.specHash)) + logger.info('Sandbox spec changed, scheduled a build', { workspaceId, sandboxId }) + } + + const sandbox = await readWorkspaceSandbox(workspaceId, sandboxId) + if (!sandbox) { + return NextResponse.json({ error: 'Failed to read back the updated sandbox' }, { status: 500 }) + } + return NextResponse.json({ sandbox }) +}) + +export const DELETE = withRouteHandler(async (request: NextRequest, context: SandboxContext) => { + const { id: workspaceId, sandboxId } = await context.params + + const authorized = await authorizeSandboxMutation(workspaceId) + if (!authorized.ok) return authorized.response + + const parsed = await parseRequest(deleteSandboxContract, request, context) + if (!parsed.success) return parsed.response + + // A block may still reference this sandbox. Deleting is allowed anyway; that + // execution then fails closed with a message naming the missing sandbox, + // rather than silently falling back to an image without its dependencies. + const deleted = await db + .delete(workspaceSandbox) + .where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId))) + .returning({ id: workspaceSandbox.id, specHash: workspaceSandbox.specHash }) + + if (deleted.length === 0) { + return NextResponse.json({ error: 'Sandbox not found' }, { status: 404 }) + } + + invalidateSandboxResolution() + // Detached: the row is already gone, so the caller's delete succeeded whatever + // the provider says. Awaiting would hold a UI delete open on a remote call the + // retention sweep would retry anyway. + runDetached('release-sandbox-image', () => releaseSandboxImage(deleted[0].specHash)) + logger.info('Deleted workspace sandbox', { workspaceId, sandboxId }) + return NextResponse.json({ success: true }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts new file mode 100644 index 0000000000..7cfef4bea2 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/authorize.ts @@ -0,0 +1,124 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { enforceWorkspaceRateLimit } from '@/lib/core/rate-limiter/route-helpers' +import type { SandboxLanguage } from '@/lib/execution/remote-sandbox/sandbox-spec' +import { + buildSpecUpdate, + MAX_PLAN_REQUIRED, + SANDBOX_ADMIN_REQUIRED, + SANDBOX_MUTATION_LIMIT, + SandboxDependencyError, + type SandboxSpecUpdate, + WORKSPACE_SANDBOX_NAME_INDEX, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +export interface SandboxMutationActor { + userId: string + name?: string | null + email?: string | null +} + +/** + * The 409 both write paths return for a duplicate name. Shared so the pre-check + * and the unique-index catch cannot describe the same conflict differently. + */ +export function nameConflictResponse(name: string): NextResponse { + return NextResponse.json( + { error: `A sandbox named "${name}" already exists in this workspace` }, + { status: 409 } + ) +} + +/** + * Validates a submitted dependency list, returning the 400 the editor knows how + * to read — `issues` carries a line number per rejected row, which the generic + * validation error does not. + */ +export function buildSpecOrResponse( + language: SandboxLanguage, + dependencies: readonly string[] +): { ok: true; spec: SandboxSpecUpdate } | { ok: false; response: NextResponse } { + try { + return { ok: true, spec: buildSpecUpdate(language, dependencies) } + } catch (error) { + if (error instanceof SandboxDependencyError) { + return { + ok: false, + response: NextResponse.json( + { error: error.message, issues: error.issues }, + { status: 400 } + ), + } + } + throw error + } +} + +/** + * Whether a write failed because it collided with the workspace/name unique + * index. Both paths pre-check the name, but the index is the real arbiter and a + * concurrent write can still lose the race — which is a 409, not a 500. + */ +export function isNameConflictError(error: unknown): boolean { + const message = getErrorMessage(error) + return message.includes(WORKSPACE_SANDBOX_NAME_INDEX) || message.includes('23505') +} + +/** + * Authenticates, authorizes, entitles, and rate-limits a sandbox mutation — in + * that order, and always before any untrusted input is parsed. + * + * Shared by both route files so the create path and the edit/delete path cannot + * drift into different checks. + */ +export async function authorizeSandboxMutation( + workspaceId: string +): Promise<{ ok: true; actor: SandboxMutationActor } | { ok: false; response: NextResponse }> { + const session = await getSession() + if (!session?.user?.id) { + return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + } + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin') { + return { + ok: false, + response: NextResponse.json({ error: SANDBOX_ADMIN_REQUIRED }, { status: 403 }), + } + } + if (!(await hasWorkspaceSandboxAccess(workspaceId))) { + return { + ok: false, + response: NextResponse.json({ error: MAX_PLAN_REQUIRED }, { status: 403 }), + } + } + const limited = await enforceWorkspaceRateLimit( + 'sandbox-mutations', + workspaceId, + SANDBOX_MUTATION_LIMIT + ) + if (limited) return { ok: false, response: limited } + + return { + ok: true, + actor: { userId: session.user.id, name: session.user.name, email: session.user.email }, + } +} + +/** Reads a workspace sandbox list; any member may look, only admins may write. */ +export async function authorizeSandboxRead( + _request: NextRequest, + workspaceId: string +): Promise<{ ok: true; userId: string } | { ok: false; response: NextResponse }> { + const session = await getSession() + if (!session?.user?.id) { + return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + } + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission) { + return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + } + return { ok: true, userId: session.user.id } +} diff --git a/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts b/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts new file mode 100644 index 0000000000..f39bc6b421 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/sandboxes/route.ts @@ -0,0 +1,101 @@ +import { db } from '@sim/db' +import { workspaceSandbox } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { createSandboxContract } from '@/lib/api/contracts/sandboxes' +import { parseRequest } from '@/lib/api/server' +import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + currentSandboxStrategy, + isSandboxNameTaken, + listWorkspaceSandboxes, + readWorkspaceSandbox, + scheduleSandboxBuild, +} from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { + authorizeSandboxMutation, + authorizeSandboxRead, + buildSpecOrResponse, + isNameConflictError, + nameConflictResponse, +} from '@/app/api/workspaces/[id]/sandboxes/authorize' + +const logger = createLogger('WorkspaceSandboxesAPI') + +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const workspaceId = (await context.params).id + + const viewer = await authorizeSandboxRead(request, workspaceId) + if (!viewer.ok) return viewer.response + + // The list itself is not plan-gated: a workspace that downgraded must still + // see (and keep executing) what it already built. `entitled` drives whether + // the editor renders or an upgrade prompt does. + const [sandboxes, entitled] = await Promise.all([ + listWorkspaceSandboxes(workspaceId), + hasWorkspaceSandboxAccess(workspaceId), + ]) + + return NextResponse.json({ + sandboxes, + strategy: currentSandboxStrategy(), + entitled, + }) + } +) + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const workspaceId = (await context.params).id + + const authorized = await authorizeSandboxMutation(workspaceId) + if (!authorized.ok) return authorized.response + + const parsed = await parseRequest(createSandboxContract, request, context) + if (!parsed.success) return parsed.response + const { name, language, dependencies } = parsed.data.body + + const built = buildSpecOrResponse(language, dependencies) + if (!built.ok) return built.response + const { spec } = built + + if (await isSandboxNameTaken(workspaceId, name)) { + return nameConflictResponse(name) + } + + const id = generateId() + try { + await db.insert(workspaceSandbox).values({ + id, + workspaceId, + name, + language: spec.language, + dependencies: spec.dependencies, + specHash: spec.specHash, + createdBy: authorized.actor.userId, + }) + } catch (error) { + // The unique index is the real arbiter — the pre-check above only exists to + // return a friendlier message when there is no race. + if (isNameConflictError(error)) return nameConflictResponse(name) + logger.error('Failed to insert sandbox', { workspaceId, error: getErrorMessage(error) }) + throw error + } + + await scheduleSandboxBuild(spec) + logger.info('Created workspace sandbox', { workspaceId, sandboxId: id, language }) + + const sandbox = await readWorkspaceSandbox(workspaceId, id) + if (!sandbox) { + return NextResponse.json( + { error: 'Failed to read back the created sandbox' }, + { status: 500 } + ) + } + return NextResponse.json({ sandbox }) + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index 934e4b0701..661bc32518 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -22,7 +22,6 @@ import { } from '@sim/emcn' import { ArrowLeft, Plus } from 'lucide-react' import { useParams } from 'next/navigation' -import { isBillingEnabled } from '@/lib/core/config/env-flags' import { consumeOAuthReturnContext } from '@/lib/credentials/client-state' import { getCanonicalScopesForProvider, @@ -79,7 +78,7 @@ export function AddConnectorModal({ const { ownerBilling } = useWorkspaceHostContext() const { mutate: createConnector, isPending: isCreating } = useCreateConnector() - const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling, isBillingEnabled) + const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling) const connectorConfig = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : null const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.test.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.test.ts index be610b9887..beef4a6d51 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it } from 'vitest' import type { WorkspaceOwnerBilling } from '@/lib/api/contracts/workspaces' import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements' @@ -19,52 +20,57 @@ const HOST_MAX_BILLING: WorkspaceOwnerBilling = { billingBlockedReason: null, } +const FREE_BILLING: WorkspaceOwnerBilling = { + ...HOST_MAX_BILLING, + plan: 'free', + status: null, + isPaid: false, + isTeam: false, + isOrgScoped: false, + organizationId: null, +} + +afterAll(resetEnvFlagsMock) + describe('hasWorkspaceMaxConnectorAccess', () => { + beforeEach(() => { + resetEnvFlagsMock() + setEnvFlags({ isHosted: true, isBillingEnabled: true }) + }) + it('uses the workspace host Max entitlement', () => { - expect(hasWorkspaceMaxConnectorAccess(HOST_MAX_BILLING, true)).toBe(true) + expect(hasWorkspaceMaxConnectorAccess(HOST_MAX_BILLING)).toBe(true) }) it('does not unlock live sync from a free host plan', () => { - expect( - hasWorkspaceMaxConnectorAccess( - { - ...HOST_MAX_BILLING, - plan: 'free', - status: null, - isPaid: false, - isTeam: false, - isOrgScoped: false, - organizationId: null, - }, - true - ) - ).toBe(false) + expect(hasWorkspaceMaxConnectorAccess(FREE_BILLING)).toBe(false) }) it('does not unlock live sync for a blocked Max payer', () => { expect( - hasWorkspaceMaxConnectorAccess( - { - ...HOST_MAX_BILLING, - billingBlocked: true, - billingBlockedReason: 'payment_failed', - }, - true - ) + hasWorkspaceMaxConnectorAccess({ + ...HOST_MAX_BILLING, + billingBlocked: true, + billingBlockedReason: 'payment_failed', + }) ).toBe(false) }) it('keeps connector intervals available when billing is disabled', () => { - expect( - hasWorkspaceMaxConnectorAccess( - { - ...HOST_MAX_BILLING, - plan: 'free', - status: null, - isPaid: false, - }, - false - ) - ).toBe(true) + setEnvFlags({ isBillingEnabled: false }) + + expect(hasWorkspaceMaxConnectorAccess(FREE_BILLING)).toBe(true) + }) + + /** + * The server gate is `!isHosted || !isBillingEnabled` — sub-hourly sync is + * ungated off the hosted deployment. This client helper omitted the `isHosted` + * branch, so a self-hosted operator with billing enabled saw "Live" locked while + * the API would have accepted it. + */ + it('keeps connector intervals available off the hosted deployment even with billing enabled', () => { + setEnvFlags({ isHosted: false, isBillingEnabled: true }) + + expect(hasWorkspaceMaxConnectorAccess(FREE_BILLING)).toBe(true) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.ts index a25769cf00..dba607958d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements.ts @@ -1,10 +1,16 @@ import type { WorkspaceOwnerBilling } from '@/lib/api/contracts/workspaces' import { getSubscriptionAccessState } from '@/lib/billing/client' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' -export function hasWorkspaceMaxConnectorAccess( - ownerBilling: WorkspaceOwnerBilling, - billingEnabled: boolean -): boolean { - if (!billingEnabled) return true +/** + * Client mirror of `hasWorkspaceLiveSyncAccess`. + * + * Reads the same two env flags in the same order as the server helper so the two + * cannot diverge: sub-hourly sync is ungated off the hosted deployment even when + * billing is enabled. Without the `isHosted` branch a self-hosted operator with + * billing on saw the "Live" interval locked while the API would have accepted it. + */ +export function hasWorkspaceMaxConnectorAccess(ownerBilling: WorkspaceOwnerBilling): boolean { + if (!isHosted || !isBillingEnabled) return true return getSubscriptionAccessState(ownerBilling).hasUsableMaxAccess } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 943f975cbe..b916a31b23 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -17,7 +17,6 @@ import { } from '@sim/emcn' import { createLogger } from '@sim/logger' import { ExternalLink, RotateCcw } from 'lucide-react' -import { isBillingEnabled } from '@/lib/core/config/env-flags' import { ConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields' import { hasWorkspaceMaxConnectorAccess } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-entitlements' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' @@ -194,7 +193,7 @@ export function EditConnectorModal({ const { ownerBilling } = useWorkspaceHostContext() const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector() - const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling, isBillingEnabled) + const hasMaxAccess = hasWorkspaceMaxConnectorAccess(ownerBilling) const persistedCanonicalModes = useMemo( () => readPersistedCanonicalModes(connector.sourceConfig), diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 6303933ab1..fee9364735 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -11,7 +11,7 @@ import { } from '@/components/settings/navigation' import { getSession } from '@/lib/auth' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' +import { hasWorkspaceInboxAccess, hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' @@ -49,6 +49,7 @@ const WORKSPACE_SECTION_MAP: Partial item.id === workspaceSection)) notFound() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index b594b4c1e0..b9a95f6564 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -29,6 +29,11 @@ const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').th const Secrets = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets) ) +const Sandboxes = dynamic(() => + import('@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes').then( + (m) => m.Sandboxes + ) +) const CustomTools = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools').then( (m) => m.CustomTools @@ -187,6 +192,7 @@ export function SettingsPage({ section }: SettingsPageProps) { )} {effectiveSection === 'byok' && } + {effectiveSection === 'sandboxes' && } {effectiveSection === 'mcp' && } {effectiveSection === 'forks' && } {effectiveSection === 'custom-tools' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx index b980b83c04..e1ff2ee790 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx @@ -1,7 +1,5 @@ 'use client' -import { Chip } from '@sim/emcn' -import { ArrowRight } from 'lucide-react' import { useParams } from 'next/navigation' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -12,12 +10,11 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/inbox/components' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice' import { useInboxConfig } from '@/hooks/queries/inbox' -import { useSettingsNavigation } from '@/hooks/use-settings-navigation' export function Inbox() { const params = useParams() - const { navigateToSettings } = useSettingsNavigation() const workspaceId = params.workspaceId as string const { data: config, isLoading } = useInboxConfig(workspaceId) @@ -38,26 +35,11 @@ export function Inbox() { } return ( -
-
-

- Sim Mailer requires an active Max plan -

-

- Upgrade to Max and ensure billing is active to receive tasks via email and let Sim - work on your behalf. -

-
- {canAdmin && ( - navigateToSettings({ section: 'billing' })} - > - Upgrade to Max - - )} -
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-create-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-create-modal.tsx new file mode 100644 index 0000000000..059bf95fd9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-create-modal.tsx @@ -0,0 +1,201 @@ +'use client' + +import { useState } from 'react' +import { + ChipDropdown, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { useParams } from 'next/navigation' +import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' +import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + DEPENDENCY_PLACEHOLDERS, + emptyDraft, + extractIssues, + LANGUAGE_OPTIONS, + SANDBOX_UPGRADE_DESCRIPTION, + SANDBOX_UPGRADE_TITLE, + type SandboxDraft, + type SandboxLanguage, + toSubmittedLines, +} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice' +import { type Sandbox, useCreateSandbox, useSandboxes } from '@/hooks/queries/sandboxes' + +interface SandboxCreateModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** + * Seeds the language of the new sandbox. Pickers scope their list to one + * language, so without this a sandbox created from a JavaScript block could + * land in the Python list and never appear. + */ + defaultLanguage?: SandboxLanguage + /** Receives the created sandbox so the caller can select it. */ + onCreated?: (sandbox: Sandbox) => void +} + +/** + * Creates a sandbox from wherever one is being picked, so authoring a package + * list never means leaving the workflow for Settings. + * + * Editing stays in Settings — this is deliberately create-only, matching the + * "Create Skill" / "Create Tool" rows it sits beside. + */ +export function SandboxCreateModal({ + open, + onOpenChange, + defaultLanguage, + onCreated, +}: SandboxCreateModalProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + + // Keyed off `open` so the list is not fetched by every mounted picker, only by + // one the user actually opened. It shares the picker's cache entry either way. + const { data } = useSandboxes(open ? workspaceId : undefined) + const permissions = useUserPermissionsContext() + const canAdmin = canMutateWorkspaceSettingsSection('sandboxes', permissions) + // Assume entitled until the list answers — a flash of the upgrade copy on a + // workspace that has it would be worse than a late, accurate one. + const entitled = data?.entitled ?? true + + const createSandbox = useCreateSandbox() + + const [draft, setDraft] = useState(emptyDraft) + const [issues, setIssues] = useState([]) + const [error, setError] = useState(null) + + // The modal stays mounted for its exit animation, so each opening has to clear + // what the last one left behind. + const [wasOpen, setWasOpen] = useState(open) + if (wasOpen !== open) { + setWasOpen(open) + if (open) { + setDraft({ ...emptyDraft(), ...(defaultLanguage ? { language: defaultLanguage } : {}) }) + setIssues([]) + setError(null) + } + } + + // A gate answers the whole dialog rather than reddening a form the user can + // never submit — the same call the settings page makes. + const gate = !entitled ? 'plan' : !canAdmin ? 'permission' : null + const saving = createSandbox.isPending + + const handleCreate = async () => { + setIssues([]) + setError(null) + try { + const { sandbox } = await createSandbox.mutateAsync({ + workspaceId, + name: draft.name.trim(), + language: draft.language, + dependencies: toSubmittedLines(draft.dependencies), + }) + onCreated?.(sandbox) + onOpenChange(false) + } catch (caught) { + const lineIssues = extractIssues(caught) + if (lineIssues.length > 0) { + setIssues(lineIssues) + return + } + setError(getErrorMessage(caught, 'Failed to create sandbox')) + } + } + + return ( + + onOpenChange(false)}>Create sandbox + + + {gate === 'plan' ? ( + + ) : gate === 'permission' ? ( + + Only workspace admins can create sandboxes. + + ) : ( + <> + setDraft((prev) => ({ ...prev, name }))} + placeholder='bigquery-etl' + maxLength={64} + autoComplete='off' + required + disabled={saving} + /> + + + + setDraft((prev) => ({ ...prev, language: language as SandboxLanguage })) + } + options={LANGUAGE_OPTIONS.map((option) => ({ + label: option.label, + value: option.value, + }))} + disabled={saving} + aria-label='Language' + /> + + + setDraft((prev) => ({ ...prev, dependencies }))} + placeholder={DEPENDENCY_PLACEHOLDERS[draft.language]} + rows={8} + disabled={saving} + hint='One per line. Version pins are optional.' + error={ + issues.length > 0 ? ( + <> + {issues.map((issue) => ( + + Line {issue.line}: {issue.reason} + + ))} + + ) : undefined + } + /> + + {error} + + )} + + + {gate === null && ( + onOpenChange(false)} + cancelDisabled={saving} + primaryAction={{ + label: saving ? 'Creating...' : 'Create', + onClick: () => void handleCreate(), + disabled: saving || draft.name.trim().length === 0, + }} + /> + )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx new file mode 100644 index 0000000000..c6887f2327 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor.tsx @@ -0,0 +1,177 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Chip, ChipDropdown, ChipInput, ChipTextarea, cn } from '@sim/emcn' +import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes' +import { + DEPENDENCY_PLACEHOLDERS, + LANGUAGE_OPTIONS, + type SandboxDraft, + type SandboxLanguage, +} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import type { Sandbox } from '@/hooks/queries/sandboxes' + +interface SandboxEditorProps { + draft: SandboxDraft + onChange: (draft: SandboxDraft) => void + /** Server-reported bad lines, addressed to the row the user typed them on. */ + issues: SandboxDependencyIssue[] + disabled?: boolean + /** Build status row. Absent for an unsaved sandbox and under the runtime strategy. */ + status?: React.ReactNode +} + +export function SandboxEditor({ + draft, + onChange, + issues, + disabled = false, + status, +}: SandboxEditorProps) { + const issuesByLine = useMemo(() => { + const byLine = new Map() + for (const issue of issues) { + if (!byLine.has(issue.line)) byLine.set(issue.line, issue) + } + return byLine + }, [issues]) + + return ( +
+ +
+
+ Name + onChange({ ...draft, name: event.target.value })} + placeholder='bigquery-etl' + disabled={disabled} + maxLength={64} + autoComplete='off' + /> +
+
+ Language + onChange({ ...draft, language: language as SandboxLanguage })} + options={LANGUAGE_OPTIONS.map((option) => ({ + label: option.label, + value: option.value, + }))} + disabled={disabled} + /> +
+
+
+ + +
+ onChange({ ...draft, dependencies: event.target.value })} + placeholder={DEPENDENCY_PLACEHOLDERS[draft.language]} + rows={8} + disabled={disabled} + error={issues.length > 0} + spellCheck={false} + autoComplete='off' + /> +

+ One per line. Version pins are optional. +

+ {issues.length > 0 && ( +
    + {[...issuesByLine.values()].map((issue) => ( +
  • + Line {issue.line}: {issue.reason} +
  • + ))} +
+ )} +
+
+ + {status && {status}} +
+ ) +} + +const STATUS_LABEL: Record = { + ready: 'Ready', + failed: 'Failed', + building: 'Building', + pending: 'Queued', +} + +interface SandboxStatusProps { + sandbox: Sandbox + /** Runtime-strategy deployments have no build to report. */ + strategy: 'prebuilt' | 'runtime' +} + +export function SandboxStatus({ sandbox, strategy }: SandboxStatusProps) { + const [showLog, setShowLog] = useState(false) + + if (strategy === 'runtime') { + return ( +

+ Dependencies install at run time on this deployment, adding roughly 10–30s per execution. + Prebuilt sandboxes require E2B. +

+ ) + } + + const packageCount = sandbox.dependencies.length + + // A sandbox with no packages declares nothing to build, so it has no registry + // row — reporting that as "Queued" would describe a build that will never come. + if (packageCount === 0) { + return ( +

+ No packages yet. Add dependencies above to build this sandbox; until then it runs on the + default image. +

+ ) + } + + const status = sandbox.buildStatus ?? 'pending' + + return ( +
+
+ + {STATUS_LABEL[status]} + + + · {packageCount} {packageCount === 1 ? 'package' : 'packages'} + +
+ + {status === 'failed' && sandbox.errorMessage && ( +
+

{sandbox.errorMessage}

+ {sandbox.errorDetail && ( + <> + setShowLog((open) => !open)}> + {showLog ? 'Hide log' : 'Show log'} + + {showLog && ( +
+                  {sandbox.errorDetail}
+                
+ )} + + )} +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/index.ts new file mode 100644 index 0000000000..51d9aee1e2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/index.ts @@ -0,0 +1 @@ +export { Sandboxes } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx new file mode 100644 index 0000000000..a1ad58c087 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx @@ -0,0 +1,303 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { toast } from '@sim/emcn' +import { ArrowLeft, Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { useParams } from 'next/navigation' +import { useQueryState } from 'nuqs' +import { CodeIcon } from '@/components/icons' +import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' +import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes' +import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + SandboxEditor, + SandboxStatus, +} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-editor' +import { + sandboxIdParam, + sandboxIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/search-params' +import { + draftFromSandbox, + emptyDraft, + extractIssues, + SANDBOX_UPGRADE_DESCRIPTION, + SANDBOX_UPGRADE_TITLE, + type SandboxDraft, + toSubmittedLines, +} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' +import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import { + type Sandbox, + useCreateSandbox, + useDeleteSandbox, + useSandboxes, + useUpdateSandbox, +} from '@/hooks/queries/sandboxes' + +export function Sandboxes() { + const params = useParams() + const workspaceId = params.workspaceId as string + + const [searchTerm, setSearchTerm] = useSettingsSearch() + const [selectedId, setSelectedId] = useQueryState(sandboxIdParam.key, { + ...sandboxIdParam.parser, + ...sandboxIdUrlKeys, + }) + + const { data, isLoading } = useSandboxes(workspaceId) + const createSandbox = useCreateSandbox() + const updateSandbox = useUpdateSandbox() + const deleteSandbox = useDeleteSandbox() + + const permissions = useUserPermissionsContext() + const canAdmin = canMutateWorkspaceSettingsSection('sandboxes', permissions) + + const [draft, setDraft] = useState(null) + const [issues, setIssues] = useState([]) + const [isCreating, setIsCreating] = useState(false) + + // The draft belongs to whatever was open when it was typed. Browser Back + // clears `selectedId` without going through `closeEditor`, so without this the + // next sandbox opened would render — and save — the previous one's edits. + const [draftOwnerId, setDraftOwnerId] = useState(null) + if (draftOwnerId !== selectedId) { + setDraftOwnerId(selectedId) + if (draft) { + setDraft(null) + setIssues([]) + } + // Creating and having one open are mutually exclusive, and history can land on + // a sandbox while create mode is still set — Forward after starting a new one. + // Leaving both on renders an empty "New sandbox" form whose Delete still points + // at the restored sandbox. + if (selectedId) setIsCreating(false) + } + + const sandboxes = data?.sandboxes ?? [] + const strategy = data?.strategy ?? 'prebuilt' + const entitled = data?.entitled ?? false + + // Derived, never duplicated into state: a stale id from an old link resolves to + // nothing and simply falls back to the list. + const selected = selectedId ? (sandboxes.find((s) => s.id === selectedId) ?? null) : null + const baseline = isCreating ? null : selected + const original = useMemo(() => (baseline ? draftFromSandbox(baseline) : emptyDraft()), [baseline]) + const current = draft ?? original + const isEditing = isCreating || Boolean(selected) + const isDirty = + isEditing && + (isCreating + ? current.name.trim().length > 0 || current.dependencies.trim().length > 0 + : current.name !== original.name || + current.language !== original.language || + current.dependencies !== original.dependencies) + + // Called before every early return — a hook after a gate is skipped on gated renders. + const guard = useSettingsUnsavedGuard({ isDirty }) + + const closeEditor = useCallback(() => { + setDraft(null) + setIssues([]) + setIsCreating(false) + // Opening pushed a history entry; closing must not push another. + void setSelectedId(null, { history: 'replace' }) + }, [setSelectedId]) + + const handleSave = useCallback(async () => { + setIssues([]) + const body = { + name: current.name.trim(), + language: current.language, + dependencies: toSubmittedLines(current.dependencies), + } + try { + if (isCreating) { + await createSandbox.mutateAsync({ workspaceId, ...body }) + } else if (selected) { + await updateSandbox.mutateAsync({ workspaceId, sandboxId: selected.id, ...body }) + } + closeEditor() + } catch (error) { + const lineIssues = extractIssues(error) + if (lineIssues.length > 0) { + setIssues(lineIssues) + return + } + toast.error(getErrorMessage(error, 'Failed to save sandbox')) + } + }, [current, isCreating, selected, workspaceId, createSandbox, updateSandbox, closeEditor]) + + const handleDelete = useCallback( + async (sandbox: Sandbox) => { + try { + await deleteSandbox.mutateAsync({ workspaceId, sandboxId: sandbox.id }) + if (selectedId === sandbox.id) closeEditor() + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to delete sandbox')) + } + }, + [deleteSandbox, workspaceId, selectedId, closeEditor] + ) + + const filtered = useMemo(() => { + const query = searchTerm.trim().toLowerCase() + if (!query) return sandboxes + return sandboxes.filter( + (sandbox) => + sandbox.name.toLowerCase().includes(query) || + sandbox.dependencies.some((dependency) => dependency.toLowerCase().includes(query)) + ) + }, [sandboxes, searchTerm]) + + if (isLoading) { + return ( + + Loading sandboxes... + + ) + } + + if (!entitled) { + return ( + + + + ) + } + + if (isEditing) { + const saving = createSandbox.isPending || updateSandbox.isPending + return ( + <> + guard.guardBack(closeEditor), + }} + actions={[ + ...saveDiscardActions({ + dirty: isDirty, + saving, + onSave: () => void handleSave(), + onDiscard: () => { + setDraft(null) + setIssues([]) + }, + saveDisabled: !canAdmin || current.name.trim().length === 0, + }), + ...(selected && canAdmin + ? [ + { + text: 'Delete', + textTone: 'error' as const, + onSelect: () => void handleDelete(selected), + disabled: deleteSandbox.isPending, + }, + ] + : []), + ]} + > + : undefined} + /> + + + + + ) + } + + return ( + { + setDraft(emptyDraft()) + setIsCreating(true) + // Starting a new one is not editing the open one. Replace rather + // than push: this is a mode switch, not a destination. + void setSelectedId(null, { history: 'replace' }) + }, + }, + ] + : [] + } + > + + {filtered.length === 0 ? ( + + {searchTerm + ? 'No sandboxes match your search.' + : 'No sandboxes yet. Create one to let Function blocks import packages.'} + + ) : ( +
+ {filtered.map((sandbox) => ( + } + title={ + + } + description={`${sandbox.language === 'python' ? 'Python' : 'JavaScript'} · ${sandbox.dependencies.length} ${sandbox.dependencies.length === 1 ? 'package' : 'packages'}`} + trailing={ + canAdmin ? ( + void setSelectedId(sandbox.id) }, + { + label: 'Delete', + destructive: true, + onSelect: () => void handleDelete(sandbox), + }, + ]} + /> + ) : undefined + } + /> + ))} +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/search-params.ts new file mode 100644 index 0000000000..a2e2b8cb87 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/search-params.ts @@ -0,0 +1,17 @@ +import { parseAsString } from 'nuqs/server' + +/** + * The sandbox open in the editor. Only the id lives in the URL; the object is + * derived from the already-loaded list, so a stale id from an old link simply + * falls back to the list rather than rendering a broken detail view. + */ +export const sandboxIdParam = { + key: 'sandboxId', + parser: parseAsString, +} as const + +/** Opening a sandbox is a destination — Back should close it. */ +export const sandboxIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.test.ts new file mode 100644 index 0000000000..41c3fae6cf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + emptyDraft, + extractIssues, + LANGUAGE_OPTIONS, + toSubmittedLines, +} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' +import { FunctionBlock } from '@/blocks/blocks/function' + +const languageField = FunctionBlock.subBlocks.find((subBlock) => subBlock.id === 'language') +const sandboxField = FunctionBlock.subBlocks.find((subBlock) => subBlock.id === 'sandboxId') + +describe('sandbox draft defaults', () => { + it('starts a new sandbox in the language the Function block itself defaults to', () => { + const blockDefault = typeof languageField?.value === 'function' ? languageField.value() : null + expect(blockDefault).toBe('javascript') + expect(emptyDraft().language).toBe(blockDefault) + }) + + it('offers languages in the Function block dropdown order', () => { + expect(LANGUAGE_OPTIONS.map((option) => option.value)).toEqual( + languageField?.options?.map((option) => (typeof option === 'string' ? option : option.id)) + ) + }) + + it('starts empty so nothing is submitted by accident', () => { + expect(emptyDraft()).toEqual({ name: '', language: 'javascript', dependencies: '' }) + }) +}) + +describe('sandbox picker create action', () => { + it('declares the inline create row the picker renders', () => { + expect(sandboxField?.createAction).toBe('sandbox') + }) +}) + +describe('toSubmittedLines', () => { + it('keeps blank rows so a rejection can address the line the user typed on', () => { + expect(toSubmittedLines('axios\n\nzod')).toEqual(['axios', '', 'zod']) + }) +}) + +describe('extractIssues', () => { + it('reads the per-line rejections off a failed save', () => { + const error = { body: { issues: [{ line: 2, reason: 'not a package name' }] } } + expect(extractIssues(error)).toEqual([{ line: 2, reason: 'not a package name' }]) + }) + + it('returns nothing for an error that carries no issues', () => { + expect(extractIssues(new Error('network'))).toEqual([]) + expect(extractIssues({ body: { issues: 'nope' } })).toEqual([]) + expect(extractIssues(undefined)).toEqual([]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts new file mode 100644 index 0000000000..ae5c909621 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/utils.ts @@ -0,0 +1,58 @@ +import type { Sandbox, SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes' + +export type SandboxLanguage = Sandbox['language'] + +/** Shared by the settings page and the picker's create modal so the wall reads identically. */ +export const SANDBOX_UPGRADE_TITLE = 'Sandboxes require an active Max plan' +export const SANDBOX_UPGRADE_DESCRIPTION = + 'Upgrade to Max and ensure billing is active to install Python or npm packages that your Function blocks can import.' + +/** Ordered to match the Function block's own `language` dropdown. */ +export const LANGUAGE_OPTIONS = [ + { label: 'JavaScript', value: 'javascript' }, + { label: 'Python', value: 'python' }, +] as const + +/** + * Placeholders double as the format documentation, so they swap with the + * language rather than describing one syntax for both. + */ +export const DEPENDENCY_PLACEHOLDERS: Record = { + python: 'google-cloud-bigquery==3.25.0\npyairtable>=3.0\npandas', + javascript: 'axios@^1.7.0\n@aws-sdk/client-s3\nzod', +} + +export interface SandboxDraft { + name: string + language: SandboxLanguage + /** Raw textarea contents — one dependency per line, comments allowed. */ + dependencies: string +} + +export function draftFromSandbox(sandbox: Sandbox): SandboxDraft { + return { + name: sandbox.name, + language: sandbox.language, + dependencies: sandbox.dependencies.join('\n'), + } +} + +/** Defaults to the Function block's own default language so the two agree. */ +export function emptyDraft(): SandboxDraft { + return { name: '', language: 'javascript', dependencies: '' } +} + +/** Splits the textarea into one entry per row so a rejection keeps its line number. */ +export function toSubmittedLines(dependencies: string): string[] { + return dependencies.split('\n') +} + +/** + * Pulls the per-line rejections off a failed save. The server addresses each one + * to the row the user typed it on, so they are surfaced against the textarea + * rather than as a single opaque error. + */ +export function extractIssues(error: unknown): SandboxDependencyIssue[] { + const issues = (error as { body?: { issues?: SandboxDependencyIssue[] } })?.body?.issues + return Array.isArray(issues) ? issues : [] +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/index.ts new file mode 100644 index 0000000000..89c77a89ac --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/index.ts @@ -0,0 +1 @@ +export { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/settings-upgrade-notice' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/settings-upgrade-notice.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/settings-upgrade-notice.tsx new file mode 100644 index 0000000000..591e1bd8ef --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice/settings-upgrade-notice.tsx @@ -0,0 +1,58 @@ +'use client' + +import { Chip, cn } from '@sim/emcn' +import { ArrowRight } from '@sim/emcn/icons' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' + +interface SettingsUpgradeNoticeProps { + /** Names the gated surface, e.g. `Sandboxes require an active Max plan`. */ + title: string + /** One sentence on what the plan unlocks. */ + description: string + /** + * Whether to offer the upgrade action. Members who cannot act on it are shown + * the reason without a button that would only dead-end them. + */ + canUpgrade?: boolean + /** + * Tightens the vertical rhythm for a modal, where the full-height centering a + * settings page wants would leave the dialog mostly empty. + */ + compact?: boolean +} + +/** + * Canonical wall for a surface gated behind the Max plan. Owns the copy rhythm + * and the route to upgrade, so every gated section reads and behaves the same. + * + * The action lands on billing, which redirects a member who cannot manage + * billing to the plan-comparison page instead — so it is never a dead end. + */ +export function SettingsUpgradeNotice({ + title, + description, + canUpgrade = false, + compact = false, +}: SettingsUpgradeNoticeProps) { + const { navigateToSettings } = useSettingsNavigation() + + return ( +
+
+

{title}

+

{description}

+
+ {canUpgrade && ( + navigateToSettings({ section: 'billing' })} + > + Upgrade to Max + + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index d24163419a..5c2a5bfe3e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -1,4 +1,16 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +/** + * The Sandboxes section is dropped when no sandbox provider is configured, and + * `allNavigationItems` is built once at module load — so this has to be set before + * the module graph is imported, where a `beforeEach` would run too late. Pinning it + * also keeps the catalog assertions off the developer's untracked `apps/sim/.env`, + * which CI does not have. + */ +vi.hoisted(() => { + process.env.NEXT_PUBLIC_SANDBOX_ENABLED = 'true' +}) + import { SETTINGS_SECTION_REGISTRY, WORKSPACE_SETTINGS_ITEMS, @@ -39,6 +51,7 @@ describe('unified settings navigation', () => { { id: 'apikeys', label: 'Sim API keys', section: 'system' }, { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'system' }, { id: 'byok', label: 'BYOK', section: 'system' }, + { id: 'sandboxes', label: 'Sandboxes', section: 'system' }, { id: 'inbox', label: 'Sim mailer', section: 'system' }, { id: 'recently-deleted', label: 'Recently deleted', section: 'system' }, { id: 'sso', label: 'Single sign-on', section: 'enterprise' }, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx index bc3972c4b9..a0d18f4d63 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx @@ -321,12 +321,14 @@ export const Code = memo(function Code({ }, [wandConfig, languageValue]) const [tableIdValue] = useSubBlockValue(blockId, 'tableId') + const [sandboxIdValue] = useSubBlockValue(blockId, 'sandboxId') const wandHook = useWand({ wandConfig: dynamicWandConfig || { enabled: false, prompt: '' }, currentValue: code, contextParams: { tableId: typeof tableIdValue === 'string' ? tableIdValue : null, + sandboxId: typeof sandboxIdValue === 'string' ? sandboxIdValue : null, }, onStreamStart: () => handleStreamStartRef.current?.(), onStreamChunk: (chunk: string) => handleStreamChunkRef.current?.(chunk), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index b2856d1001..a244207036 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -1,24 +1,21 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Combobox, type ComboboxOption, cn } from '@sim/emcn' -import { getErrorMessage } from '@sim/utils/errors' -import { isEqual } from 'es-toolkit' +import { Plus } from '@sim/emcn/icons' import { useReactFlow } from 'reactflow' -import { useStoreWithEqualityFn } from 'zustand/traditional' -import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' +import { SandboxCreateModal } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-create-modal' +import type { SandboxLanguage } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' +import { useFetchedOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' -import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { usePermissionConfig } from '@/hooks/use-permission-config' import { getProviderFromModel } from '@/providers/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** * Constants for ComboBox component behavior @@ -29,6 +26,17 @@ const MIN_ZOOM = 0.1 const MAX_ZOOM = 1 const ZOOM_DURATION = 0 +const CREATE_ACTION_LABEL: Record, string> = { + sandbox: 'Create Sandbox', +} + +/** + * Reserved value for the pinned create row. It can never collide with a stored + * value: emcn short-circuits on the option's `onSelect`, so the row never + * reaches `onChange`. + */ +const CREATE_ACTION_VALUE = '__sub-block-create-action__' + /** * Represents a selectable option in the combobox */ @@ -94,56 +102,6 @@ export const ComboBox = memo(function ComboBox({ // Dependency tracking for fetchOptions const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn]) - const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) - const blockState = useWorkflowStore((state) => state.blocks[blockId]) - const blockConfig = blockState?.type ? getBlock(blockState.type) : null - const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] - ) - const canonicalModeOverrides = blockState?.data?.canonicalModes - const dependencyValues = useStoreWithEqualityFn( - useSubBlockStore, - useCallback( - (state) => { - if (dependsOnFields.length === 0 || !activeWorkflowId) return [] - const workflowValues = state.workflowValues[activeWorkflowId] || {} - const blockValues = workflowValues[blockId] || {} - return dependsOnFields.map((depKey) => - resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides) - ) - }, - [dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides] - ), - isEqual - ) - - // State management - const [fetchedOptions, setFetchedOptions] = useState>([]) - const [isLoadingOptions, setIsLoadingOptions] = useState(false) - const [fetchError, setFetchError] = useState(null) - const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) - const previousDependencyValuesRef = useRef('') - - /** - * Fetches options from the async fetchOptions function if provided - */ - const fetchOptionsIfNeeded = useCallback(async () => { - if (!fetchOptions || isPreview || disabled) return - - setIsLoadingOptions(true) - setFetchError(null) - try { - const options = await fetchOptions(blockId) - setFetchedOptions(options) - } catch (error) { - const errorMessage = getErrorMessage(error, 'Failed to fetch options') - setFetchError(errorMessage) - setFetchedOptions([]) - } finally { - setIsLoadingOptions(false) - } - }, [fetchOptions, blockId, isPreview, disabled]) // Determine the active value based on mode (preview vs. controlled vs. store) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue @@ -174,6 +132,47 @@ export const ComboBox = memo(function ComboBox({ return opts }, [options, subBlockId, isProviderAllowed, isModelAllowed]) + const { + fetchedOptions, + isLoadingOptions, + fetchError, + hydratedOption, + refetch: refetchOptions, + } = useFetchedOptions({ + blockId, + dependsOnFields, + fetchOptions, + fetchOptionById, + isPreview: Boolean(isPreview), + disabled: Boolean(disabled), + valueToHydrate: value as string | null | undefined, + localOptions: staticOptions, + }) + + const [isCreateOpen, setIsCreateOpen] = useState(false) + const [createLanguage, setCreateLanguage] = useState(undefined) + const [createdOption, setCreatedOption] = useState<{ label: string; id: string } | null>(null) + + /** + * The pinned "create a new one" row, when the field declares one. Seeded from + * the sibling the list is scoped by, so a sandbox created off a JavaScript + * block does not land in the Python list and vanish. + */ + const createOption = useMemo((): ComboboxOption | null => { + const action = config.createAction + if (!action || isPreview || disabled) return null + return { + label: CREATE_ACTION_LABEL[action], + value: CREATE_ACTION_VALUE, + icon: Plus, + onSelect: () => { + const language = useSubBlockStore.getState().getValue(blockId, 'language') + setCreateLanguage(language === 'python' || language === 'javascript' ? language : undefined) + setIsCreateOpen(true) + }, + } + }, [config.createAction, isPreview, disabled, blockId]) + // Normalize fetched options to match ComboBoxOption format const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => { return fetchedOptions.map((opt) => ({ label: opt.label, id: opt.id })) @@ -206,12 +205,25 @@ export const ComboBox = memo(function ComboBox({ } } + // Something just created through the pinned create row is selected before any + // list has refetched, so without this the field would sit on the raw id until + // hydration answered. Dropped again the moment a real fetch carries it. + if (createdOption) { + const alreadyPresent = opts.some((o) => + typeof o === 'string' ? o === createdOption.id : o.id === createdOption.id + ) + if (!alreadyPresent) { + opts = [createdOption, ...opts] + } + } + return opts }, [ fetchOptions, normalizedFetchedOptions, staticOptions, hydratedOption, + createdOption, subBlockId, isProviderAllowed, isModelAllowed, @@ -219,13 +231,14 @@ export const ComboBox = memo(function ComboBox({ // Convert options to Combobox format const comboboxOptions = useMemo((): ComboboxOption[] => { - return evaluatedOptions.map((option) => { + const mapped = evaluatedOptions.map((option): ComboboxOption => { if (typeof option === 'string') { return { label: option, value: option } } return { label: option.label, value: option.id, icon: option.icon } }) - }, [evaluatedOptions]) + return createOption ? [createOption, ...mapped] : mapped + }, [evaluatedOptions, createOption]) /** * Extracts the value identifier from an option @@ -260,12 +273,20 @@ export const ComboBox = memo(function ComboBox({ } } + // Auto-selecting the first option is only right for a field that must hold + // something. When empty is a real, documented choice (`sandboxId` — "no extra + // packages"), pre-filling it silently mutates and persists the block the + // moment the user opens advanced options. + if (config.emptyIsValid) { + return undefined + } + if (evaluatedOptions.length > 0) { return getOptionValue(evaluatedOptions[0]) } return undefined - }, [defaultValue, evaluatedOptions, subBlockId, getOptionValue]) + }, [defaultValue, evaluatedOptions, subBlockId, getOptionValue, config.emptyIsValid]) /** * Resolve the user-facing text for the current stored value. @@ -302,93 +323,6 @@ export const ComboBox = memo(function ComboBox({ } }, [value, defaultOptionValue, setStoreValue, isPermissionLoading]) - // Clear fetched options and hydrated option when dependencies change - useEffect(() => { - if (fetchOptions && dependsOnFields.length > 0) { - const currentDependencyValuesStr = JSON.stringify(dependencyValues) - const previousDependencyValuesStr = previousDependencyValuesRef.current - - if ( - previousDependencyValuesStr && - currentDependencyValuesStr !== previousDependencyValuesStr - ) { - setFetchedOptions([]) - setHydratedOption(null) - } - - previousDependencyValuesRef.current = currentDependencyValuesStr - } - }, [dependencyValues, fetchOptions, dependsOnFields.length]) - - // Fetch options when needed (on mount, when enabled, or when dependencies change) - useEffect(() => { - if ( - fetchOptions && - !isPreview && - !disabled && - fetchedOptions.length === 0 && - !isLoadingOptions && - !fetchError - ) { - fetchOptionsIfNeeded() - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above - }, [ - fetchOptions, - isPreview, - disabled, - fetchedOptions.length, - isLoadingOptions, - fetchError, - dependencyValues, - ]) - - // Hydrate the stored value's label by fetching it individually - useEffect(() => { - if (!fetchOptionById || isPreview || disabled) return - - const valueToHydrate = value as string | null | undefined - if (!valueToHydrate) return - - // Skip if value is an expression (not a real ID) - if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return - - // Skip if already hydrated with the same value - if (hydratedOption?.id === valueToHydrate) return - - // Skip if value is already in fetched options or static options - const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate) - const alreadyInStaticOptions = staticOptions.some((opt) => - typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate - ) - if (alreadyInFetchedOptions || alreadyInStaticOptions) return - - // Track if effect is still active (cleanup on unmount or value change) - let isActive = true - - // Fetch the hydrated option - fetchOptionById(blockId, valueToHydrate) - .then((option) => { - if (isActive) setHydratedOption(option) - }) - .catch(() => { - if (isActive) setHydratedOption(null) - }) - - return () => { - isActive = false - } - }, [ - fetchOptionById, - value, - blockId, - isPreview, - disabled, - fetchedOptions, - staticOptions, - hydratedOption?.id, - ]) - /** * Handles wheel event for ReactFlow zoom control * Intercepts Ctrl/Cmd+Wheel to zoom the canvas @@ -434,10 +368,10 @@ export const ComboBox = memo(function ComboBox({ const handleOpenChange = useCallback( (open: boolean) => { if (open) { - void fetchOptionsIfNeeded() + refetchOptions() } }, - [fetchOptionsIfNeeded] + [refetchOptions] ) /** @@ -590,6 +524,18 @@ export const ComboBox = memo(function ComboBox({ ) }} + + {config.createAction === 'sandbox' && ( + { + setCreatedOption({ label: sandbox.name, id: sandbox.id }) + setStoreValue(sandbox.id) + }} + /> + )} ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index d4f831d559..cf50fe1673 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -1,13 +1,10 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { ChipTag, Combobox, type ComboboxOption } from '@sim/emcn' -import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' -import { isEqual } from 'es-toolkit' -import { useStoreWithEqualityFn } from 'zustand/traditional' -import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' +import { useFetchedOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { getBlock } from '@/blocks/registry' @@ -15,8 +12,6 @@ import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' import { usePermissionConfig } from '@/hooks/use-permission-config' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** Selected-value badges shown before folding the rest into a "+N" badge. */ @@ -107,37 +102,10 @@ export const Dropdown = memo(function Dropdown({ const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn]) - const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) - const blockState = useWorkflowStore((state) => state.blocks[blockId]) - const blockConfig = blockState?.type ? getBlock(blockState.type) : null - const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] - ) - const canonicalModeOverrides = blockState?.data?.canonicalModes - const dependencyValues = useStoreWithEqualityFn( - useSubBlockStore, - useCallback( - (state) => { - if (dependsOnFields.length === 0 || !activeWorkflowId) return [] - const workflowValues = state.workflowValues[activeWorkflowId] || {} - const blockValues = workflowValues[blockId] || {} - return dependsOnFields.map((depKey) => - resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides) - ) - }, - [dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides] - ), - isEqual - ) - - const [fetchedOptions, setFetchedOptions] = useState>([]) - const [isLoadingOptions, setIsLoadingOptions] = useState(false) - const [fetchError, setFetchError] = useState(null) - const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) + const blockType = useWorkflowStore((state) => state.blocks[blockId]?.type) + const blockConfig = blockType ? getBlock(blockType) : null const previousModeRef = useRef(null) - const previousDependencyValuesRef = useRef('') const [builderData, setBuilderData] = useSubBlockValue(blockId, 'builderData') const [data, setData] = useSubBlockValue(blockId, 'data') @@ -161,22 +129,26 @@ export const Dropdown = memo(function Dropdown({ : [] : null - const fetchOptionsIfNeeded = useCallback(async () => { - if (!fetchOptions || isPreview || disabled) return + const evaluatedOptions = useMemo(() => { + return typeof options === 'function' ? options() : options + }, [options]) - setIsLoadingOptions(true) - setFetchError(null) - try { - const options = await fetchOptions(blockId) - setFetchedOptions(options) - } catch (error) { - const errorMessage = getErrorMessage(error, 'Failed to fetch options') - setFetchError(errorMessage) - setFetchedOptions([]) - } finally { - setIsLoadingOptions(false) - } - }, [fetchOptions, blockId, isPreview, disabled]) + const { + fetchedOptions, + isLoadingOptions, + fetchError, + hydratedOption, + refetch: refetchOptions, + } = useFetchedOptions({ + blockId, + dependsOnFields, + fetchOptions, + fetchOptionById, + isPreview: Boolean(isPreview), + disabled: Boolean(disabled), + valueToHydrate: singleValue, + localOptions: evaluatedOptions, + }) /** * Handles combobox open state changes to trigger option fetching @@ -184,16 +156,12 @@ export const Dropdown = memo(function Dropdown({ const handleOpenChange = useCallback( (open: boolean) => { if (open) { - void fetchOptionsIfNeeded() + refetchOptions() } }, - [fetchOptionsIfNeeded] + [refetchOptions] ) - const evaluatedOptions = useMemo(() => { - return typeof options === 'function' ? options() : options - }, [options]) - const normalizedFetchedOptions = useMemo(() => { return fetchedOptions.map((opt) => ({ label: opt.label, id: opt.id })) }, [fetchedOptions]) @@ -388,103 +356,6 @@ export const Dropdown = memo(function Dropdown({ [isPreview, disabled, setStoreValue] ) - /** - * Effect to clear fetched options and hydrated option when dependencies actually change - * This ensures options are refetched with new dependency values (e.g., new credentials) - */ - useEffect(() => { - if (fetchOptions && dependsOnFields.length > 0) { - const currentDependencyValuesStr = JSON.stringify(dependencyValues) - const previousDependencyValuesStr = previousDependencyValuesRef.current - - if ( - previousDependencyValuesStr && - currentDependencyValuesStr !== previousDependencyValuesStr - ) { - setFetchedOptions([]) - setHydratedOption(null) - } - - previousDependencyValuesRef.current = currentDependencyValuesStr - } - }, [dependencyValues, fetchOptions, dependsOnFields.length]) - - /** - * Effect to fetch options when needed (on mount, when enabled, or when dependencies change) - */ - useEffect(() => { - if ( - fetchOptions && - !isPreview && - !disabled && - fetchedOptions.length === 0 && - !isLoadingOptions && - !fetchError - ) { - fetchOptionsIfNeeded() - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above - }, [ - fetchOptions, - isPreview, - disabled, - fetchedOptions.length, - isLoadingOptions, - fetchError, - dependencyValues, - ]) - - /** - * Effect to hydrate the stored value's label by fetching it individually - * This ensures the correct label is shown before the full options list loads - */ - useEffect(() => { - if (!fetchOptionById || isPreview || disabled) return - - // Get the value to hydrate (single value only, not multi-select) - const valueToHydrate = multiSelect ? null : (singleValue as string | null | undefined) - if (!valueToHydrate) return - - // Skip if value is an expression (not a real ID) - if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return - - // Skip if already hydrated with the same value - if (hydratedOption?.id === valueToHydrate) return - - // Skip if value is already in fetched options or static options - const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate) - const alreadyInStaticOptions = evaluatedOptions.some((opt) => - typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate - ) - if (alreadyInFetchedOptions || alreadyInStaticOptions) return - - // Track if effect is still active (cleanup on unmount or value change) - let isActive = true - - // Fetch the hydrated option - fetchOptionById(blockId, valueToHydrate) - .then((option) => { - if (isActive) setHydratedOption(option) - }) - .catch(() => { - if (isActive) setHydratedOption(null) - }) - - return () => { - isActive = false - } - }, [ - fetchOptionById, - singleValue, - multiSelect, - blockId, - isPreview, - disabled, - fetchedOptions, - evaluatedOptions, - hydratedOption?.id, - ]) - /** * Custom overlay content for multi-select mode. Shows at most two badges * and folds the rest into a "+N" badge, matching the summary notation used diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx index 00798f4a97..20492c4127 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -37,6 +37,16 @@ interface ToolSubBlockRendererProps { */ const OBJECT_SUBBLOCK_TYPES = new Set(['file-upload', 'table', 'grouped-checkbox-list']) +/** + * Whether this subblock's store value is a non-string. Covers the always-object + * types above plus any `multiSelect` control, whose value is an array — without + * this a multi-select's JSON string is rendered as a single literal chip and the + * next edit persists a nested-encoded value. + */ +function holdsObjectValue(subBlock: { type: string; multiSelect?: boolean }): boolean { + return OBJECT_SUBBLOCK_TYPES.has(subBlock.type) || Boolean(subBlock.multiSelect) +} + /** * Bridges the subblock store with StoredTool.params via a synthetic store key, * then delegates all rendering to SubBlock for full parity. @@ -55,7 +65,7 @@ export function ToolSubBlockRenderer({ }: ToolSubBlockRendererProps) { const syntheticId = buildToolSubBlockId(subBlockId, toolIndex, effectiveParamId) const toolParamValue = toolParams?.[effectiveParamId] ?? '' - const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type) + const isObjectType = holdsObjectValue(subBlock) const syncedRef = useRef(null) const onParamChangeRef = useRef(onParamChange) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts new file mode 100644 index 0000000000..2fd69a61ca --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { isEqual } from 'es-toolkit' +import { useStoreWithEqualityFn } from 'zustand/traditional' +import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks/registry' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +export interface FetchedOption { + label: string + id: string +} + +/** An option the control already knows about, static or previously fetched. */ +type LocalOption = string | { id: string } + +interface UseFetchedOptionsProps { + blockId: string + /** Sibling subblock ids this list is scoped by; a change refetches. */ + dependsOnFields: string[] + fetchOptions?: (blockId: string) => Promise + fetchOptionById?: (blockId: string, optionId: string) => Promise + isPreview: boolean + disabled: boolean + /** + * The stored value whose label needs resolving before the full list loads. + * Multi-select controls pass `null` — there is no single label to hydrate. + */ + valueToHydrate: string | null | undefined + /** Options already resolvable without a fetch, so hydration can skip one. */ + localOptions: readonly LocalOption[] +} + +export interface UseFetchedOptionsResult { + fetchedOptions: FetchedOption[] + isLoadingOptions: boolean + fetchError: string | null + hydratedOption: FetchedOption | null + /** Fetches now, bypassing the once-per-dependency-set guard. For open handlers. */ + refetch: () => void +} + +function hasLocalOption(options: readonly LocalOption[], id: string): boolean { + return options.some((option) => (typeof option === 'string' ? option === id : option.id === id)) +} + +/** + * Owns the async-option lifecycle shared by the Dropdown and ComboBox subblock + * controls: fetching the list, clearing and refetching it when the fields it + * depends on change, and hydrating a stored value's label before the list loads. + * + * This exists as one hook because the two controls previously carried the same + * ~115 lines twice and drifted: a fix that added a `hasFetched` guard to both + * added the matching reset to only one, leaving every dependent Dropdown unable + * to refetch after its dependency changed. + */ +export function useFetchedOptions({ + blockId, + dependsOnFields, + fetchOptions, + fetchOptionById, + isPreview, + disabled, + valueToHydrate, + localOptions, +}: UseFetchedOptionsProps): UseFetchedOptionsResult { + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const blockState = useWorkflowStore((state) => state.blocks[blockId]) + const blockConfig = blockState?.type ? getBlock(blockState.type) : null + const canonicalModeOverrides = blockState?.data?.canonicalModes + const canonicalIndex = useMemo( + () => buildCanonicalIndex(blockConfig?.subBlocks || []), + [blockConfig?.subBlocks] + ) + + const dependencyValues = useStoreWithEqualityFn( + useSubBlockStore, + useCallback( + (state) => { + if (dependsOnFields.length === 0 || !activeWorkflowId) return [] + const workflowValues = state.workflowValues[activeWorkflowId] || {} + const blockValues = workflowValues[blockId] || {} + return dependsOnFields.map((depKey) => + resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides) + ) + }, + [dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides] + ), + isEqual + ) + + const [fetchedOptions, setFetchedOptions] = useState([]) + const [isLoadingOptions, setIsLoadingOptions] = useState(false) + const [fetchError, setFetchError] = useState(null) + const [hydratedOption, setHydratedOption] = useState(null) + + const previousDependencyValuesRef = useRef('') + /** + * Whether a fetch has already been attempted for the current dependency values. + * "Have we fetched?" cannot be inferred from `fetchedOptions.length === 0` — a + * fetcher that legitimately returns no options (a workspace with no sandboxes, + * no credential selected) leaves the length at 0 while the loading flag flips + * back to false, re-satisfying the effect's guards and spinning it forever. + */ + const hasFetchedRef = useRef(false) + + const runFetch = useCallback(async () => { + if (!fetchOptions || isPreview || disabled) return + + setIsLoadingOptions(true) + setFetchError(null) + try { + const options = await fetchOptions(blockId) + setFetchedOptions(options) + } catch (error) { + setFetchError(getErrorMessage(error, 'Failed to fetch options')) + setFetchedOptions([]) + } finally { + setIsLoadingOptions(false) + } + }, [fetchOptions, blockId, isPreview, disabled]) + + useEffect(() => { + if (!fetchOptions || dependsOnFields.length === 0) return + + const current = JSON.stringify(dependencyValues) + const previous = previousDependencyValuesRef.current + if (previous && current !== previous) { + setFetchedOptions([]) + setHydratedOption(null) + // Both flags are what gate the fetch effect below, so both have to clear + // with the list: a stale error would block every future refetch, and a + // stale `hasFetched` would stop the new dependency values ever loading. + setFetchError(null) + hasFetchedRef.current = false + } + previousDependencyValuesRef.current = current + }, [dependencyValues, fetchOptions, dependsOnFields.length]) + + useEffect(() => { + if ( + fetchOptions && + !isPreview && + !disabled && + !hasFetchedRef.current && + !isLoadingOptions && + !fetchError + ) { + hasFetchedRef.current = true + void runFetch() + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- runFetch deps already covered above + }, [fetchOptions, isPreview, disabled, isLoadingOptions, fetchError, dependencyValues]) + + useEffect(() => { + if (!fetchOptionById || isPreview || disabled) return + if (!valueToHydrate) return + + // An expression rather than a real id — there is nothing to look up. + if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return + + if (hydratedOption?.id === valueToHydrate) return + if (hasLocalOption(fetchedOptions, valueToHydrate)) return + if (hasLocalOption(localOptions, valueToHydrate)) return + + let isActive = true + fetchOptionById(blockId, valueToHydrate) + .then((option) => { + if (isActive) setHydratedOption(option) + }) + .catch(() => { + if (isActive) setHydratedOption(null) + }) + + return () => { + isActive = false + } + }, [ + fetchOptionById, + valueToHydrate, + blockId, + isPreview, + disabled, + fetchedOptions, + localOptions, + hydratedOption?.id, + ]) + + const refetch = useCallback(() => { + hasFetchedRef.current = true + void runFetch() + }, [runFetch]) + + return { fetchedOptions, isLoadingOptions, fetchError, hydratedOption, refetch } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts index 9c4a0a0921..6ca9b69470 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts @@ -6,6 +6,7 @@ import { isSubBlockHidden, isSubBlockVisibleForMode, isSubBlockVisibleForTriggerMode, + isToolInputOnlySubBlock, shouldUseSubBlockForTriggerModeCanonicalIndex, } from '@/lib/workflows/subblocks/visibility' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' @@ -117,6 +118,9 @@ export function useEditorSubblockLayout( const visibleSubBlocks = (config.subBlocks || []).filter((block) => { if (block.hidden) return false + // Configures the block as an agent tool; it has no meaning on the canvas. + if (isToolInputOnlySubBlock(block)) return false + // Filter by reactive condition (evaluated via hooks before useMemo) if (hiddenByReactiveCondition.has(block.id)) return false diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 4794bd8212..7166864328 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -17,6 +17,7 @@ import { getDisplayValue, resolveDropdownLabel, resolveFilterFieldLabel, + resolveSandboxLabel, resolveSkillsLabel, resolveToolsLabel, resolveVariablesLabel, @@ -30,6 +31,7 @@ import { isSubBlockFeatureEnabled, isSubBlockHidden, isSubBlockVisibleForMode, + isToolInputOnlySubBlock, isTriggerModeSubBlock, resolveDependencyValue, } from '@/lib/workflows/subblocks/visibility' @@ -60,6 +62,7 @@ import { useCustomTools } from '@/hooks/queries/custom-tools' import { useDeployWorkflow } from '@/hooks/queries/deployments' import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp' import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials' +import { useSandboxes } from '@/hooks/queries/sandboxes' import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules' import { useSkills } from '@/hooks/queries/skills' import { useTablesList } from '@/hooks/queries/tables' @@ -446,6 +449,19 @@ const SubBlockRow = memo(function SubBlockRow({ [subBlock, rawValue, workspaceSkills] ) + /** + * Hydrates the Function block's sandbox id to its name. Deliberately scoped to + * the sandbox row: this row is memoized per subblock, and the shared list query + * polls while a build is in flight, so subscribing unconditionally would + * re-render every row on the canvas on each poll tick. + */ + const isSandboxField = subBlock?.id === 'sandboxId' && subBlock?.type === 'combobox' + const { data: sandboxData } = useSandboxes(isSandboxField ? workspaceId || undefined : undefined) + const sandboxDisplayValue = useMemo( + () => resolveSandboxLabel(subBlock, rawValue, sandboxData?.sandboxes ?? []), + [subBlock, rawValue, sandboxData] + ) + const isPasswordField = subBlock?.password === true const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null const isMonospaceField = Boolean(filterDisplayValue) @@ -458,6 +474,7 @@ const SubBlockRow = memo(function SubBlockRow({ filterDisplayValue || toolsDisplayValue || skillsDisplayValue || + sandboxDisplayValue || knowledgeBaseDisplayName || workflowSelectionName || mcpServerDisplayName || @@ -628,6 +645,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({ if (block.hideFromPreview) return false if (hiddenByReactiveCondition.has(block.id)) return false if (!isSubBlockFeatureEnabled(block)) return false + + // Configures the block as an agent tool; it has no meaning on the canvas. + if (isToolInputOnlySubBlock(block)) return false if (isSubBlockHidden(block)) return false const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index dc6bd65b33..34c0b712ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from 'react' import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' +import { filterUndefined } from '@sim/utils/object' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' import { requestRaw } from '@/lib/api/client' @@ -76,12 +77,29 @@ export interface WandConfig { maintainHistory?: boolean // Whether to keep conversation history } +/** + * Client-supplied context the server's `wandEnrichers` expand into extra system + * prompt. Sent as ids only — the enricher does the DB/registry lookup, so no + * schema or package metadata has to round-trip through the browser. + */ +interface WandContextParams { + tableId?: string | null + sandboxId?: string | null +} + +/** Drops the unset keys so an all-empty context is omitted from the request. */ +function buildWandContext(params?: WandContextParams): Record | undefined { + const context = filterUndefined({ + tableId: params?.tableId ?? undefined, + sandboxId: params?.sandboxId ?? undefined, + }) + return Object.keys(context).length > 0 ? context : undefined +} + interface UseWandProps { wandConfig?: WandConfig currentValue?: string - contextParams?: { - tableId?: string | null - } + contextParams?: WandContextParams onGeneratedContent: (content: string) => void onStreamChunk?: (chunk: string) => void onStreamStart?: () => void @@ -185,7 +203,7 @@ export function useWand({ generationType: wandConfig?.generationType, workflowId: workflowId ?? undefined, workspaceId: workspaceId ?? undefined, - wandContext: contextParams?.tableId ? { tableId: contextParams.tableId } : undefined, + wandContext: buildWandContext(contextParams), }, signal: abortControllerRef.current.signal, }, @@ -267,6 +285,7 @@ export function useWand({ onGenerationComplete, queryClient, contextParams?.tableId, + contextParams?.sandboxId, workflowId, workspaceId, navigateToSettings, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 46af4b662d..6bbeafd597 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -38,6 +38,7 @@ import { hasAdvancedValues, isSubBlockFeatureEnabled, isSubBlockVisibleForMode, + isToolInputOnlySubBlock, } from '@/lib/workflows/subblocks/visibility' import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components' @@ -1171,6 +1172,9 @@ function PreviewEditorContent({ if (effectiveTrigger && subBlock.mode !== 'trigger' && subBlock.mode !== 'trigger-advanced') return false if (!isSubBlockFeatureEnabled(subBlock)) return false + + // Configures the block as an agent tool; it has no meaning on the canvas. + if (isToolInputOnlySubBlock(subBlock)) return false if ( !isSubBlockVisibleForMode( subBlock, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index 4dde0ebfbf..9c9b9f8a93 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -17,6 +17,7 @@ import { evaluateSubBlockCondition, isSubBlockFeatureEnabled, isSubBlockVisibleForMode, + isToolInputOnlySubBlock, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' import { getTileIconColorClass } from '@/blocks/icon-color' @@ -212,6 +213,9 @@ function WorkflowPreviewBlockInner({ data }: NodeProps if (subBlock.hideFromPreview) return false if (!isSubBlockFeatureEnabled(subBlock)) return false + // Configures the block as an agent tool; it has no meaning on the canvas. + if (isToolInputOnlySubBlock(subBlock)) return false + if (effectiveTrigger) { const isValidTriggerSubblock = isPureTriggerBlock ? subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' || !subBlock.mode diff --git a/apps/sim/background/cleanup-sandbox-images.ts b/apps/sim/background/cleanup-sandbox-images.ts new file mode 100644 index 0000000000..7342479ac2 --- /dev/null +++ b/apps/sim/background/cleanup-sandbox-images.ts @@ -0,0 +1,20 @@ +import { createLogger } from '@sim/logger' +import { cleanupSandboxImages } from '@/lib/execution/remote-sandbox/image-registry' + +const logger = createLogger('CleanupSandboxImages') + +/** + * Days a build may go unused before the retention sweep removes it. A rebuild is + * cheap next to keeping every abandoned dependency set alive indefinitely, and + * E2B's docs flag possible future template-storage pricing. + */ +export const SANDBOX_IMAGE_RETENTION_DAYS = 30 + +export async function runCleanupSandboxImages(): Promise<{ deleted: number; failed: number }> { + const result = await cleanupSandboxImages(SANDBOX_IMAGE_RETENTION_DAYS) + logger.info('Swept unreferenced sandbox images', { + ...result, + retentionDays: SANDBOX_IMAGE_RETENTION_DAYS, + }) + return result +} diff --git a/apps/sim/background/sandbox-image-build.ts b/apps/sim/background/sandbox-image-build.ts new file mode 100644 index 0000000000..a7768e3bce --- /dev/null +++ b/apps/sim/background/sandbox-image-build.ts @@ -0,0 +1,28 @@ +import { task } from '@trigger.dev/sdk' +import { + runSandboxImageBuild, + type SandboxImageBuildPayload, +} from '@/lib/execution/remote-sandbox/image-registry' + +/** + * Trigger.dev wrapper around `runSandboxImageBuild`. The build's lifecycle lives + * in the `sandbox_image` row rather than the task, so this is a thin shell: the + * runner claims the row conditionally, which makes a re-delivery a no-op. + * + * `maxAttempts: 1` — the runner already polls the provider to a terminal state + * and records a classified failure the user can act on. A blind retry would just + * race the row it already marked `failed`; the user retries by saving again. + */ +export const sandboxImageBuildTask = task({ + id: 'sandbox-image-build', + machine: 'small-1x', + maxDuration: 1200, + retry: { maxAttempts: 1 }, + queue: { + name: 'sandbox-image-build', + concurrencyLimit: 5, + }, + run: async (payload: SandboxImageBuildPayload) => { + await runSandboxImageBuild(payload) + }, +}) diff --git a/apps/sim/blocks/blocks/function.ts b/apps/sim/blocks/blocks/function.ts index f4a8e72b77..6229b718a7 100644 --- a/apps/sim/blocks/blocks/function.ts +++ b/apps/sim/blocks/blocks/function.ts @@ -1,5 +1,10 @@ import { CodeIcon } from '@/components/icons' import { CodeLanguage, getLanguageDisplayName } from '@/lib/execution/languages' +import { + fetchWorkspaceSandboxOption, + fetchWorkspaceSandboxOptions, + fetchWorkspaceSecretNameOptions, +} from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' import type { CodeExecutionOutput } from '@/tools/function/types' @@ -8,11 +13,12 @@ export const FunctionBlock: BlockConfig = { name: 'Function', description: 'Run custom logic', longDescription: - 'This is a core workflow block. Execute custom JavaScript or Python code within your workflow. JavaScript without imports runs locally for fast execution, while code with imports or Python uses E2B sandbox.', + 'This is a core workflow block. Execute custom JavaScript or Python code within your workflow. JavaScript without imports runs locally for fast execution, while code with imports or Python runs in a remote sandbox.', bestPractices: ` - JavaScript code without external imports runs in a local VM for fastest execution. - - JavaScript code with import/require statements requires E2B and runs in a secure sandbox. - - Python code always requires E2B and runs in a secure sandbox. + - JavaScript code with import/require statements runs in a remote sandbox. + - Python code always runs in a remote sandbox. + - To import third-party packages, create a sandbox in Settings > Sandboxes and select it under the block's advanced options. Without one, only the standard library and built-in modules are available. - Can reference workflow variables using syntax as usual within code. Avoid XML/HTML tags. `, docsLink: 'https://docs.sim.ai/workflows/blocks/function', @@ -29,7 +35,7 @@ export const FunctionBlock: BlockConfig = { ], placeholder: 'Select language', value: () => CodeLanguage.JavaScript, - showWhenEnvSet: 'NEXT_PUBLIC_E2B_ENABLED', + showWhenEnvSet: 'NEXT_PUBLIC_SANDBOX_ENABLED,NEXT_PUBLIC_E2B_ENABLED', }, { id: 'code', @@ -50,7 +56,7 @@ IMPORTANT FORMATTING RULES: 1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes (e.g., use 'apiKey = {{SERVICE_API_KEY}}' not 'apiKey = "{{SERVICE_API_KEY}}"'). Our system replaces these placeholders before execution. 2. Reference Input Parameters/Workflow Variables: Use the exact syntax . Do NOT wrap it in quotes (e.g., use 'userId = ;' not 'userId = "";'). This includes parameters defined in the block's schema and outputs from previous blocks. 3. Function Body ONLY: Do NOT include the function signature (e.g., 'async function myFunction() {' or the surrounding '}'). -4. Imports: Do NOT include import/require statements unless they are standard Node.js built-in modules (e.g., 'crypto', 'fs'). External libraries are not supported in this context. +4. Imports: Standard Node.js built-in modules (e.g., 'crypto', 'fs') are always available. Third-party packages are available ONLY when the block has a sandbox selected — the sandbox's package list is appended below when one is. Never import a package that is not on that list. 5. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'. 6. Clarity: Write clean, readable code. 7. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw JavaScript code for the function body. @@ -89,6 +95,54 @@ try { generationType: 'javascript-function-body', }, }, + { + id: 'sandboxId', + title: 'Sandbox', + type: 'combobox', + mode: 'advanced', + searchable: true, + // Empty means the default image — the picker must never auto-select for us. + emptyIsValid: true, + createAction: 'sandbox', + // Refetched whenever `language` changes, so the list is always scoped to + // sandboxes this block can actually run in. + dependsOn: ['language'], + showWhenEnvSet: 'NEXT_PUBLIC_SANDBOX_ENABLED,NEXT_PUBLIC_E2B_ENABLED', + placeholder: 'Default image', + description: + 'Packages this block can import. Manage sandboxes in Settings > Sandboxes. Leaving this empty runs on the default image.', + options: [], + fetchOptions: (blockId) => fetchWorkspaceSandboxOptions(blockId), + fetchOptionById: (blockId, optionId) => fetchWorkspaceSandboxOption(blockId, optionId), + }, + { + id: 'secretScope', + title: 'Secret access', + type: 'dropdown', + // Only meaningful when an agent calls this block as a tool. + context: 'tool-input', + paramVisibility: 'user-only', + options: [ + { label: 'All secrets', id: 'all' }, + { label: 'Selected secrets', id: 'selected' }, + ], + value: () => 'all', + description: + 'Code can read any workspace secret, including ones added later. Narrow this to advertise a specific set to the model.', + }, + { + id: 'mountedSecrets', + title: 'Secrets', + type: 'dropdown', + context: 'tool-input', + paramVisibility: 'user-only', + multiSelect: true, + searchable: true, + options: [], + condition: { field: 'secretScope', value: 'selected' }, + placeholder: 'Select secrets this tool can read', + fetchOptions: () => fetchWorkspaceSecretNameOptions(), + }, ], tools: { access: ['function_execute'], @@ -97,6 +151,12 @@ try { code: { type: 'string', description: 'JavaScript or Python code to execute' }, language: { type: 'string', description: 'Language (javascript or python)' }, timeout: { type: 'number', description: 'Execution timeout' }, + sandboxId: { type: 'string', description: 'Workspace sandbox providing importable packages' }, + secretScope: { type: 'string', description: 'Secret access mode: all or selected' }, + mountedSecrets: { + type: 'json', + description: 'Workspace secret names this block may read when secretScope is selected', + }, }, outputs: { result: { type: 'json', description: 'Return value from the executed JavaScript function' }, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 110e062e97..2b29a9a1ab 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -262,6 +262,29 @@ export interface SubBlockConfig { canonicalParamId?: string /** Controls parameter visibility in agent/tool-input context */ paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden' + /** + * Marks "nothing selected" as a real choice, so a dynamic-option control does + * not pre-fill itself with the first option it fetches. Without it a combobox + * silently writes and persists a value the user never picked. + */ + emptyIsValid?: boolean + /** + * Pins a "create a new one" row above the options of a picker, so authoring a + * resource never means leaving the workflow for Settings. + * + * Names the resource rather than carrying a component: block configs are read + * by the serializer and the executor, which must not pull in React. The picker + * owns the modal each name maps to. + */ + createAction?: 'sandbox' + /** + * Restricts where a subblock renders. `tool-input` means it configures how the + * block behaves *as an agent tool* and has no meaning on the canvas, so the + * canvas editor skips it while the agent's tool-input config still shows it. + * + * Generic on purpose: shared code branches on this flag, never on a block type. + */ + context?: 'tool-input' required?: | boolean | { @@ -315,7 +338,7 @@ export interface SubBlockConfig { hidden?: boolean hideFromPreview?: boolean // Hide this subblock from the workflow block preview hideDividerBefore?: boolean // Visually group this field with the preceding visible subblock - showWhenEnvSet?: string // Show this subblock only when the named NEXT_PUBLIC_ env var is truthy + showWhenEnvSet?: string // Show this subblock only when a named NEXT_PUBLIC_ env var is truthy; comma-separated means any of them hideWhenHosted?: boolean // Hide this subblock when running on hosted sim hideWhenEnvSet?: string // Hide this subblock when the named NEXT_PUBLIC_ env var is truthy description?: string diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 0dc9a894ff..0bf912e7df 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it } from 'vitest' import { ACCOUNT_SETTINGS_ITEMS, ACCOUNT_SETTINGS_PATH_ALIASES, @@ -23,6 +24,18 @@ import { WORKSPACE_SETTINGS_PATH_ALIASES, } from '@/components/settings/navigation' +/** + * The Sandboxes section is dropped on a deployment with no sandbox provider, and + * the env mock falls through to `process.env` — so without pinning this, every + * assertion below would depend on whether the developer's untracked + * `apps/sim/.env` sets the flag, passing locally and failing on CI. + */ +beforeEach(() => { + setEnv({ NEXT_PUBLIC_SANDBOX_ENABLED: 'true', NEXT_PUBLIC_E2B_ENABLED: undefined }) +}) + +afterAll(resetEnvMock) + describe('settings navigation boundaries', () => { it('preserves the order of all four settings catalogs', () => { expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toEqual([ @@ -42,6 +55,7 @@ describe('settings navigation boundaries', () => { 'apikeys', 'workflow-mcp-servers', 'byok', + 'sandboxes', 'inbox', 'recently-deleted', 'sso', @@ -76,6 +90,7 @@ describe('settings navigation boundaries', () => { 'teammates', 'secrets', 'byok', + 'sandboxes', 'custom-tools', 'mcp', 'workflow-mcp-servers', @@ -87,6 +102,37 @@ describe('settings navigation boundaries', () => { ]) }) + /** + * Entitlement decides whether a workspace may author sandboxes; this decides + * whether anything could run one. With no provider the tab is a dead end, so it + * is dropped rather than locked — an upgrade would not fix it. Both planes are + * asserted because each has its own filter. + */ + it('drops the Sandboxes section when no sandbox provider is configured', () => { + setEnv({ NEXT_PUBLIC_SANDBOX_ENABLED: undefined, NEXT_PUBLIC_E2B_ENABLED: undefined }) + + expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).not.toContain('sandboxes') + expect( + resolveWorkspaceNavigation({ + permission: 'admin', + permissionConfig: {}, + entitlements: { + byok: true, + inbox: true, + customBlocks: true, + forks: true, + sandboxes: true, + }, + }).map(({ id }) => id) + ).not.toContain('sandboxes') + }) + + it('keeps the Sandboxes section on the pre-Daytona E2B flag alone', () => { + setEnv({ NEXT_PUBLIC_SANDBOX_ENABLED: undefined, NEXT_PUBLIC_E2B_ENABLED: 'true' }) + + expect(buildUnifiedSettingsNavigation().map(({ id }) => id)).toContain('sandboxes') + }) + it('has one registry source for every unified and plane item', () => { const unifiedIds = SETTINGS_SECTION_REGISTRY.flatMap(({ unified }) => unified ? [unified.id] : [] @@ -283,6 +329,7 @@ describe('settings navigation boundaries', () => { 'teammates', 'secrets', 'byok', + 'sandboxes', 'custom-tools', 'mcp', 'workflow-mcp-servers', @@ -299,6 +346,7 @@ describe('settings navigation boundaries', () => { 'teammates', 'secrets', 'byok', + 'sandboxes', 'custom-tools', 'mcp', 'workflow-mcp-servers', @@ -325,6 +373,7 @@ describe('settings navigation boundaries', () => { customBlocks: true, forks: true, inbox: true, + sandboxes: true, }, }) @@ -348,12 +397,14 @@ describe('settings navigation boundaries', () => { customBlocks: true, forks: true, inbox: true, + sandboxes: true, }, }) expect(items.map(({ id }) => id)).toEqual([ 'teammates', 'byok', + 'sandboxes', 'workflow-mcp-servers', 'recently-deleted', 'forks', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 08f12d531b..3557160e1d 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -24,7 +24,7 @@ import { Wrench, } from '@sim/emcn/icons' import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' -import { McpIcon } from '@/components/icons' +import { CodeIcon, McpIcon } from '@/components/icons' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isAccessControlEnabled, @@ -33,6 +33,7 @@ import { isDataRetentionEnabled, isHosted, isInboxEnabled, + isSandboxesEnabled, isSessionPoliciesEnabled, isSsoEnabled, isWhitelabelingEnabled, @@ -63,6 +64,7 @@ export type WorkspaceSettingsSection = | 'teammates' | 'secrets' | 'byok' + | 'sandboxes' | 'custom-tools' | 'mcp' | 'workflow-mcp-servers' @@ -110,6 +112,7 @@ export type UnifiedSettingsSection = | 'custom-tools' | 'workflow-mcp-servers' | 'inbox' + | 'sandboxes' | 'admin' | 'sessions' | 'data-retention' @@ -208,11 +211,31 @@ const SETTINGS_SELF_HOSTED_OVERRIDES = { dataDrains: isDataDrainsEnabled, dataRetention: isDataRetentionEnabled, inbox: isInboxEnabled, + sandboxes: isSandboxesEnabled, sessionPolicies: isSessionPoliciesEnabled, sso: isSsoEnabled, whitelabeling: isWhitelabelingEnabled, } as const +/** + * Whether this deployment can run remote sandboxes at all. + * + * Entitlement decides whether a workspace may *author* sandboxes; this decides + * whether anything could ever *run* one. Without a provider the tab is a dead + * end — you can define a dependency set that nothing will build and no Function + * block can select, because the picker is gated on this same pair of vars. + * + * It reads those browser twins rather than the server's `isRemoteSandboxEnabled` + * precisely so the two agree: that flag reads non-public vars, and this module + * renders on both sides. `NEXT_PUBLIC_E2B_ENABLED` is the pre-Daytona fallback, + * matching the picker's `showWhenEnvSet` order. + */ +function isSandboxExecutionAvailable(): boolean { + return ( + isTruthy(getEnv('NEXT_PUBLIC_SANDBOX_ENABLED')) || isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED')) + ) +} + export const SETTINGS_NAVIGATION_BILLING_ENABLED = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) type SettingsHrefSearchParams = Pick @@ -439,7 +462,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'enterprise', }, planes: { - workspace: { id: 'forks', group: 'enterprise', order: 9 }, + workspace: { id: 'forks', group: 'enterprise', order: 10 }, }, }, { @@ -526,7 +549,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'tools', }, planes: { - workspace: { id: 'custom-tools', group: 'tools', order: 3 }, + workspace: { id: 'custom-tools', group: 'tools', order: 4 }, }, }, { @@ -538,7 +561,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'tools', }, planes: { - workspace: { id: 'mcp', group: 'tools', order: 4 }, + workspace: { id: 'mcp', group: 'tools', order: 5 }, }, }, { @@ -560,7 +583,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] id: 'api-keys', description: 'Manage workspace API keys and personal-key policy.', group: 'system', - order: 6, + order: 7, }, }, }, @@ -573,7 +596,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'system', }, planes: { - workspace: { id: 'workflow-mcp-servers', group: 'tools', order: 5 }, + workspace: { id: 'workflow-mcp-servers', group: 'tools', order: 6 }, }, }, { @@ -589,6 +612,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] workspace: { id: 'byok', group: 'workspace', order: 2 }, }, }, + { + label: 'Sandboxes', + icon: CodeIcon, + docsLink: 'https://docs.sim.ai/workflows/blocks/function', + unified: { + id: 'sandboxes', + description: 'Install Python or npm packages for Function blocks to import.', + group: 'system', + requiresMax: true, + selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sandboxes, + showWhenLocked: true, + }, + planes: { + workspace: { id: 'sandboxes', group: 'workspace', order: 3 }, + }, + }, { label: 'Chat keys', icon: HexSimple, @@ -614,7 +653,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] showWhenLocked: true, }, planes: { - workspace: { id: 'inbox', group: 'system', order: 7 }, + workspace: { id: 'inbox', group: 'system', order: 8 }, }, }, { @@ -626,7 +665,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] group: 'system', }, planes: { - workspace: { id: 'recently-deleted', group: 'system', order: 8 }, + workspace: { id: 'recently-deleted', group: 'system', order: 9 }, }, }, { @@ -724,7 +763,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.customBlocks, }, planes: { - workspace: { id: 'custom-blocks', group: 'enterprise', order: 10 }, + workspace: { id: 'custom-blocks', group: 'enterprise', order: 11 }, }, }, { @@ -758,6 +797,10 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] export function buildUnifiedSettingsNavigation(): UnifiedSettingsNavigationItem[] { return SETTINGS_SECTION_REGISTRY.flatMap(({ label, icon, docsLink, unified }) => { if (!unified) return [] + // Dropped here rather than in each consumer's filter: the sidebar's + // `selfHostedOverride` short-circuit would otherwise reveal the tab on a + // deployment that has the entitlement but no provider to run what it builds. + if (unified.id === 'sandboxes' && !isSandboxExecutionAvailable()) return [] const { group, ...item } = unified return [ { @@ -892,6 +935,20 @@ export interface WorkspaceSettingsEntitlements { customBlocks: boolean forks: boolean inbox: boolean + sandboxes: boolean +} + +/** + * Sections that stay visible without their entitlement, rendering a locked + * upgrade prompt instead of disappearing from the nav. Keyed by the entitlement + * that unlocks them, so adding a gated section is one entry rather than another + * hardcoded id check in {@link resolveWorkspaceNavigation}. + */ +const LOCKABLE_WORKSPACE_SECTIONS: Partial< + Record +> = { + inbox: 'inbox', + sandboxes: 'sandboxes', } interface ResolveWorkspaceNavigationOptions { @@ -910,6 +967,7 @@ const WORKSPACE_MUTATION_PERMISSION: Record [...sandboxKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...sandboxKeys.lists(), workspaceId ?? ''] as const, +} + +export const SANDBOX_LIST_STALE_TIME = 30 * 1000 + +/** Poll cadence while any sandbox is still building; see {@link useSandboxes}. */ +export const SANDBOX_BUILD_POLL_INTERVAL = 3 * 1000 + +/** + * Bounds the build poll. A worker killed mid-build leaves a row `building` until + * the next save re-claims it, and without this a tab left open on that sandbox + * would poll forever. Covers a little over the 15-minute build cap. + */ +const MAX_BUILD_POLLS = 350 + +async function fetchSandboxes( + workspaceId: string, + signal?: AbortSignal +): Promise { + return requestJson(listSandboxesContract, { params: { id: workspaceId }, signal }) +} + +/** True while at least one sandbox has a build that has not reached a terminal state. */ +export function hasPendingBuild(sandboxes: readonly Sandbox[]): boolean { + return sandboxes.some( + (sandbox) => sandbox.buildStatus === 'pending' || sandbox.buildStatus === 'building' + ) +} + +/** + * Query options shared by the hook and the Function block's sandbox picker + * (`fetchWorkspaceSandboxOptions`), so both read one cache entry rather than two. + */ +export function getSandboxListQueryOptions(workspaceId: string) { + return { + queryKey: sandboxKeys.list(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => fetchSandboxes(workspaceId, signal), + staleTime: SANDBOX_LIST_STALE_TIME, + } +} + +export function useSandboxes(workspaceId?: string) { + return useQuery({ + queryKey: sandboxKeys.list(workspaceId), + queryFn: ({ signal }) => fetchSandboxes(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: SANDBOX_LIST_STALE_TIME, + // Builds are the only thing that changes without a user action, so the poll + // runs only while one is in flight and stops on the first terminal read. + refetchInterval: (query) => + query.state.data && + hasPendingBuild(query.state.data.sandboxes) && + query.state.dataUpdateCount < MAX_BUILD_POLLS + ? SANDBOX_BUILD_POLL_INTERVAL + : false, + }) +} + +type CreateSandboxParams = { + workspaceId: string +} & ContractBodyInput + +export function useCreateSandbox() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, ...body }: CreateSandboxParams) => { + const data = await requestJson(createSandboxContract, { + params: { id: workspaceId }, + body, + }) + logger.info(`Created sandbox ${body.name} in workspace ${workspaceId}`) + return data + }, + onSettled: (_data, _error, variables) => + queryClient.invalidateQueries({ queryKey: sandboxKeys.list(variables.workspaceId) }), + }) +} + +type UpdateSandboxParams = { + workspaceId: string + sandboxId: string +} & ContractBodyInput + +export function useUpdateSandbox() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, sandboxId, ...body }: UpdateSandboxParams) => { + const data = await requestJson(updateSandboxContract, { + params: { id: workspaceId, sandboxId }, + body, + }) + logger.info(`Updated sandbox ${sandboxId} in workspace ${workspaceId}`) + return data + }, + onSettled: (_data, _error, variables) => + queryClient.invalidateQueries({ queryKey: sandboxKeys.list(variables.workspaceId) }), + }) +} + +export function useDeleteSandbox() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, sandboxId }: { workspaceId: string; sandboxId: string }) => { + const data = await requestJson(deleteSandboxContract, { + params: { id: workspaceId, sandboxId }, + }) + logger.info(`Deleted sandbox ${sandboxId} from workspace ${workspaceId}`) + return data + }, + onSettled: (_data, _error, variables) => + queryClient.invalidateQueries({ queryKey: sandboxKeys.list(variables.workspaceId) }), + }) +} diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index 564dbf63f5..eaa75f8f43 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -190,6 +190,12 @@ export const functionExecuteContract = defineRouteContract({ workspaceId: z.string().optional(), userId: z.string().optional(), isCustomTool: z.boolean().optional().default(false), + /** Workspace sandbox whose dependency set this execution runs against. */ + sandboxId: z.string().optional(), + /** `all` (default) or `selected`; see mountedSecrets. */ + secretScope: z.enum(['all', 'selected']).optional(), + /** Secret names this execution may read when secretScope is `selected`. */ + mountedSecrets: z.array(z.string()).optional(), _sandboxFiles: z .array( z.union([ diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 958a4ed21f..2001b85b8c 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -21,6 +21,7 @@ export * from './media' export * from './permission-groups' export * from './pinned-items' export * from './primitives' +export * from './sandboxes' export * from './selectors' export * from './skills' export * from './storage-transfer' diff --git a/apps/sim/lib/api/contracts/sandboxes.ts b/apps/sim/lib/api/contracts/sandboxes.ts new file mode 100644 index 0000000000..39547edfdc --- /dev/null +++ b/apps/sim/lib/api/contracts/sandboxes.ts @@ -0,0 +1,151 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' + +export const sandboxLanguageSchema = z.enum(['javascript', 'python']) + +export const sandboxBuildStatusSchema = z.enum(['pending', 'building', 'ready', 'failed']) + +/** + * How the active deployment materializes dependencies. `runtime` deployments + * have no build to report, so the UI swaps the build-status chip for a note + * about the per-execution install cost. + */ +export const sandboxStrategySchema = z.enum(['prebuilt', 'runtime']) + +/** + * A structural payload guard, NOT the product limit. + * + * The client submits one entry per raw textarea line — blanks and `#` comments + * included — so that a rejection can be addressed back to the row the user typed + * it on. `validateDependencies` strips those before applying the real + * MAX_SANDBOX_DEPENDENCIES / MAX_DEPENDENCY_LENGTH caps and returns per-line + * `issues` the editor marks inline. Bounding this at the real limits instead + * would reject a legal list (50 packages plus a trailing newline is 51 entries) + * with a generic error carrying no `issues`, leaving the editor nothing to mark. + */ +const dependencyListSchema = z + .array(z.string().max(2000, 'a dependency line is unreasonably long')) + .max(1000, 'too many lines — paste a shorter dependency list') + +const sandboxNameSchema = z + .string() + .trim() + .min(1, 'Name is required') + .max(64, 'Name must be 64 characters or fewer') + +export const sandboxSchema = z.object({ + id: z.string(), + name: z.string(), + language: sandboxLanguageSchema, + dependencies: z.array(z.string()), + /** Absent under the `runtime` strategy, which has nothing to build. */ + buildStatus: sandboxBuildStatusSchema.nullable(), + /** Classified failure code from the build taxonomy. */ + errorCode: z.string().nullable(), + /** User-facing copy for the failure; never a raw traceback. */ + errorMessage: z.string().nullable(), + /** Installer log tail, shown behind a disclosure. */ + errorDetail: z.string().nullable(), + builtAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +export type Sandbox = z.output + +/** A dependency line the server refused, addressed to the row the user typed it on. */ +export const sandboxDependencyIssueSchema = z.object({ + line: z.number().int().positive(), + value: z.string(), + reason: z.string(), +}) + +export type SandboxDependencyIssue = z.output + +const sandboxWorkspaceParamsSchema = z.object({ + id: workspaceIdSchema, +}) + +const sandboxResourceParamsSchema = z.object({ + id: workspaceIdSchema, + sandboxId: z.string().min(1, 'sandboxId is required'), +}) + +export const createSandboxBodySchema = z.object({ + name: sandboxNameSchema, + language: sandboxLanguageSchema, + /** One entry per submitted line, comments and blanks included, so rejections keep their row. */ + dependencies: dependencyListSchema, +}) + +export type CreateSandboxBody = z.input + +export const updateSandboxBodySchema = z + .object({ + name: sandboxNameSchema.optional(), + language: sandboxLanguageSchema.optional(), + dependencies: dependencyListSchema.optional(), + }) + .refine( + (body) => + body.name !== undefined || body.language !== undefined || body.dependencies !== undefined, + { message: 'Provide at least one of name, language, or dependencies' } + ) + +export type UpdateSandboxBody = z.input + +export const listSandboxesContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/sandboxes', + params: sandboxWorkspaceParamsSchema, + response: { + mode: 'json', + schema: z.object({ + sandboxes: z.array(sandboxSchema), + strategy: sandboxStrategySchema, + /** False on a free plan: the list still renders, but read-only behind an upgrade prompt. */ + entitled: z.boolean(), + }), + }, +}) + +export const createSandboxContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/sandboxes', + params: sandboxWorkspaceParamsSchema, + body: createSandboxBodySchema, + response: { + mode: 'json', + schema: z.object({ + sandbox: sandboxSchema, + }), + }, +}) + +export const updateSandboxContract = defineRouteContract({ + method: 'PATCH', + path: '/api/workspaces/[id]/sandboxes/[sandboxId]', + params: sandboxResourceParamsSchema, + body: updateSandboxBodySchema, + response: { + mode: 'json', + schema: z.object({ + sandbox: sandboxSchema, + }), + }, +}) + +export const deleteSandboxContract = defineRouteContract({ + method: 'DELETE', + path: '/api/workspaces/[id]/sandboxes/[sandboxId]', + params: sandboxResourceParamsSchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + }), + }, +}) + +export type SandboxListResponse = ContractJsonResponse diff --git a/apps/sim/lib/billing/client/plan-view.ts b/apps/sim/lib/billing/client/plan-view.ts index 6b363d4915..23a30f8a26 100644 --- a/apps/sim/lib/billing/client/plan-view.ts +++ b/apps/sim/lib/billing/client/plan-view.ts @@ -1,4 +1,4 @@ -import { CREDIT_TIERS } from '@/lib/billing/constants' +import { MAX_TIER_CREDITS } from '@/lib/billing/constants' import { getPlanTierCredits, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers' /** @@ -46,8 +46,6 @@ export interface PlanView { showCredits: boolean } -const MAX_TIER_CREDITS = CREDIT_TIERS[1].credits - /** Tier ordering used to derive upgrade/downgrade/highlight relationships. */ const PLAN_RANK: Record = { free: 0, pro: 1, max: 2, enterprise: 3 } diff --git a/apps/sim/lib/billing/client/utils.ts b/apps/sim/lib/billing/client/utils.ts index 91211902e7..b2e28b56ee 100644 --- a/apps/sim/lib/billing/client/utils.ts +++ b/apps/sim/lib/billing/client/utils.ts @@ -4,7 +4,7 @@ */ import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' -import { getPlanTierCredits, isEnterprise, isFree, isPro } from '@/lib/billing/plan-helpers' +import { isFree, isMaxTier, isPro } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionAccess } from '@/lib/billing/subscriptions/utils' import { USAGE_PILL_COLORS } from './consts' import type { BillingStatus, SubscriptionData, UsageData } from './types' @@ -54,8 +54,9 @@ export function getSubscriptionAccessState( const hasUsableTeamAccess = hasUsablePaidAccess && (status.isOrgScoped || status.isTeam || status.isEnterprise) const hasUsableEnterpriseAccess = hasUsablePaidAccess && status.isEnterprise - const hasUsableMaxAccess = - hasUsablePaidAccess && (getPlanTierCredits(status.plan) >= 25000 || isEnterprise(status.plan)) + // isMaxTier is the same predicate the server gates use, so a Max-gated surface + // can never render unlocked against an API that will refuse it. + const hasUsableMaxAccess = hasUsablePaidAccess && isMaxTier(status.plan) return { ...status, diff --git a/apps/sim/lib/billing/constants.ts b/apps/sim/lib/billing/constants.ts index d4e60cd74e..a3c793e29d 100644 --- a/apps/sim/lib/billing/constants.ts +++ b/apps/sim/lib/billing/constants.ts @@ -43,13 +43,24 @@ export const BILLING_LOCK_TIMEOUT_MS = 5_000 * Available credit tiers. Each tier maps a credit amount to the underlying dollar cost. * 1 credit = $0.005, so credits = dollars * 200. */ -export const CREDIT_TIERS = [ - { credits: 6000, dollars: 25, name: 'Pro' }, - { credits: 25000, dollars: 100, name: 'Max' }, -] as const +const PRO_CREDIT_TIER = { credits: 6000, dollars: 25, name: 'Pro' } as const +const MAX_CREDIT_TIER = { credits: 25000, dollars: 100, name: 'Max' } as const + +export const CREDIT_TIERS = [PRO_CREDIT_TIER, MAX_CREDIT_TIER] as const export type CreditTier = (typeof CREDIT_TIERS)[number] +/** + * Credit allocation at which a paid plan enters the Max tier. + * + * Derived from the tier table above so the threshold can never drift from it. + * Do not re-spell this number: every "is this Max?" decision goes through + * `isMaxTier` in `@/lib/billing/plan-helpers`, which reads this constant. The + * server feature gates and the client `hasUsableMaxAccess` derivation share that + * predicate precisely so the UI can never offer a feature the API refuses. + */ +export const MAX_TIER_CREDITS = MAX_CREDIT_TIER.credits + /** * Credits granted per dollar of plan spend. A credit is $0.005, so a dollar * buys 200 — the conversion behind both free-tier and daily-refresh credits. diff --git a/apps/sim/lib/billing/core/access.test.ts b/apps/sim/lib/billing/core/access.test.ts new file mode 100644 index 0000000000..9ae1dee42c --- /dev/null +++ b/apps/sim/lib/billing/core/access.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { getBillingEntityBlockStatus, getEffectiveBillingStatus } from '@/lib/billing/core/access' + +/** A clean `user_stats` row as `getEffectiveBillingStatus` selects it. */ +const UNBLOCKED_STATS = { blocked: false, blockedReason: null } + +describe('getEffectiveBillingStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it("reports the user's own block without consulting memberships", async () => { + queueTableRows(schemaMock.userStats, [{ blocked: true, blockedReason: 'payment_failed' }]) + + await expect(getEffectiveBillingStatus('user-1')).resolves.toEqual({ + billingBlocked: true, + billingBlockedReason: 'payment_failed', + blockedByOrgOwner: false, + }) + }) + + it('blocks a clean user whose organization owner is delinquent', async () => { + queueTableRows(schemaMock.userStats, [UNBLOCKED_STATS]) + queueTableRows(schemaMock.member, [{ organizationId: 'org-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.userStats, [{ blocked: true, blockedReason: 'dispute' }]) + + await expect(getEffectiveBillingStatus('user-1')).resolves.toEqual({ + billingBlocked: true, + billingBlockedReason: 'dispute', + blockedByOrgOwner: true, + }) + }) + + it('allows a clean user who owns the organization they belong to', async () => { + queueTableRows(schemaMock.userStats, [UNBLOCKED_STATS]) + queueTableRows(schemaMock.member, [{ organizationId: 'org-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'user-1' }]) + + await expect(getEffectiveBillingStatus('user-1')).resolves.toEqual({ + billingBlocked: false, + billingBlockedReason: null, + blockedByOrgOwner: false, + }) + }) +}) + +describe('getBillingEntityBlockStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + describe('personal payer', () => { + it('blocks a payer whose own row is blocked', async () => { + queueTableRows(schemaMock.userStats, [{ blocked: true, blockedReason: 'payment_failed' }]) + + await expect(getBillingEntityBlockStatus({ type: 'user', id: 'payer-1' })).resolves.toEqual({ + billingBlocked: true, + billingBlockedReason: 'payment_failed', + }) + }) + + /** + * The payer's own row is clean — only membership in a delinquent org blocks + * them. Reading `user_stats` directly would report "not blocked" here and + * disagree with every other entitlement gate, because `blockOrgMembers` + * fans out point-in-time and never marks a member who joins after the block. + */ + it('blocks a payer whose own row is clean but whose organization owner is delinquent', async () => { + queueTableRows(schemaMock.userStats, [UNBLOCKED_STATS]) + queueTableRows(schemaMock.member, [{ organizationId: 'org-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.userStats, [{ blocked: true, blockedReason: 'payment_failed' }]) + + await expect(getBillingEntityBlockStatus({ type: 'user', id: 'payer-1' })).resolves.toEqual({ + billingBlocked: true, + billingBlockedReason: 'payment_failed', + }) + }) + + it('does not widen the payer block shape with blockedByOrgOwner', async () => { + queueTableRows(schemaMock.userStats, [UNBLOCKED_STATS]) + queueTableRows(schemaMock.member, []) + + const status = await getBillingEntityBlockStatus({ type: 'user', id: 'payer-1' }) + + expect(Object.keys(status).sort()).toEqual(['billingBlocked', 'billingBlockedReason']) + }) + + it('allows a clean payer with no memberships', async () => { + queueTableRows(schemaMock.userStats, [UNBLOCKED_STATS]) + queueTableRows(schemaMock.member, []) + + await expect(getBillingEntityBlockStatus({ type: 'user', id: 'payer-1' })).resolves.toEqual({ + billingBlocked: false, + billingBlockedReason: null, + }) + }) + }) + + describe('organization payer', () => { + /** + * An organization's debt is its owner's own debt. Re-deriving through the + * owner's memberships would let some unrelated org the owner belongs to + * block this organization's workspaces. + */ + it("reads the owner's own row and stops there", async () => { + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.userStats, [{ billingBlocked: false, billingBlockedReason: null }]) + queueTableRows(schemaMock.member, [{ organizationId: 'unrelated-org' }]) + queueTableRows(schemaMock.userStats, [{ blocked: true, blockedReason: 'dispute' }]) + + await expect( + getBillingEntityBlockStatus({ type: 'organization', id: 'org-1' }) + ).resolves.toEqual({ + billingBlocked: false, + billingBlockedReason: null, + }) + }) + + it("blocks when the owner's own row is blocked", async () => { + queueTableRows(schemaMock.member, [{ userId: 'owner-1' }]) + queueTableRows(schemaMock.userStats, [ + { billingBlocked: true, billingBlockedReason: 'payment_failed' }, + ]) + + await expect( + getBillingEntityBlockStatus({ type: 'organization', id: 'org-1' }) + ).resolves.toEqual({ + billingBlocked: true, + billingBlockedReason: 'payment_failed', + }) + }) + + it('is not blocked when the organization has no owner row', async () => { + queueTableRows(schemaMock.member, []) + + await expect( + getBillingEntityBlockStatus({ type: 'organization', id: 'org-1' }) + ).resolves.toEqual({ + billingBlocked: false, + billingBlockedReason: null, + }) + }) + }) +}) diff --git a/apps/sim/lib/billing/core/access.ts b/apps/sim/lib/billing/core/access.ts index 283fb06a59..89a558c202 100644 --- a/apps/sim/lib/billing/core/access.ts +++ b/apps/sim/lib/billing/core/access.ts @@ -16,7 +16,16 @@ export interface BillingEntityBlockStatus { billingBlockedReason: 'payment_failed' | 'dispute' | null } -async function getUserBillingBlockStatus(userId: string): Promise { +/** + * Reads one user's own `user_stats` row, without re-deriving the block that + * their organization membership would imply. + * + * Only the organization branch of {@link getBillingEntityBlockStatus} wants this + * narrow read: an organization's debt is its owner's own debt, and some other + * org the owner merely belongs to is not this organization's problem. Callers + * asking whether a *user* is blocked want {@link getEffectiveBillingStatus}. + */ +async function getUserStatsBlockStatus(userId: string): Promise { const [stats] = await db .select({ billingBlocked: userStats.billingBlocked, @@ -34,14 +43,27 @@ async function getUserBillingBlockStatus(userId: string): Promise { if (billingEntity.type === 'user') { - return getUserBillingBlockStatus(billingEntity.id) + const { billingBlocked, billingBlockedReason } = await getEffectiveBillingStatus( + billingEntity.id + ) + return { billingBlocked, billingBlockedReason } } const [owner] = await db @@ -58,7 +80,7 @@ export async function getBillingEntityBlockStatus(billingEntity: { return { billingBlocked: false, billingBlockedReason: null } } - return getUserBillingBlockStatus(owner.userId) + return getUserStatsBlockStatus(owner.userId) } /** diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index 758658d95a..79bac7b661 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -11,6 +11,7 @@ const { mockCheckEnterprisePlan, mockGetPlanTierCredits, mockHasUsableSubscriptionAccess, + mockGetEffectiveBillingStatus, } = vi.hoisted(() => ({ mockGetHighestPrioritySubscription: vi.fn(), mockGetHighestPriorityPersonalSubscription: vi.fn(), @@ -18,10 +19,11 @@ const { mockCheckEnterprisePlan: vi.fn(), mockGetPlanTierCredits: vi.fn(), mockHasUsableSubscriptionAccess: vi.fn(), + mockGetEffectiveBillingStatus: vi.fn(), })) vi.mock('@/lib/billing/core/access', () => ({ - getEffectiveBillingStatus: vi.fn(), + getEffectiveBillingStatus: mockGetEffectiveBillingStatus, isOrganizationBillingBlocked: vi.fn(), })) @@ -32,7 +34,9 @@ vi.mock('@/lib/billing/core/plan', () => ({ vi.mock('@/lib/billing/plan-helpers', () => ({ getPlanTierCredits: mockGetPlanTierCredits, - isEnterprise: vi.fn().mockReturnValue(false), + isEnterprise: (plan: string | null | undefined) => plan === 'enterprise', + isMaxTier: (plan: string | null | undefined) => + mockGetPlanTierCredits(plan) >= 25000 || plan === 'enterprise', isOrgPlan: (plan: string | null | undefined) => plan === 'enterprise' || plan === 'team' || Boolean(plan?.startsWith('team_')), isPro: vi.fn(), @@ -40,13 +44,14 @@ vi.mock('@/lib/billing/plan-helpers', () => ({ sqlIsPaid: vi.fn(() => ({ type: 'sqlIsPaid' })), })) +/** Mirrors the production sets exactly — a mock that widens them would let a gate regress unnoticed. */ vi.mock('@/lib/billing/subscriptions/utils', () => ({ checkEnterprisePlan: mockCheckEnterprisePlan, checkProPlan: vi.fn(), checkTeamPlan: vi.fn(), - ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing'], + ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'], hasUsableSubscriptionAccess: mockHasUsableSubscriptionAccess, - USABLE_SUBSCRIPTION_STATUSES: ['active', 'trialing'], + USABLE_SUBSCRIPTION_STATUSES: ['active'], })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -58,6 +63,7 @@ import { getOrganizationIdForSubscriptionReference, hasPaidSubscription, hasWorkspaceLiveSyncAccess, + hasWorkspaceSandboxAccess, isWorkspaceOnEnterprisePlan, syncSubscriptionPlan, } from '@/lib/billing/core/subscription' @@ -201,23 +207,62 @@ describe('isWorkspaceOnEnterprisePlan', () => { billedAccountUserId: 'owner-1', organizationId: null, }) + mockHasUsableSubscriptionAccess.mockImplementation( + (status: string | null, billingBlocked: boolean) => status === 'active' && !billingBlocked + ) + mockGetEffectiveBillingStatus.mockResolvedValue({ + billingBlocked: false, + billingBlockedReason: null, + blockedByOrgOwner: false, + }) }) it('uses only the exact personal subscription for a personal workspace payer', async () => { mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ referenceId: 'owner-1', plan: 'enterprise', + status: 'active', }) mockGetHighestPrioritySubscription.mockResolvedValue({ referenceId: 'unrelated-org', plan: 'enterprise', + status: 'active', }) - mockCheckEnterprisePlan.mockReturnValue(true) await expect(isWorkspaceOnEnterprisePlan('ws-1')).resolves.toBe(true) expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledWith('owner-1') expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() }) + + // The organization branch has always required an `active`, unblocked payer via + // `isOrganizationOnEnterprisePlan`. The personal branch used to skip both checks, + // so a delinquent personal enterprise payer kept the feature while an org in the + // identical state lost it. These two pin the branches to the same policy. + it('denies a personal enterprise payer whose subscription is only past_due', async () => { + mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ + referenceId: 'owner-1', + plan: 'enterprise', + status: 'past_due', + }) + + await expect(isWorkspaceOnEnterprisePlan('ws-1')).resolves.toBe(false) + }) + + it('denies a personal enterprise payer who is billing-blocked through their org owner', async () => { + mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ + referenceId: 'owner-1', + plan: 'enterprise', + status: 'active', + }) + mockGetEffectiveBillingStatus.mockResolvedValue({ + billingBlocked: true, + billingBlockedReason: 'payment_failed', + blockedByOrgOwner: true, + }) + + await expect(isWorkspaceOnEnterprisePlan('ws-1')).resolves.toBe(false) + expect(mockGetEffectiveBillingStatus).toHaveBeenCalledWith('owner-1') + }) }) describe('hasWorkspaceLiveSyncAccess', () => { @@ -231,7 +276,11 @@ describe('hasWorkspaceLiveSyncAccess', () => { mockHasUsableSubscriptionAccess.mockImplementation( (status: string | null, billingBlocked: boolean) => status === 'active' && !billingBlocked ) - dbChainMockFns.limit.mockResolvedValue([{ billingBlocked: false }]) + mockGetEffectiveBillingStatus.mockResolvedValue({ + billingBlocked: false, + billingBlockedReason: null, + blockedByOrgOwner: false, + }) }) it('allows live sync from the exact Max workspace payer', async () => { @@ -264,4 +313,84 @@ describe('hasWorkspaceLiveSyncAccess', () => { expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledWith('workspace-owner') expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() }) + + // The payer's own row is clean; only their membership in a delinquent org + // blocks them. Reading `userStats.billingBlocked` directly would let this + // through whenever `blockOrgMembers`' fan-out is stale — a member who joined + // after the block, or whose row a sibling org's unblock already cleared. + it('denies a paid personal payer who is blocked by their org owner', async () => { + mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ + referenceId: 'workspace-owner', + plan: 'pro_25000', + status: 'active', + }) + mockGetPlanTierCredits.mockReturnValue(25000) + mockGetEffectiveBillingStatus.mockResolvedValue({ + billingBlocked: true, + billingBlockedReason: 'payment_failed', + blockedByOrgOwner: true, + }) + + await expect(hasWorkspaceLiveSyncAccess('workspace-host')).resolves.toBe(false) + expect(mockGetEffectiveBillingStatus).toHaveBeenCalledWith('workspace-owner') + }) +}) + +/** + * Sandboxes are an enterprise feature, so `SANDBOXES_ENABLED` must win over the + * plan gate the way `INBOX_ENABLED` does. Both cases run with billing enabled — + * the `!isBillingEnabled` bail would otherwise answer every one of them, hiding + * whether the override is wired at all. + */ +describe('hasWorkspaceSandboxAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isBillingEnabled: true, isHosted: true, isSandboxesEnabled: false }) + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-host', + billedAccountUserId: 'workspace-owner', + organizationId: null, + }) + mockHasUsableSubscriptionAccess.mockImplementation( + (status: string | null, billingBlocked: boolean) => status === 'active' && !billingBlocked + ) + mockGetEffectiveBillingStatus.mockResolvedValue({ + billingBlocked: false, + billingBlockedReason: null, + blockedByOrgOwner: false, + }) + }) + + afterAll(() => setEnvFlags({ isSandboxesEnabled: true })) + + it('grants access from the self-hosted override without resolving a payer', async () => { + setEnvFlags({ isSandboxesEnabled: true }) + + await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(true) + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + expect(mockGetHighestPriorityPersonalSubscription).not.toHaveBeenCalled() + }) + + it('falls back to the Max plan gate when the override is unset', async () => { + mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ + referenceId: 'workspace-owner', + plan: 'pro_25000', + status: 'active', + }) + mockGetPlanTierCredits.mockReturnValue(25000) + + await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(true) + expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledWith('workspace-owner') + }) + + it('denies a sub-Max payer when the override is unset', async () => { + mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ + referenceId: 'workspace-owner', + plan: 'pro_6000', + status: 'active', + }) + mockGetPlanTierCredits.mockReturnValue(6000) + + await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(false) + }) }) diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 9352d88235..d4306a28b3 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { member, organization, subscription, user, userStats } from '@sim/db/schema' +import { member, organization, subscription, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, sql } from 'drizzle-orm' @@ -9,7 +9,7 @@ import { getHighestPrioritySubscription, } from '@/lib/billing/core/plan' import { - getPlanTierCredits, + isMaxTier, isOrgPlan, isEnterprise as isPlanEnterprise, isPro as isPlanPro, @@ -29,6 +29,7 @@ import { isBillingEnabled, isHosted, isInboxEnabled, + isSandboxesEnabled, isSsoEnabled, } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -500,44 +501,118 @@ export async function hasSSOAccess(userId: string): Promise { } /** - * Check whether a workspace is entitled to the Access Control (Permission Groups) - * feature. Entitlement follows the workspace's `billedAccountUserId`: + * Check whether a workspace is entitled to workspace-scoped enterprise features + * — today, copilot BYOK. Entitlement follows the workspace's billing entity: * - self-hosted override honored via ACCESS_CONTROL_ENABLED, OR * - billing disabled, OR * - the workspace belongs to an enterprise-plan organization (org-mode), OR * - the billed user has an individual enterprise subscription (personal workspace). + * + * Org-scoped Access Control (Permission Groups) gates on + * {@link isOrganizationOnEnterprisePlan} instead — it has no workspace to resolve. */ export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise { try { if (!isBillingEnabled) return true if (isAccessControlEnabled && !isHosted) return true - const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') - const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) - if (!ws) return false - - if (ws.organizationId) { - return isOrganizationOnEnterprisePlan(ws.organizationId) - } - - const billedSub = await getHighestPriorityPersonalSubscription(ws.billedAccountUserId) - return !!billedSub && checkEnterprisePlan(billedSub) + return await hasWorkspaceTierAccess(workspaceId, isPlanEnterprise) } catch (error) { logger.error('Error checking workspace enterprise plan status', { error, workspaceId }) return false } } -const MAX_PLAN_CREDITS = 25000 +/** + * How a workspace tier gate treats subscription status and billing-blocked state. + * + * - `'active-use'` — the payer must hold an `active` subscription and must not be + * billing-blocked. Correct for gating use of a feature. + * - `'retention'` — `active` and `past_due` both count, and block state is + * ignored, so a transient payment failure never triggers destructive teardown of + * already-provisioned infrastructure. Only reconciliation guards want this. + */ +type WorkspaceTierIntent = 'active-use' | 'retention' + +interface WorkspaceTierAccessOptions { + intent?: WorkspaceTierIntent + /** + * Result when the workspace row no longer exists. Teardown guards pass `true` + * so a missing workspace never reads as "safe to destroy". + */ + onMissingWorkspace?: boolean +} /** - * Whether a plan tier entitles the inbox (Sim Mailer) feature: a Max tier - * (credits >= 25000, covering `pro_25000` and `team_25000`) or any enterprise - * plan. Subscription status (usable vs entitled) is gated by callers before this - * runs — the predicate is tier-only. + * Whether the workspace's payer is on a plan satisfying `isTierEntitled`. + * + * Entitlement follows the workspace's billing entity — not the acting user — so + * any workspace admin (including an external member) qualifies when the + * workspace's organization, or its billed account for personal workspaces, is on + * a qualifying plan. + * + * This is the single payer resolution behind every workspace-scoped tier gate. + * Callers supply only the tier predicate and their own feature's env override; + * keeping the org/personal fork here is what stops the gates from drifting apart + * as billing edge cases are handled. + * + * The personal branch reads `getEffectiveBillingStatus`, NOT `userStats.billingBlocked` + * directly. Both express the same shipped policy — `blockOrgMembers` fans a + * delinquent org's block out to every member's own row, so membership in a + * delinquent org blocks you on personal resources too — but the fan-out is a + * point-in-time write and goes stale: nothing marks a member who joins an + * already-blocked org, and `unblockOrgMembers` clears the row even when a second + * delinquent org still covers them. Re-deriving from membership is what makes + * the read agree with the policy in those cases. */ -function isInboxEntitledPlan(plan: string): boolean { - return getPlanTierCredits(plan) >= MAX_PLAN_CREDITS || isPlanEnterprise(plan) +async function hasWorkspaceTierAccess( + workspaceId: string, + isTierEntitled: (plan: string) => boolean, + options: WorkspaceTierAccessOptions = {} +): Promise { + const { intent = 'active-use', onMissingWorkspace = false } = options + + const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') + const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + if (!ws) return onMissingWorkspace + + if (intent === 'retention') { + if (ws.organizationId) { + const { getOrganizationSubscription } = await import('@/lib/billing/core/billing') + const orgSub = await getOrganizationSubscription(ws.organizationId) + return !!orgSub && isTierEntitled(orgSub.plan) + } + + const billedSub = await getHighestPriorityPersonalSubscription(ws.billedAccountUserId) + return !!billedSub && isTierEntitled(billedSub.plan) + } + + if (ws.organizationId) { + const [billingBlocked, orgSub] = await Promise.all([ + isOrganizationBillingBlocked(ws.organizationId), + getOrganizationSubscriptionUsable(ws.organizationId), + ]) + if (!orgSub) return false + if (!hasUsableSubscriptionAccess(orgSub.status, billingBlocked)) return false + return isTierEntitled(orgSub.plan) + } + + const [billedSub, billingStatus] = await Promise.all([ + getHighestPriorityPersonalSubscription(ws.billedAccountUserId), + getEffectiveBillingStatus(ws.billedAccountUserId), + ]) + if (!billedSub) return false + if (!hasUsableSubscriptionAccess(billedSub.status, billingStatus.billingBlocked)) return false + return isTierEntitled(billedSub.plan) +} + +/** + * Whether the workspace's payer is on a usable Max-or-Enterprise subscription. + * Shared by the inbox (Sim Mailer), live sync, and custom sandboxes, which all + * sit on the same entitlement tier. + */ +async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise { + return hasWorkspaceTierAccess(workspaceId, isMaxTier) } /** @@ -557,24 +632,7 @@ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise { try { if (!isHosted || !isBillingEnabled) return true - - const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') - const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) - if (!ws) return false - - if (ws.organizationId) { - const [billingBlocked, orgSub] = await Promise.all([ - isOrganizationBillingBlocked(ws.organizationId), - getOrganizationSubscriptionUsable(ws.organizationId), - ]) - if (!orgSub) return false - if (!hasUsableSubscriptionAccess(orgSub.status, billingBlocked)) return false - return isInboxEntitledPlan(orgSub.plan) - } - - const [billedSub, billedStatusRows] = await Promise.all([ - getHighestPriorityPersonalSubscription(ws.billedAccountUserId), - db - .select({ billingBlocked: userStats.billingBlocked }) - .from(userStats) - .where(eq(userStats.userId, ws.billedAccountUserId)) - .limit(1), - ]) - if (!billedSub) return false - if ( - !hasUsableSubscriptionAccess(billedSub.status, Boolean(billedStatusRows[0]?.billingBlocked)) - ) { - return false - } - return isInboxEntitledPlan(billedSub.plan) + return await hasMaxTierWorkspaceAccess(workspaceId) } catch (error) { logger.error('Error checking workspace live sync access', { error, workspaceId }) return false } } +/** + * Checks whether the exact workspace payer can create and edit custom sandboxes. + * + * Same entitlement as the inbox (Sim Mailer), and the same shape: the + * `SANDBOXES_ENABLED` self-hosted override wins first, then a deployment + * without billing is unrestricted, and otherwise the workspace payer must hold + * a usable Max or Enterprise subscription. Builds cost provider compute and + * storage, so this deliberately sits above the plain paid tier. + * + * This gates creating and editing only. Execution deliberately does not consult + * it (see `resolveWorkspaceSandbox`), so a workspace that downgrades keeps + * running the sandboxes it already built. + */ +export async function hasWorkspaceSandboxAccess(workspaceId: string): Promise { + try { + if (isSandboxesEnabled) return true + if (!isBillingEnabled) return true + return await hasMaxTierWorkspaceAccess(workspaceId) + } catch (error) { + logger.error('Error checking workspace sandbox access', { error, workspaceId }) + return false + } +} + /** * Send welcome email for Pro and Team plan subscriptions */ diff --git a/apps/sim/lib/billing/core/workspace-access.ts b/apps/sim/lib/billing/core/workspace-access.ts index 7625dca085..2470c4a866 100644 --- a/apps/sim/lib/billing/core/workspace-access.ts +++ b/apps/sim/lib/billing/core/workspace-access.ts @@ -31,6 +31,10 @@ export interface WorkspaceOwnerSubscriptionAccess { /** * Resolves the workspace-selected organization or personal payer and returns * its exact subscription access fields. + * + * Block state comes from {@link getBillingEntityBlockStatus}, so a personal + * payer who is blocked only through a delinquent organization loses paid access + * here too — the same answer the server-side entitlement gates give. */ export async function getWorkspaceOwnerSubscriptionAccess( workspaceId: string diff --git a/apps/sim/lib/billing/enterprise-provisioning.ts b/apps/sim/lib/billing/enterprise-provisioning.ts index 368f713530..73e9e358ab 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.ts @@ -21,10 +21,11 @@ import { import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock' import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { withEnterpriseReconciliationLease } from '@/lib/billing/webhooks/enterprise-reconciliation-lease' import { enqueueOutboxEvent, type OutboxHandler } from '@/lib/core/outbox/service' -const TERMINAL_SUBSCRIPTION_STATUSES = new Set(['canceled', 'incomplete_expired']) +const TERMINAL_STATUSES = new Set(TERMINAL_SUBSCRIPTION_STATUSES) function metadataRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) @@ -33,7 +34,7 @@ function metadataRecord(value: unknown): Record { } function isNonterminalSubscriptionStatus(status: string | null | undefined): boolean { - return !status || !TERMINAL_SUBSCRIPTION_STATUSES.has(status) + return !status || !TERMINAL_STATUSES.has(status) } function subscriptionOrganizationId(metadata: unknown): string | null { diff --git a/apps/sim/lib/billing/max-tier-parity.test.ts b/apps/sim/lib/billing/max-tier-parity.test.ts new file mode 100644 index 0000000000..23f488f246 --- /dev/null +++ b/apps/sim/lib/billing/max-tier-parity.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getSubscriptionAccessState } from '@/lib/billing/client/utils' +import { MAX_TIER_CREDITS } from '@/lib/billing/constants' +import { getPlanTierCredits, isMaxTier } from '@/lib/billing/plan-helpers' + +/** + * Every plan name the product can put on a subscription row. + * + * `pro`/`team` are the legacy bare names; the rest are the `{type}_{credits}` + * form. A new tier added to `CREDIT_TIERS` must be added here too. + */ +const ALL_PLANS = [ + 'free', + 'pro', + 'pro_6000', + 'pro_25000', + 'team', + 'team_6000', + 'team_25000', + 'enterprise', +] as const + +/** + * `isMaxTier` is the sole definition of the Max entitlement. It is consumed by the + * server gates (`hasWorkspaceInboxAccess`, `hasWorkspaceLiveSyncAccess`, + * `hasWorkspaceSandboxAccess`) and by the client `hasUsableMaxAccess` that decides + * whether the settings sidebar renders Sandboxes and Sim Mailer unlocked. + * + * Those two must agree for every plan. When they disagreed, the sidebar offered + * features the API answered 403 for, and the personal-workspace cap inverted so + * Max-for-Teams and Enterprise got a smaller allowance than plain Pro. + */ +describe('Max tier parity', () => { + it('admits exactly the plans at or above the Max credit tier, plus enterprise', () => { + const maxPlans = ALL_PLANS.filter((plan) => isMaxTier(plan)) + + expect(maxPlans).toEqual(['pro_25000', 'team_25000', 'enterprise']) + }) + + it('includes both the individual and team plan at the Max credit allocation', () => { + expect(isMaxTier('pro_25000')).toBe(true) + expect(isMaxTier('team_25000')).toBe(true) + }) + + it('treats enterprise as Max even though it carries no credit suffix', () => { + expect(getPlanTierCredits('enterprise')).toBe(0) + expect(isMaxTier('enterprise')).toBe(true) + }) + + it('excludes every plan below the Max credit allocation', () => { + for (const plan of ['free', 'pro', 'pro_6000', 'team', 'team_6000']) { + expect(isMaxTier(plan)).toBe(false) + } + }) + + it('derives its threshold from the tier table rather than a literal', () => { + expect(isMaxTier(`pro_${MAX_TIER_CREDITS}`)).toBe(true) + expect(isMaxTier(`pro_${MAX_TIER_CREDITS - 1}`)).toBe(false) + }) + + it('handles a missing plan without throwing', () => { + expect(isMaxTier(null)).toBe(false) + expect(isMaxTier(undefined)).toBe(false) + expect(isMaxTier('')).toBe(false) + }) + + /** + * The client gate layers subscription status and billing-blocked state on top of + * the tier predicate, so with an active, unblocked payer the two must return the + * same answer for every plan. A divergence here is the render-unlocked-then-403 + * bug class. + */ + it('matches the client hasUsableMaxAccess derivation for every plan', () => { + for (const plan of ALL_PLANS) { + const client = getSubscriptionAccessState({ + plan, + status: 'active', + isPaid: true, + billingBlocked: false, + }) + + expect(client.hasUsableMaxAccess, `plan ${plan}`).toBe(isMaxTier(plan)) + } + }) + + it('denies Max on the client for a blocked or non-active payer even at the Max tier', () => { + expect( + getSubscriptionAccessState({ + plan: 'pro_25000', + status: 'active', + isPaid: true, + billingBlocked: true, + }).hasUsableMaxAccess + ).toBe(false) + + expect( + getSubscriptionAccessState({ + plan: 'pro_25000', + status: 'past_due', + isPaid: true, + billingBlocked: false, + }).hasUsableMaxAccess + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/billing/plan-helpers.ts b/apps/sim/lib/billing/plan-helpers.ts index e905c67139..99ef6382d9 100644 --- a/apps/sim/lib/billing/plan-helpers.ts +++ b/apps/sim/lib/billing/plan-helpers.ts @@ -16,6 +16,7 @@ import { CREDIT_TIERS, DEFAULT_PRO_TIER_COST_LIMIT, DEFAULT_TEAM_TIER_COST_LIMIT, + MAX_TIER_CREDITS, } from '@/lib/billing/constants' export type PlanCategory = 'free' | 'pro' | 'team' | 'enterprise' @@ -25,8 +26,19 @@ export function isPro(plan: string | null | undefined): boolean { return plan === 'pro' || plan.startsWith('pro_') } -export function isMax(plan: string | null | undefined): boolean { - return isPro(plan) && getPlanTierCredits(plan) >= 25000 +/** + * Whether a plan is Max tier or above: any paid plan at or above the Max credit + * allocation (covering both `pro_25000` and `team_25000`), or any enterprise plan. + * + * Tier-only — subscription status and billing-blocked state are the caller's + * responsibility. This is the single definition of "Max" in the codebase: the + * server feature gates (inbox, live sync, sandboxes), the client + * `hasUsableMaxAccess` derivation, and the personal-workspace cap all route + * through it, so a Max-gated surface can never render unlocked against a server + * that will refuse it. + */ +export function isMaxTier(plan: string | null | undefined): boolean { + return getPlanTierCredits(plan) >= MAX_TIER_CREDITS || isEnterprise(plan) } export function isTeam(plan: string | null | undefined): boolean { @@ -107,7 +119,7 @@ export function getPlanType(plan: string | null | undefined): PlanCategory { export function getPlanTypeForLimits(plan: string | null | undefined): PlanCategory { if (plan === 'pro' || plan === 'team') return getPlanType(plan) if (isPro(plan) || isTeam(plan)) { - return getPlanTierCredits(plan) >= 25000 ? 'team' : 'pro' + return getPlanTierCredits(plan) >= MAX_TIER_CREDITS ? 'team' : 'pro' } return getPlanType(plan) } @@ -136,13 +148,17 @@ export function getValidPlanNames(type: 'pro' | 'team'): string[] { /** * SQL-level plan filters for Drizzle queries. * These are the SQL equivalents of the JS helpers above. + * + * The `_` in the plan-name separator is escaped because it is a single-character + * wildcard in SQL `LIKE`. Unescaped, `'pro_%'` would also match `proX…`, making + * these filters admit a wider set than their JS counterparts. */ export function sqlIsPro(column: AnyColumn): SQL | undefined { - return or(eq(column, 'pro'), like(column, 'pro_%')) + return or(eq(column, 'pro'), like(column, 'pro\\_%')) } export function sqlIsTeam(column: AnyColumn): SQL | undefined { - return or(eq(column, 'team'), like(column, 'team_%')) + return or(eq(column, 'team'), like(column, 'team\\_%')) } export function sqlIsPaid(column: AnyColumn): SQL | undefined { diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index 8dcdcd52cd..cdd2d96088 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -57,7 +57,7 @@ vi.mock('@/lib/billing/stripe-client', () => stripeClientMock) vi.mock('@/lib/billing/stripe-payment-method', () => stripePaymentMethodMock) vi.mock('@/lib/billing/subscriptions/utils', () => ({ - ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing', 'past_due'], + ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'past_due'], })) vi.mock('@/lib/billing/utils/decimal', () => ({ diff --git a/apps/sim/lib/core/config/enterprise-entitlements.ts b/apps/sim/lib/core/config/enterprise-entitlements.ts index ea82fea86c..9ab83057f3 100644 --- a/apps/sim/lib/core/config/enterprise-entitlements.ts +++ b/apps/sim/lib/core/config/enterprise-entitlements.ts @@ -33,6 +33,7 @@ export type EnterpriseFeature = | 'forking' | 'inbox' | 'organizations' + | 'sandboxes' | 'sessionPolicies' | 'sso' | 'whitelabeling' @@ -58,6 +59,12 @@ export type EnterpriseFeature = * delete pass is gated here. Defaulting it on would start expiring logs on * upgrade against plan defaults the operator never chose. * + * `sandboxes` is `true` for the mirror-image reason: its gate already returns + * true whenever billing is off, exactly like `inbox`. A `false` here would make + * the settings-nav override disagree with the gate that actually answers the + * request. Self-hosted builds run on the operator's own E2B/Daytona + * credentials, so there is no Sim-side cost to withhold. + * * Do not "tidy" these to a uniform value. Each records observed prior behavior, * and changing one silently alters a live deployment on upgrade. */ @@ -69,6 +76,7 @@ export const ENTERPRISE_FEATURE_LEGACY_DEFAULTS: Readonly { + const key = `route:${bucketName}:workspace:${workspaceId}` + const { allowed, resetAt } = await rateLimiter.checkRateLimitDirect(key, config) + if (allowed) return null + logger.warn('Workspace rate limit exceeded', { bucket: bucketName, workspaceId }) + return buildRateLimitResponse(resetAt) +} + /** * Apply a per-user limit when a userId is present, else fall back to per-IP. * Use for routes whose auth path may legitimately resolve without a userId diff --git a/apps/sim/lib/execution/remote-sandbox/build-errors.ts b/apps/sim/lib/execution/remote-sandbox/build-errors.ts new file mode 100644 index 0000000000..16ca66c632 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/build-errors.ts @@ -0,0 +1,154 @@ +import { registryFor, type SandboxLanguage } from '@/lib/execution/remote-sandbox/sandbox-spec' + +/** + * Classified reasons a dependency set failed to materialize. The code is what + * gets stored and reported on; the rendered copy is what the settings UI shows, + * so a user never meets a raw pip traceback as the primary message. + */ +export type SandboxBuildErrorCode = + | 'package_not_found' + | 'version_not_found' + | 'resolution_conflict' + | 'build_timeout' + | 'install_failed' + | 'provider_error' + | 'rate_limited' + +export interface SandboxBuildError { + code: SandboxBuildErrorCode + /** User-facing copy. Never a traceback. */ + message: string + /** The dependency the failure named, when one could be extracted. */ + dependency?: string +} + +/** How much installer output to keep as the disclosure detail. */ +export const BUILD_LOG_TAIL_CHARS = 4000 + +/** + * Keeps the tail of installer output rather than the head: pip and npm print the + * actual failure last, after pages of resolution noise. + */ +export function tailBuildLog(output: string): string { + const trimmed = output.trim() + if (trimmed.length <= BUILD_LOG_TAIL_CHARS) return trimmed + return `…\n${trimmed.slice(-BUILD_LOG_TAIL_CHARS)}` +} + +interface Matcher { + code: SandboxBuildErrorCode + match: RegExp + render: (language: SandboxLanguage, groups: RegExpMatchArray) => SandboxBuildError +} + +/** + * Ordered installer-output matchers. Order matters: a version miss also mentions + * the package name, so the more specific pattern has to win. + */ +const MATCHERS: readonly Matcher[] = [ + { + // `Could not find a version that satisfies the requirement foo==9 (from versions: 1, 2)` + code: 'version_not_found', + match: + /could not find a version that satisfies the requirement ([^\s(]+)[^\n(]*\(from versions:\s*([^)]*)\)/i, + render: (language, groups) => { + const available = groups[2]?.trim() + const known = available && available.toLowerCase() !== 'none' + if (!known) { + return { + code: 'package_not_found', + dependency: groups[1], + message: `Package "${groups[1]}" was not found on ${registryFor(language)}.`, + } + } + return { + code: 'version_not_found', + dependency: groups[1], + message: `"${groups[1]}" has no version matching that specifier. Available: ${available}.`, + } + }, + }, + { + // npm: `notarget No matching version found for axios@^99.0.0` + code: 'version_not_found', + match: /no matching version found for (\S+)/i, + render: (_language, groups) => ({ + code: 'version_not_found', + dependency: groups[1], + message: `"${groups[1]}" has no version matching that specifier.`, + }), + }, + { + // npm: `404 Not Found - GET https://registry.npmjs.org/does-not-exist` + code: 'package_not_found', + match: /404 not found[^\n]*?\/([^/\s'"]+)\s*$/im, + render: (language, groups) => ({ + code: 'package_not_found', + dependency: groups[1], + message: `Package "${groups[1]}" was not found on ${registryFor(language)}.`, + }), + }, + { + code: 'package_not_found', + match: /no matching distribution found for (\S+)/i, + render: (language, groups) => ({ + code: 'package_not_found', + dependency: groups[1], + message: `Package "${groups[1]}" was not found on ${registryFor(language)}.`, + }), + }, + { + code: 'resolution_conflict', + match: + /(resolutionimpossible|conflicting dependencies|eresolve unable to resolve dependency tree|the conflict is caused by)/i, + render: () => ({ + code: 'resolution_conflict', + message: + 'These dependencies require incompatible versions of a shared package. Relax or remove a version pin.', + }), + }, + { + code: 'rate_limited', + match: /\b429\b|too many requests|rate ?limit/i, + render: (language) => ({ + code: 'rate_limited', + message: `${registryFor(language)} rate-limited the install. Try again in a few minutes.`, + }), + }, +] + +/** + * Maps raw installer output onto the taxonomy. Anything unrecognized falls back + * to `install_failed` with the log retained rather than being dropped — an + * unclassifiable failure is still a failure the user has to be able to debug. + */ +export function classifyInstallOutput( + language: SandboxLanguage, + output: string +): SandboxBuildError { + for (const matcher of MATCHERS) { + const groups = output.match(matcher.match) + if (groups) return matcher.render(language, groups) + } + return { + code: 'install_failed', + message: 'Installation failed. See the log below.', + } +} + +/** Copy for the failures that have no installer output to classify. */ +export function buildTimeoutError(minutes: number): SandboxBuildError { + return { + code: 'build_timeout', + message: `The build took longer than ${minutes} minutes and was stopped.`, + } +} + +export function providerBuildError(detail?: string): SandboxBuildError { + return { + code: 'provider_error', + message: detail + ? `The sandbox provider rejected the build. Try again shortly. (${detail})` + : 'The sandbox provider rejected the build. Try again shortly.', + } +} diff --git a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts index 5c3f0adb39..1fbb85b74b 100644 --- a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts @@ -10,6 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CodeLanguage } from '@/lib/execution/languages' const { + mockResolveSandbox, + mockProvisionRuntime, mockEnv, mockE2BCreate, mockE2BRunCode, @@ -30,6 +32,8 @@ const { mockGetSessionCommand, mockDeleteSession, } = vi.hoisted(() => ({ + mockResolveSandbox: vi.fn(), + mockProvisionRuntime: vi.fn(), mockEnv: { SANDBOX_PROVIDER: 'e2b' as string | undefined, PI_SANDBOX_LIFETIME_MS: undefined as string | undefined, @@ -69,6 +73,12 @@ vi.mock('@daytona/sdk', () => ({ }, })) vi.mock('@/lib/core/config/env', () => ({ env: mockEnv })) +vi.mock('@/lib/execution/remote-sandbox/resolve', () => ({ + resolveWorkspaceSandbox: mockResolveSandbox, + provisionRuntimeDependencies: mockProvisionRuntime, + invalidateSandboxResolution: vi.fn(), + RUNTIME_INSTALL_TIMEOUT_MS: 240_000, +})) import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { @@ -77,6 +87,8 @@ import { SIM_RESULT_PREFIX, withPiSandbox, } from '@/lib/execution/remote-sandbox' +import { daytonaProvider } from '@/lib/execution/remote-sandbox/daytona' +import { e2bProvider } from '@/lib/execution/remote-sandbox/e2b' import { PI_SANDBOX_MAX_LIFETIME_MS, PI_SANDBOX_MIN_LIFETIME_MS, @@ -132,6 +144,8 @@ beforeEach(() => { delete: mockDelete, }) mockExecuteCommand.mockResolvedValue({ result: '', exitCode: 0 }) + mockResolveSandbox.mockResolvedValue(null) + mockProvisionRuntime.mockResolvedValue(undefined) mockExecuteSessionCommand.mockResolvedValue({ cmdId: 'cmd_1' }) mockGetSessionCommand.mockResolvedValue({ exitCode: 0 }) }) @@ -322,6 +336,149 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { }) }) +describe('custom dependency sets', () => { + it.each(PROVIDERS)('honors imageRef for code executions [%s]', async (provider) => { + useProvider(provider) + stubCodeRun(provider, `${SIM_RESULT_PREFIX}null`) + mockResolveSandbox.mockResolvedValue({ + id: 'sbx-1', + name: 'etl', + language: CodeLanguage.Python, + dependencies: ['pandas'], + strategy: 'prebuilt', + imageRef: 'sim-sbx-abc', + }) + + await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + if (provider === 'e2b') { + expect(mockE2BCreate).toHaveBeenCalledWith('sim-sbx-abc', expect.anything()) + } else { + expect(mockDaytonaCreate).toHaveBeenCalledWith( + expect.objectContaining({ snapshot: 'sim-sbx-abc' }) + ) + } + }) + + it.each(PROVIDERS)('refuses to let a sandbox displace the doc image [%s]', async (provider) => { + useProvider(provider) + stubCodeRun(provider, `${SIM_RESULT_PREFIX}null`) + // Even if resolution somehow yields a ref, the provider gates on the kind. + mockResolveSandbox.mockResolvedValue({ + id: 'sbx-1', + name: 'etl', + language: CodeLanguage.Python, + dependencies: ['pandas'], + strategy: 'prebuilt', + imageRef: 'sim-sbx-abc', + }) + + await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 1000, + sandboxKind: 'doc', + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + if (provider === 'e2b') { + expect(mockE2BCreate).toHaveBeenCalledWith('mothership-docs', expect.anything()) + } else { + expect(mockDaytonaCreate).toHaveBeenCalledWith( + expect.objectContaining({ snapshot: 'mothership-docs:v1' }) + ) + } + }) + + it.each(PROVIDERS)( + 'spends the runtime install out of the caller budget, not on top of it [%s]', + async (provider) => { + useProvider(provider) + stubCodeRun(provider, `${SIM_RESULT_PREFIX}null`) + mockResolveSandbox.mockResolvedValue({ + id: 'sbx-1', + name: 'etl', + language: CodeLanguage.Python, + dependencies: ['pandas'], + strategy: 'runtime', + }) + + // Well under the 240s ceiling, so the caller's budget is what binds. + await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 60_000, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + // 60s budget minus the 15s reserved for the code itself. + expect(mockProvisionRuntime).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + timeoutMs: 45_000, + }) + } + ) + + it.each(PROVIDERS)( + 'caps the install at its own ceiling when the budget is generous [%s]', + async (provider) => { + useProvider(provider) + stubCodeRun(provider, `${SIM_RESULT_PREFIX}null`) + mockResolveSandbox.mockResolvedValue({ + id: 'sbx-1', + name: 'etl', + language: CodeLanguage.Python, + dependencies: ['pandas'], + strategy: 'runtime', + }) + + await executeInSandbox({ + code: 'x', + language: CodeLanguage.Python, + timeoutMs: 900_000, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + expect(mockProvisionRuntime).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + timeoutMs: 240_000, + }) + } + ) + + it.each(PROVIDERS)('uses the env template when nothing is selected [%s]', async (provider) => { + useProvider(provider) + stubCodeRun(provider, `${SIM_RESULT_PREFIX}null`) + + await executeInSandbox({ code: 'x', language: CodeLanguage.Python, timeoutMs: 1000 }) + + if (provider === 'e2b') { + expect(mockE2BCreate).toHaveBeenCalledWith('mothership-shell', expect.anything()) + } else { + expect(mockDaytonaCreate).toHaveBeenCalledWith( + expect.objectContaining({ snapshot: 'mothership-shell:v1' }) + ) + } + // No selection means no install step, on either strategy. + expect(mockE2BCommandsRun).not.toHaveBeenCalled() + expect(mockExecuteCommand).not.toHaveBeenCalled() + }) + + it('declares one strategy per provider, and only the prebuilt one can build', () => { + expect(e2bProvider.dependencyStrategy).toBe('prebuilt') + expect(e2bProvider.images).toBeDefined() + expect(daytonaProvider.dependencyStrategy).toBe('runtime') + expect(daytonaProvider.images).toBeUndefined() + }) +}) + describe('provider selection', () => { it('routes by SANDBOX_PROVIDER, defaulting to E2B when unset', async () => { stubCodeRun('e2b', `${SIM_RESULT_PREFIX}null`) diff --git a/apps/sim/lib/execution/remote-sandbox/daytona.ts b/apps/sim/lib/execution/remote-sandbox/daytona.ts index c171075775..3f2c904747 100644 --- a/apps/sim/lib/execution/remote-sandbox/daytona.ts +++ b/apps/sim/lib/execution/remote-sandbox/daytona.ts @@ -20,7 +20,13 @@ function toSeconds(timeoutMs: number): number { return Math.max(1, Math.ceil(timeoutMs / 1000)) } -function snapshotFor(kind: SandboxKind): string { +function snapshotFor(kind: SandboxKind, imageRef?: string): string { + // An operator-supplied snapshot may only displace the general shell image. + // `doc` and `pi` keep their vetted snapshots unconditionally, so nothing a + // workspace configures can land under the doc compiler or the coding agent. + if (imageRef && (kind === 'code' || kind === 'shell')) { + return imageRef + } // Mirrors the E2B provider's fail-closed behaviour: never let LLM-authored code // run in a provider default image just because a snapshot id is unset. const snapshot = @@ -56,7 +62,10 @@ class DaytonaSandboxHandle implements SandboxHandle { return this.sandbox.id } - async runCode(code: string, options: { timeoutMs: number }): Promise { + async runCode( + code: string, + options: { timeoutMs: number; envs?: Record } + ): Promise { // Python goes through CodeInterpreter because it reports a structured // `{ name, value, traceback }` error — the same shape E2B returns, which the // route's line-offset error formatting depends on. CodeInterpreter is @@ -65,6 +74,7 @@ class DaytonaSandboxHandle implements SandboxHandle { if (this.language === CodeLanguage.Python) { const result = await this.sandbox.codeInterpreter.runCode(code, { timeout: toSeconds(options.timeoutMs), + ...(options.envs ? { envs: options.envs } : {}), }) return { text: '', @@ -80,7 +90,11 @@ class DaytonaSandboxHandle implements SandboxHandle { } } - const result = await this.sandbox.process.codeRun(code, undefined, toSeconds(options.timeoutMs)) + const result = await this.sandbox.process.codeRun( + code, + options.envs ? { env: options.envs } : undefined, + toSeconds(options.timeoutMs) + ) const output: string = result.result ?? '' if (result.exitCode !== 0) { // `process.codeRun` has no structured error channel — the interpreter's @@ -242,14 +256,24 @@ function lastNonEmptyLine(output: string): string { return lines.length > 0 ? lines[lines.length - 1] : 'Execution failed' } +/** + * Daytona takes the `runtime` strategy and exposes no image builder. + * + * Prebuilt images are not available at any tier: the 30-active-snapshot quota + * lives on the organization rather than the plan, snapshots auto-deactivate + * after 14 days unused, and raising the quota needs Daytona support. Since + * builds are content-addressed and shared, 30 would be the ceiling for all of + * Sim rather than per workspace. Installing per execution consumes no quota. + */ export const daytonaProvider: SandboxProvider = { id: 'daytona', + dependencyStrategy: 'runtime', async create(kind: SandboxKind, options?: CreateSandboxOptions): Promise { const apiKey = env.DAYTONA_API_KEY if (!apiKey) { throw new Error('DAYTONA_API_KEY is required when the Daytona sandbox provider is selected') } - const snapshot = snapshotFor(kind) + const snapshot = snapshotFor(kind, options?.imageRef) const language = options?.language ?? CodeLanguage.Python logger.info('Creating Daytona sandbox', { kind, snapshot }) diff --git a/apps/sim/lib/execution/remote-sandbox/e2b.ts b/apps/sim/lib/execution/remote-sandbox/e2b.ts index 2f5dd11934..24f6041d68 100644 --- a/apps/sim/lib/execution/remote-sandbox/e2b.ts +++ b/apps/sim/lib/execution/remote-sandbox/e2b.ts @@ -1,21 +1,68 @@ -import type { Sandbox as E2BSandbox } from '@e2b/code-interpreter' +import type { Sandbox as E2BSandbox, Template as E2BTemplate } from '@e2b/code-interpreter' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { env } from '@/lib/core/config/env' import { CodeLanguage } from '@/lib/execution/languages' +import { classifyInstallOutput, tailBuildLog } from '@/lib/execution/remote-sandbox/build-errors' +import { + quoteDependency, + type SandboxSpec, + SIM_DEPS_DIR, + SIM_NODE_MODULES_DIR, +} from '@/lib/execution/remote-sandbox/sandbox-spec' import type { CreateSandboxOptions, RunCommandOptions, SandboxCodeResult, SandboxCommandResult, SandboxHandle, + SandboxImageBuild, + SandboxImageBuilder, + SandboxImageBuildStatus, SandboxKind, SandboxProvider, } from '@/lib/execution/remote-sandbox/types' const logger = createLogger('E2BSandboxProvider') -function templateFor(kind: SandboxKind): string | undefined { +/** The `Template` callable, threaded in so the SDK stays a dynamic import. */ +type TemplateFactory = typeof E2BTemplate + +/** + * Prefix for content-addressed dependency templates. Refs are account-global, so + * the prefix keeps them clearly attributable and out of the way of hand-built + * templates. + */ +const SANDBOX_TEMPLATE_PREFIX = 'sim-sbx-' + +/** + * How much of the spec hash goes into the ref. Template names are one namespace + * across the whole E2B account, and nothing detects a collision — two specs + * sharing a prefix would build to the same name and silently swap package sets + * between workspaces. 32 hex chars (128 bits) puts that out of reach. + */ +const SPEC_HASH_REF_LENGTH = 32 + +export function sandboxImageRef(specHash: string): string { + return `${SANDBOX_TEMPLATE_PREFIX}${specHash.slice(0, SPEC_HASH_REF_LENGTH)}` +} + +/** + * E2B's control-plane base. `ConnectionConfig` derives this internally but is + * not re-exported by `@e2b/code-interpreter`, and `e2b` itself is only a + * transitive dependency, so the one endpoint the SDK omits resolves it here. + */ +function e2bApiUrl(): string { + return `https://api.${env.E2B_DOMAIN || 'e2b.app'}` +} + +function templateFor(kind: SandboxKind, imageRef?: string): string | undefined { + // A workspace dependency set may only displace the general shell template. + // `doc` and `pi` keep their vetted images unconditionally, so a user's package + // list can never land under the document compiler or the coding agent. + if (imageRef && (kind === 'code' || kind === 'shell')) { + return imageRef + } // Document generation uses a dedicated template (python-pptx/docx/openpyxl/ // reportlab + fonts); shell/code execution use the general shell template. // Doc fails closed: never run LLM-authored Python in E2B's default template @@ -47,10 +94,14 @@ class E2BSandboxHandle implements SandboxHandle { return this.sandbox.sandboxId } - async runCode(code: string, options: { timeoutMs: number }): Promise { + async runCode( + code: string, + options: { timeoutMs: number; envs?: Record } + ): Promise { const execution = await this.sandbox.runCode(code, { language: this.language === CodeLanguage.Python ? 'python' : 'javascript', timeoutMs: options.timeoutMs, + ...(options.envs ? { envs: options.envs } : {}), }) // Kernel stream entries are chunks, not lines — each already carries its own @@ -111,14 +162,151 @@ class E2BSandboxHandle implements SandboxHandle { } } +/** + * Composes the dependency layer over the general shell template. `fromTemplate` + * means each build stacks only the new layer and inherits everything else, which + * is what makes a template per workspace sandbox cheap. + * + * Dependencies reach the installer as individually quoted argv entries. The SDK + * renders `pipInstall`/`npmInstall` by joining packages with spaces into one + * shell string, so an unquoted `django>=5.0` would redirect stdout into a file + * named `=5.0` and silently install an unpinned Django — hence + * {@link quoteDependency} and the hand-composed `runCmd`. + */ +function composeDependencyTemplate( + spec: SandboxSpec, + template: TemplateFactory, + baseTemplate: string +) { + const packages = spec.dependencies.map(quoteDependency).join(' ') + + if (spec.language === CodeLanguage.Python) { + return template() + .fromTemplate(baseTemplate) + .runCmd(`pip install --no-input --disable-pip-version-check ${packages}`, { user: 'root' }) + } + + // Unlike pip, an npm install is not automatically importable — Node resolves + // from NODE_PATH or the install location. Installing into a fixed prefix and + // baking NODE_PATH is what makes `require`/`import` find these packages. + return template() + .fromTemplate(baseTemplate) + .makeDir(SIM_DEPS_DIR, { user: 'root' }) + .runCmd(`npm install --prefix ${SIM_DEPS_DIR} --no-audit --no-fund --omit=dev ${packages}`, { + user: 'root', + }) + .setEnvs({ NODE_PATH: SIM_NODE_MODULES_DIR }) +} + +const e2bImages: SandboxImageBuilder = { + async startBuild(spec: SandboxSpec, specHash: string): Promise { + const apiKey = env.E2B_API_KEY + if (!apiKey) { + throw new Error('E2B_API_KEY is required to build a sandbox image') + } + // Resolved before the dynamic import so a misconfigured deployment fails + // without ever reaching the network. + const baseTemplate = env.MOTHERSHIP_E2B_TEMPLATE_ID + if (!baseTemplate) { + throw new Error('Sandbox builds are not configured (MOTHERSHIP_E2B_TEMPLATE_ID is unset)') + } + const imageRef = sandboxImageRef(specHash) + + const { Template } = await import('@e2b/code-interpreter') + const template = composeDependencyTemplate(spec, Template, baseTemplate) + const build = await Template.buildInBackground(template, imageRef, { apiKey }) + + logger.info('Started E2B sandbox image build', { + imageRef, + buildId: build.buildId, + language: spec.language, + dependencyCount: spec.dependencies.length, + }) + return { imageRef, buildId: build.buildId, providerImageId: build.templateId } + }, + + async getBuildStatus( + build: SandboxImageBuild, + spec: SandboxSpec + ): Promise { + const apiKey = env.E2B_API_KEY + if (!apiKey) { + throw new Error('E2B_API_KEY is required to poll a sandbox image build') + } + const { Template } = await import('@e2b/code-interpreter') + const status = await Template.getBuildStatus( + { templateId: build.providerImageId ?? build.imageRef, buildId: build.buildId }, + { apiKey } + ) + + const logs = status.logEntries.map((entry) => entry.message).join('\n') + if (status.status === 'ready') return { status: 'ready' } + if (status.status === 'error') { + return { + status: 'failed', + error: classifyInstallOutput(spec.language, `${status.reason?.message ?? ''}\n${logs}`), + logs: tailBuildLog(logs), + } + } + return { status: 'building' } + }, + + /** + * The SDK surfaces no template delete, so the retention sweep calls the REST + * endpoint directly. A non-2xx throws so the caller leaves the registry row in + * place and retries, rather than orphaning the remote template. + */ + /** + * E2B maps a 404 from the control plane to `NotFoundError`, and the only resource + * a create request names is the template — so a 404 there means the ref is gone. + * + * Its two subclasses are excluded because they describe other calls entirely: a + * missing file inside a running sandbox, or a sandbox that has already exited. + * Neither is reachable from a create. Everything else — auth, rate limit, + * transport — is deliberately not a missing image, since rebuilding on those + * would turn a provider outage into a build storm. + */ + async isMissingImage(error: unknown): Promise { + const { FileNotFoundError, NotFoundError, SandboxNotFoundError } = await import( + '@e2b/code-interpreter' + ) + return ( + error instanceof NotFoundError && + !(error instanceof SandboxNotFoundError) && + !(error instanceof FileNotFoundError) + ) + }, + + async deleteImage(build: SandboxImageBuild): Promise { + const apiKey = env.E2B_API_KEY + if (!apiKey) { + throw new Error('E2B_API_KEY is required to delete a sandbox image') + } + const templateId = build.providerImageId ?? build.imageRef + const response = await fetch(`${e2bApiUrl()}/templates/${encodeURIComponent(templateId)}`, { + method: 'DELETE', + headers: { 'X-API-KEY': apiKey }, + // The retention sweep deletes sequentially; without this one hung + // connection to the control plane stalls the whole cron handler. + signal: AbortSignal.timeout(30_000), + }) + // A template that is already gone is the state we wanted. + if (!response.ok && response.status !== 404) { + throw new Error(`E2B refused to delete template ${templateId} (${response.status})`) + } + }, +} + export const e2bProvider: SandboxProvider = { id: 'e2b', + dependencyStrategy: 'prebuilt', + images: e2bImages, async create(kind: SandboxKind, options?: CreateSandboxOptions): Promise { const apiKey = env.E2B_API_KEY if (!apiKey) { throw new Error('E2B_API_KEY is required when E2B is enabled') } - const templateName = templateFor(kind) + const templateName = templateFor(kind, options?.imageRef) logger.info('Creating E2B sandbox', { kind, template: templateName || '(default)' }) // E2B reaps a sandbox after `timeoutMs` (default five minutes). Omitted diff --git a/apps/sim/lib/execution/remote-sandbox/image-registry.test.ts b/apps/sim/lib/execution/remote-sandbox/image-registry.test.ts new file mode 100644 index 0000000000..c3b970684d --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/image-registry.test.ts @@ -0,0 +1,438 @@ +/** + * @vitest-environment node + * + * Builds are addressed by content and shared across workspaces, so releasing one + * eagerly is only safe while nothing else references it. These cases pin that + * guard down, plus the failure modes that must leave the retention sweep a job to + * finish rather than losing the image silently. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDelete, mockInsert, mockSelect, mockDeleteImage, mockProviderStrategy } = vi.hoisted( + () => ({ + mockDelete: vi.fn(), + mockInsert: vi.fn(), + mockSelect: vi.fn(), + mockDeleteImage: vi.fn(), + mockProviderStrategy: { current: 'prebuilt' as 'prebuilt' | 'runtime' }, + }) +) + +vi.mock('@sim/db', () => ({ + db: { delete: mockDelete, insert: mockInsert, select: mockSelect }, +})) + +vi.mock('@sim/db/schema', () => ({ + sandboxImage: { + id: 'id', + provider: 'provider', + specHash: 'spec_hash', + status: 'status', + imageRef: 'image_ref', + buildId: 'build_id', + providerImageId: 'provider_image_id', + lastUsedAt: 'last_used_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, + workspaceSandbox: { id: 'id', specHash: 'spec_hash' }, +})) + +vi.mock('drizzle-orm', () => ({ + and: (...args: unknown[]) => args, + eq: (...args: unknown[]) => args, + inArray: (...args: unknown[]) => args, + lt: (...args: unknown[]) => args, + notInArray: (...args: unknown[]) => args, + or: (...args: unknown[]) => args, + sql: (...args: unknown[]) => args, +})) + +vi.mock('@/lib/execution/remote-sandbox/provider', () => ({ + resolveProvider: () => ({ + id: 'e2b', + get dependencyStrategy() { + return mockProviderStrategy.current + }, + get images() { + return mockProviderStrategy.current === 'prebuilt' + ? { deleteImage: mockDeleteImage } + : undefined + }, + }), +})) + +import { + cleanupSandboxImages, + ensureSandboxImage, + FAILED_BUILD_RETRY_COOLDOWN_MS, + releaseSandboxImage, + sandboxBuildIdempotencyKey, +} from '@/lib/execution/remote-sandbox/image-registry' + +const READY_IMAGE = { + id: 'img-1', + status: 'ready', + spec: { language: 'python', dependencies: ['pandas'] }, + imageRef: 'sim-sbx-abc', + buildId: 'build-1', + providerImageId: 'tmpl-1', +} + +beforeEach(() => { + vi.clearAllMocks() + mockProviderStrategy.current = 'prebuilt' + mockDelete.mockReturnValue({ where: () => ({ returning: () => Promise.resolve([]) }) }) + mockInsert.mockReturnValue({ values: () => ({ onConflictDoNothing: () => Promise.resolve() }) }) + // Default: nothing re-adopted the hash, so the post-delete rebuild is a no-op and + // cases that are not about it stay on one `delete`. + mockSelect.mockReturnValue({ + from: () => ({ where: () => ({ limit: () => Promise.resolve([]) }) }), + }) + mockDeleteImage.mockResolvedValue(undefined) +}) + +/** Serializes the mocked predicate tree so a clause can be asserted by shape. */ +function predicateText(predicate: unknown): string { + if (predicate == null) return '' + if (Array.isArray(predicate)) return predicate.map(predicateText).join(' ') + if (typeof predicate === 'object') return JSON.stringify(predicate) + return String(predicate) +} + +/** + * True once a build upsert has been issued. Distinguished from the restore path by + * the conflict clause: a rebuild is `onConflictDoUpdate`, a restore is + * `onConflictDoNothing`. + */ +function captureUpsert(): () => boolean { + let seen = false + mockInsert.mockReturnValue({ + values: () => ({ + onConflictDoUpdate: () => { + seen = true + return { returning: () => Promise.resolve([]) } + }, + onConflictDoNothing: () => Promise.resolve(), + }), + }) + return () => seen +} + +/** Captures the conditional-delete predicate and what the claim resolves to. */ +function stubClaim(rows: unknown[]): () => unknown { + let captured: unknown + mockDelete.mockReturnValue({ + where: (predicate: unknown) => { + captured = predicate + return { returning: () => Promise.resolve(rows) } + }, + }) + return () => captured +} + +describe('releaseSandboxImage', () => { + it('deletes the provider image once the row is claimed', async () => { + stubClaim([READY_IMAGE]) + + await releaseSandboxImage('hash-1') + + expect(mockDeleteImage).toHaveBeenCalledWith({ + imageRef: 'sim-sbx-abc', + buildId: 'build-1', + providerImageId: 'tmpl-1', + }) + }) + + /** + * The bystander case: two workspaces declaring the same package list share one + * build, so one workspace's delete must not take the image out from under the + * other. The guard is the conditional delete itself — reading references in a + * separate statement left a window, spanning a provider network call, in which + * another workspace could adopt the hash between the check and the delete. + */ + it('claims only when no sandbox references the hash, in one statement', async () => { + const read = stubClaim([READY_IMAGE]) + + await releaseSandboxImage('hash-1') + + const clause = predicateText(read()) + expect(clause).toContain('not exists') + expect(clause).toContain('workspace_sandbox') + }) + + it('excludes an in-flight build from the claim rather than racing it', async () => { + const read = stubClaim([READY_IMAGE]) + + await releaseSandboxImage('hash-1') + + const clause = predicateText(read()) + expect(clause).toContain('pending') + expect(clause).toContain('building') + }) + + it('touches the provider only when the claim actually took a row', async () => { + stubClaim([]) + + await releaseSandboxImage('hash-1') + + expect(mockDeleteImage).not.toHaveBeenCalled() + }) + + it('no-ops under a runtime provider, which has no images to release', async () => { + mockProviderStrategy.current = 'runtime' + stubClaim([READY_IMAGE]) + + await releaseSandboxImage('hash-1') + + expect(mockDelete).not.toHaveBeenCalled() + expect(mockDeleteImage).not.toHaveBeenCalled() + }) + + /** + * Claiming before the provider call means a refusal would otherwise strand a + * template nothing points at, so the row goes back and the sweep inherits it. + */ + it('restores the claimed row when the provider refuses', async () => { + stubClaim([READY_IMAGE]) + mockDeleteImage.mockRejectedValue(new Error('E2B unreachable')) + + await expect(releaseSandboxImage('hash-1')).resolves.toBeUndefined() + + expect(mockInsert).toHaveBeenCalledTimes(1) + }) + + /** + * The adopter's row is new and healthy-looking, so nothing else would notice the + * template went out from under it — resolution only repairs a missing or + * `failed` row, and a `failed` one waits out the cooldown first. + */ + it('rebuilds a hash re-adopted while the provider delete was in flight', async () => { + stubClaim([READY_IMAGE]) + mockSelect.mockReturnValue({ + from: () => ({ where: () => ({ limit: () => Promise.resolve([{ id: 'img-new' }]) }) }), + }) + const enqueued = captureUpsert() + + await releaseSandboxImage('hash-1') + + expect(mockDeleteImage).toHaveBeenCalledTimes(1) + expect(enqueued()).toBe(true) + }) + + it('does not rebuild when nothing re-adopted the hash', async () => { + stubClaim([READY_IMAGE]) + mockSelect.mockReturnValue({ + from: () => ({ where: () => ({ limit: () => Promise.resolve([]) }) }), + }) + const enqueued = captureUpsert() + + await releaseSandboxImage('hash-1') + + expect(enqueued()).toBe(false) + }) + + /** + * Restoring belongs to a refused delete and nothing else. Once the template is + * gone, putting the row back would recreate a `ready` row pointing at nothing — + * the one state resolution cannot repair. + */ + it('does not restore the row when the post-delete rebuild fails', async () => { + stubClaim([READY_IMAGE]) + mockSelect.mockImplementation(() => { + throw new Error('registry unreachable') + }) + + await releaseSandboxImage('hash-1') + + expect(mockDeleteImage).toHaveBeenCalledTimes(1) + expect(mockInsert).not.toHaveBeenCalled() + }) + + /** + * A row claiming `ready` against a deleted template is the state resolution + * cannot repair, so a rebuild that does not take must not leave one behind. + * Dropping it converts the adopter into the missing-row case, which the next + * execution fixes on its own. + */ + it('drops the dead row when the rebuild cannot be scheduled', async () => { + stubClaim([READY_IMAGE]) + mockSelect.mockImplementation(() => { + throw new Error('registry unreachable') + }) + + await releaseSandboxImage('hash-1') + + // The claim itself plus the dead-row cleanup. + expect(mockDelete).toHaveBeenCalledTimes(2) + }) + + it('skips the provider when the claimed row never had an image', async () => { + stubClaim([{ ...READY_IMAGE, imageRef: null }]) + + await releaseSandboxImage('hash-1') + + expect(mockDeleteImage).not.toHaveBeenCalled() + expect(mockInsert).not.toHaveBeenCalled() + }) +}) + +/** + * The sweep reads a batch of candidates and then works through them a chunk of + * network calls at a time, so minutes can pass between the query and any one + * delete. Whether a row still qualifies has to be decided by the claim, not by + * that earlier read. + */ +describe('cleanupSandboxImages', () => { + const CANDIDATE = { id: 'img-1', specHash: 'hash-1', imageRef: 'sim-sbx-abc' } + + /** + * Nomination query only — later selects (the re-adoption check) resolve empty, so + * a candidate is not mistaken for its own adopter. + */ + function stubCandidates(rows: unknown[]) { + mockSelect.mockReturnValue({ + from: () => ({ where: () => ({ limit: () => Promise.resolve([]) }) }), + }) + mockSelect.mockReturnValueOnce({ + from: () => ({ where: () => ({ limit: () => Promise.resolve(rows) }) }), + }) + } + + it('skips a candidate a workspace adopted after the query ran', async () => { + stubCandidates([CANDIDATE]) + stubClaim([]) + + const result = await cleanupSandboxImages(30) + + expect(mockDeleteImage).not.toHaveBeenCalled() + expect(result).toEqual({ deleted: 0, failed: 0 }) + }) + + it('deletes the image for a candidate that still qualifies at claim time', async () => { + stubCandidates([CANDIDATE]) + stubClaim([READY_IMAGE]) + + const result = await cleanupSandboxImages(30) + + expect(mockDeleteImage).toHaveBeenCalledTimes(1) + expect(result).toEqual({ deleted: 1, failed: 0 }) + }) + + it('restores the row and counts a failure when the provider refuses', async () => { + stubCandidates([CANDIDATE]) + stubClaim([READY_IMAGE]) + mockDeleteImage.mockRejectedValue(new Error('E2B unreachable')) + + const result = await cleanupSandboxImages(30) + + expect(mockInsert).toHaveBeenCalledTimes(1) + expect(result).toEqual({ deleted: 0, failed: 1 }) + }) + + it('keeps the retention cutoff in the claim, not just the candidate query', async () => { + stubCandidates([CANDIDATE]) + const read = stubClaim([READY_IMAGE]) + + await cleanupSandboxImages(30) + + expect(hasTimeBound(read())).toBe(true) + }) +}) + +/** True when any leaf of the mocked predicate tree is a `Date`, i.e. a time bound. */ +function hasTimeBound(predicate: unknown): boolean { + if (predicate instanceof Date) return true + return Array.isArray(predicate) && predicate.some(hasTimeBound) +} + +/** + * The repair path fires once per execution, so re-claiming a failed row on sight + * would let a per-minute schedule enqueue a per-minute build of a package list + * that will never resolve. A save is a person asking again and must not wait. + */ +describe('ensureSandboxImage failed-build cooldown', () => { + const SPEC = { language: 'python' as const, dependencies: ['pandas'] } + + /** Captures the conflict predicate; the empty `returning` means "nothing claimed". */ + function captureSetWhere(): () => unknown { + let captured: unknown + mockInsert.mockReturnValue({ + values: () => ({ + onConflictDoUpdate: (config: { setWhere: unknown }) => { + captured = config.setWhere + return { returning: () => Promise.resolve([]) } + }, + }), + }) + return () => captured + } + + it('bounds the failed branch by time when a cooldown is requested', async () => { + const read = captureSetWhere() + + await ensureSandboxImage(SPEC, 'hash-1', { + minFailureAgeMs: FAILED_BUILD_RETRY_COOLDOWN_MS, + }) + + const [failedBranch] = read() as unknown[] + expect(hasTimeBound(failedBranch)).toBe(true) + }) + + it('leaves the failed branch unbounded for a save, so a person retries at once', async () => { + const read = captureSetWhere() + + await ensureSandboxImage(SPEC, 'hash-1') + + const [failedBranch] = read() as unknown[] + expect(hasTimeBound(failedBranch)).toBe(false) + }) + + /** + * A row that reached `ready` before the delete landed is the permanent case: + * resolution repairs a missing or `failed` row, never one claiming to be ready, + * so its dead `imageRef` would survive until someone re-saved the sandbox. + */ + it('reclaims a ready row when the image is known to be gone', async () => { + const read = captureSetWhere() + + await ensureSandboxImage(SPEC, 'hash-1', { imageKnownGone: true }) + + const branch = predicateText((read() as unknown[])[0]) + expect(branch).toContain('status') + // Excludes only in-flight statuses, so `ready` and `failed` both qualify. + expect(branch).toContain('pending') + expect(branch).not.toContain('failed') + }) +}) + +/** + * The claim already collapses concurrent saves, so the trigger key only has to + * distinguish attempts. Keyed by spec alone it suppressed the next legitimate one + * instead — Trigger.dev returns the finished run, the row stays `pending` with no + * worker, and nothing can re-claim it until it goes stale. + */ +describe('sandboxBuildIdempotencyKey', () => { + it('differs between two attempts at the same spec', () => { + const first = sandboxBuildIdempotencyKey('e2b', 'hash-1', new Date(1_000)) + const second = sandboxBuildIdempotencyKey('e2b', 'hash-1', new Date(2_000)) + + expect(first).not.toBe(second) + }) + + it('still collapses a duplicate delivery of one attempt', () => { + const attemptAt = new Date(1_000) + + expect(sandboxBuildIdempotencyKey('e2b', 'hash-1', attemptAt)).toBe( + sandboxBuildIdempotencyKey('e2b', 'hash-1', attemptAt) + ) + }) + + it('keeps providers apart for the same content address', () => { + const attemptAt = new Date(1_000) + + expect(sandboxBuildIdempotencyKey('e2b', 'hash-1', attemptAt)).not.toBe( + sandboxBuildIdempotencyKey('daytona', 'hash-1', attemptAt) + ) + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/image-registry.ts b/apps/sim/lib/execution/remote-sandbox/image-registry.ts new file mode 100644 index 0000000000..be6dc008fa --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/image-registry.ts @@ -0,0 +1,592 @@ +import { db } from '@sim/db' +import { sandboxImage } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { backoffWithJitter } from '@sim/utils/retry' +import { and, eq, inArray, lt, notInArray, or, type SQL, sql } from 'drizzle-orm' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { runDetached } from '@/lib/core/utils/background' +import { + buildTimeoutError, + providerBuildError, + type SandboxBuildError, +} from '@/lib/execution/remote-sandbox/build-errors' +import { resolveProvider } from '@/lib/execution/remote-sandbox/provider' +import { invalidateSandboxResolution } from '@/lib/execution/remote-sandbox/resolve' +import type { SandboxSpec } from '@/lib/execution/remote-sandbox/sandbox-spec' +import type { + SandboxImageBuild, + SandboxImageBuilder, + SandboxImageStatus, +} from '@/lib/execution/remote-sandbox/types' + +const logger = createLogger('SandboxImageRegistry') + +/** Ceiling on how long one build may run before it is called failed. */ +export const BUILD_POLL_CAP_MS = 15 * 60 * 1000 + +/** A `building` row older than this is assumed abandoned and may be re-claimed. */ +const STALE_BUILD_MS = BUILD_POLL_CAP_MS * 2 + +const POLL_BASE_MS = 3_000 +const POLL_MAX_MS = 20_000 + +export interface SandboxImageBuildPayload { + provider: string + specHash: string +} + +/** + * Collapses concurrent saves of the same spec into one build. Content-addressed, + * so two workspaces declaring identical dependencies share the key as well as + * the resulting image. + */ +export function sandboxBuildIdempotencyKey( + provider: string, + specHash: string, + attemptAt: Date +): string { + return `sandbox-image-${provider}-${specHash}-${attemptAt.getTime()}` +} + +/** + * How long a failed build is left alone when the caller is an automatic repair + * rather than a person. Long enough that a per-minute schedule cannot turn a + * permanently broken package list into a per-minute build, short enough that a + * transient registry outage recovers within the hour. + */ +export const FAILED_BUILD_RETRY_COOLDOWN_MS = 10 * 60 * 1000 + +export interface EnsureSandboxImageOptions { + /** + * Ignore a `failed` row younger than this instead of re-claiming it. + * + * Omitted means retry immediately, which is right for a save: a person editing + * a package list is explicitly asking for another attempt. Automatic callers + * pass {@link FAILED_BUILD_RETRY_COOLDOWN_MS} — they fire once per execution, + * and a build that fails in seconds would otherwise be re-enqueued by every + * run of a scheduled workflow forever. + */ + minFailureAgeMs?: number + /** + * Reclaim a `ready` row as well as a failed one. + * + * Only the release path sets this, and only once it has deleted an image out + * from under a hash something re-adopted. That row looks healthy while its + * `imageRef` points at nothing, and resolution cannot tell: it repairs a row + * that is missing or `failed`, never one claiming to be ready, so without this + * the sandbox stays broken until someone re-saves it by hand. + * + * An in-flight build is still left alone. It either recreates the template it + * was already building or fails into the normal repair path, and resetting it + * would only add a duplicate build. + */ + imageKnownGone?: boolean +} + +/** + * Which settled row a save may re-claim: any of them when the image is known to be + * gone, otherwise a failed one — immediately for a person, after the cooldown for + * an automatic caller. + */ +function settledRebuildBranch(options: EnsureSandboxImageOptions): SQL | undefined { + if (options.imageKnownGone) { + return notInArray(sandboxImage.status, ['pending', 'building']) + } + if (options.minFailureAgeMs) { + return and( + eq(sandboxImage.status, 'failed'), + lt(sandboxImage.updatedAt, new Date(Date.now() - options.minFailureAgeMs)) + ) + } + return eq(sandboxImage.status, 'failed') +} + +/** + * Ensures a build exists for `spec`, enqueueing one only when the registry has + * no row or the last attempt failed. A `ready` or in-flight row is left alone, + * which is what makes an unchanged save cost nothing. + * + * No-ops under a `runtime` provider: it installs per execution and never touches + * this registry. + */ +export async function ensureSandboxImage( + spec: SandboxSpec, + specHash: string, + options: EnsureSandboxImageOptions = {} +): Promise { + const provider = resolveProvider() + if (provider.dependencyStrategy !== 'prebuilt' || !provider.images) return + // Nothing to install means nothing to build — and `pip install` with no + // requirement exits non-zero, so a build here would fail permanently. + if (spec.dependencies.length === 0) return + + const inserted = await db + .insert(sandboxImage) + .values({ + id: generateId(), + provider: provider.id, + specHash, + spec, + status: 'pending', + }) + .onConflictDoUpdate({ + target: [sandboxImage.provider, sandboxImage.specHash], + set: { + status: 'pending', + errorCode: null, + errorMessage: null, + errorDetail: null, + updatedAt: new Date(), + }, + // A failed build is retryable, immediately for a person and after a cooldown + // for an automatic caller. `pending` and `building` become re-claimable once + // stale: an enqueue that threw (provider outage), a detached run that died + // with the process, or a worker killed mid-build would otherwise strand the + // row forever, and no later save could revive it because the content address + // never changes. + setWhere: or( + settledRebuildBranch(options), + and( + inArray(sandboxImage.status, ['pending', 'building']), + lt(sandboxImage.updatedAt, new Date(Date.now() - STALE_BUILD_MS)) + ) + ), + }) + .returning({ + id: sandboxImage.id, + status: sandboxImage.status, + updatedAt: sandboxImage.updatedAt, + }) + + // No row returned means the conflict target matched but `setWhere` rejected the + // update — an existing `ready`, `pending`, or `building` row. Nothing to do. + if (inserted.length === 0) return + + await enqueueSandboxImageBuild({ provider: provider.id, specHash }, inserted[0].updatedAt) +} + +async function enqueueSandboxImageBuild( + payload: SandboxImageBuildPayload, + attemptAt: Date +): Promise { + if (!isTriggerDevEnabled) { + runDetached('sandbox-image-build', () => runSandboxImageBuild(payload)) + return + } + // Dynamically imported so Trigger.dev stays out of the web bundle's static graph. + const [{ sandboxImageBuildTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/sandbox-image-build'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger('sandbox-image-build', payload, { + // Keyed by the claim, not by the spec alone. Concurrent saves are already + // collapsed by the conditional update above — only one caller gets a row back, + // so only one reaches here. A spec-only key would instead suppress the *next* + // legitimate attempt: Trigger.dev returns the finished run rather than starting + // one, leaving the row `pending` with no worker and unclaimable until it goes + // stale. That is half an hour of a save-to-retry doing nothing. + idempotencyKey: sandboxBuildIdempotencyKey(payload.provider, payload.specHash, attemptAt), + // Still short, so even a duplicate delivery of one attempt cannot linger. + idempotencyKeyTTL: '5m', + tags: [`sandboxSpec:${payload.specHash}`], + region: await resolveTriggerRegion(), + }) +} + +async function writeFailure( + payload: SandboxImageBuildPayload, + error: SandboxBuildError, + detail?: string +): Promise { + await db + .update(sandboxImage) + .set({ + status: 'failed', + errorCode: error.code, + errorMessage: error.message, + errorDetail: detail ?? null, + updatedAt: new Date(), + }) + .where( + and(eq(sandboxImage.provider, payload.provider), eq(sandboxImage.specHash, payload.specHash)) + ) + invalidateSandboxResolution() +} + +/** + * Drives one build to a terminal state: claim the row, start the provider build, + * poll with backoff to {@link BUILD_POLL_CAP_MS}, then write `ready` or `failed`. + * + * The claim is conditional on the row still being `pending`, so a re-delivered + * task (Trigger.dev at-least-once, or a detached retry) is a no-op rather than a + * second concurrent build. + */ +export async function runSandboxImageBuild(payload: SandboxImageBuildPayload): Promise { + const provider = resolveProvider() + if (provider.id !== payload.provider || !provider.images) { + logger.warn('Skipping sandbox image build for a provider this deployment does not serve', { + requested: payload.provider, + active: provider.id, + }) + return + } + + const claimed = await db + .update(sandboxImage) + .set({ status: 'building', updatedAt: new Date() }) + .where( + and( + eq(sandboxImage.provider, payload.provider), + eq(sandboxImage.specHash, payload.specHash), + eq(sandboxImage.status, 'pending') + ) + ) + .returning({ spec: sandboxImage.spec }) + + if (claimed.length === 0) { + logger.info('Sandbox image build already claimed, skipping', { specHash: payload.specHash }) + return + } + const spec = claimed[0].spec as SandboxSpec + + let build: SandboxImageBuild + try { + build = await provider.images.startBuild(spec, payload.specHash) + } catch (error) { + logger.error('Failed to start sandbox image build', toError(error)) + await writeFailure(payload, providerBuildError(getErrorMessage(error))) + return + } + + await db + .update(sandboxImage) + .set({ + imageRef: build.imageRef, + buildId: build.buildId, + providerImageId: build.providerImageId ?? null, + updatedAt: new Date(), + }) + .where( + and(eq(sandboxImage.provider, payload.provider), eq(sandboxImage.specHash, payload.specHash)) + ) + + const deadline = Date.now() + BUILD_POLL_CAP_MS + for (let attempt = 1; Date.now() < deadline; attempt++) { + await sleep(backoffWithJitter(attempt, null, { baseMs: POLL_BASE_MS, maxMs: POLL_MAX_MS })) + let status: Awaited> + try { + status = await provider.images.getBuildStatus(build, spec) + } catch (error) { + // A transient poll failure is not a build failure — keep polling until the + // cap, and let the timeout be the thing that gives up. + logger.warn('Sandbox image build poll failed', { specHash: payload.specHash, error }) + continue + } + + if (status.status === 'ready') { + await db + .update(sandboxImage) + .set({ + status: 'ready', + errorCode: null, + errorMessage: null, + errorDetail: null, + updatedAt: new Date(), + }) + .where( + and( + eq(sandboxImage.provider, payload.provider), + eq(sandboxImage.specHash, payload.specHash) + ) + ) + invalidateSandboxResolution() + logger.info('Sandbox image build ready', { + specHash: payload.specHash, + imageRef: build.imageRef, + }) + return + } + + if (status.status === 'failed') { + await writeFailure(payload, status.error ?? providerBuildError(), status.logs ?? undefined) + logger.warn('Sandbox image build failed', { + specHash: payload.specHash, + code: status.error?.code, + }) + return + } + } + + await writeFailure(payload, buildTimeoutError(BUILD_POLL_CAP_MS / 60_000)) + logger.warn('Sandbox image build timed out', { specHash: payload.specHash }) +} + +/** + * Deletes the provider image behind a spec hash once no sandbox references it. + * + * Called when a sandbox is deleted, and when an edit re-points one at a new + * content address — both leave the previous build unreferenced. Without it the + * image sits in provider storage until the retention sweep, which is up to + * `SANDBOX_IMAGE_RETENTION_DAYS` of paying to store something nothing can select. + * + * Builds are keyed by content, not by workspace, so two workspaces declaring the + * same package list share one image and deleting on the strength of one + * workspace's action would break the other. The reference check is therefore part + * of the same statement that removes the row: reading references first and + * deleting second left a window — wide, because a provider delete is a network + * call — in which another workspace could adopt the hash, inherit a `ready` row, + * and have its next run fail against a template already on its way out. Winning + * the conditional delete is what proves nothing referenced the hash. + * + * Claiming the row before the provider call means a provider that then refuses + * would strand a template nothing points at, so the row is put back and the + * retention sweep inherits the retry. An in-flight build is left alone rather + * than raced; the sweep collects it once it settles. + * + * Best-effort by contract: failures are logged and swallowed, because this runs + * after the mutation it follows has already committed and must never turn a + * successful delete into an error. + */ +export async function releaseSandboxImage(specHash: string): Promise { + const provider = resolveProvider() + if (provider.dependencyStrategy !== 'prebuilt' || !provider.images) return + + try { + const outcome = await claimAndDeleteImage(provider.id, provider.images, specHash) + if (outcome === 'released') { + invalidateSandboxResolution() + logger.info('Released unreferenced sandbox image', { specHash }) + } + } catch (error) { + logger.warn('Failed to release sandbox image; the retention sweep will retry', { + specHash, + error: getErrorMessage(error), + }) + } +} + +type ImageClaimOutcome = 'released' | 'skipped' | 'failed' + +/** + * Rebuilds a hash that was adopted while its image was being deleted. + * + * Claiming removes the row, so between that and the provider call finishing a + * workspace can declare the same package list, get a fresh row, and start a build + * under the same content-derived `imageRef` — which the in-flight delete then + * removes. The window is inherent: the registry row and the provider template are + * two systems with no shared transaction, so it can be made small but not zero. + * + * What is avoidable is the adopter being left broken. Its row is new and + * healthy-looking, so nothing else would notice — and a row that reached `ready` + * before the delete landed is worse than slow, it is permanent: resolution repairs + * a row that is missing or `failed`, never one claiming to be ready, so its + * `imageRef` would point at nothing until someone re-saved the sandbox by hand. + * `imageKnownGone` is what lets this reclaim that row. A build still in flight is + * left alone, since it either recreates the template or fails into the normal + * repair path. + */ +async function rebuildIfReadopted( + providerId: string, + specHash: string, + spec: SandboxSpec +): Promise { + try { + const [readopted] = await db + .select({ id: sandboxImage.id }) + .from(sandboxImage) + .where(and(eq(sandboxImage.provider, providerId), eq(sandboxImage.specHash, specHash))) + .limit(1) + if (!readopted) return + + logger.warn('Sandbox image was re-adopted mid-delete; rebuilding it now', { specHash }) + await ensureSandboxImage(spec, specHash, { imageKnownGone: true }) + } catch (error) { + // The template is gone and the rebuild did not take, so the adopter's row now + // claims a `ready` image that does not exist — the one state resolution cannot + // repair, because it only rebuilds a row that is missing or `failed`. Dropping + // the row converts that into the missing case, which the next execution fixes + // on its own. Swallowing instead would leave the sandbox broken until someone + // re-saved it by hand. + logger.warn('Failed to rebuild a re-adopted sandbox image; dropping the dead row', { + specHash, + error: getErrorMessage(error), + }) + try { + await db + .delete(sandboxImage) + .where(and(eq(sandboxImage.provider, providerId), eq(sandboxImage.specHash, specHash))) + } catch (cleanupError) { + logger.error('Could not drop a sandbox image row pointing at a deleted template', { + specHash, + error: getErrorMessage(cleanupError), + }) + } + } +} + +/** + * Takes ownership of a spec hash's registry row and deletes the image behind it. + * + * Both callers that remove an image go through here, because the ordering is the + * whole contract and having written it twice is what let the two paths drift: + * + * 1. The unreferenced check is part of the `DELETE`, not a query before it. + * Winning the delete is the proof nothing referenced the hash. Reading first + * and deleting second leaves a window — spanning a provider network call — + * where another workspace declares the same package list, inherits the `ready` + * row without rebuilding, and loses the template underneath it. + * 2. The provider delete runs only after the claim, so two sweeps or a sweep and + * a release cannot both issue it. + * 3. A provider that refuses gets the row put back, since claiming first would + * otherwise strand a template nothing points at and no later pass would find + * it. Restoring is what keeps the retry on the sweep. + * + * `extraConditions` lets the sweep add its retention cutoff to the same claim + * rather than trusting the candidate query it ran earlier. + */ +async function claimAndDeleteImage( + providerId: string, + images: SandboxImageBuilder, + specHash: string, + extraConditions: SQL[] = [] +): Promise { + const [claimed] = await db + .delete(sandboxImage) + .where( + and( + eq(sandboxImage.provider, providerId), + eq(sandboxImage.specHash, specHash), + notInArray(sandboxImage.status, ['pending', 'building']), + sql`not exists (select 1 from workspace_sandbox ws where ws.spec_hash = ${specHash})`, + ...extraConditions + ) + ) + .returning({ + id: sandboxImage.id, + spec: sandboxImage.spec, + status: sandboxImage.status, + imageRef: sandboxImage.imageRef, + buildId: sandboxImage.buildId, + providerImageId: sandboxImage.providerImageId, + }) + if (!claimed) return 'skipped' + if (!claimed.imageRef) return 'released' + + try { + await images.deleteImage({ + imageRef: claimed.imageRef, + buildId: claimed.buildId ?? '', + providerImageId: claimed.providerImageId ?? undefined, + }) + } catch (error) { + await db + .insert(sandboxImage) + .values({ + id: claimed.id, + provider: providerId, + specHash, + spec: claimed.spec, + status: claimed.status as SandboxImageStatus, + imageRef: claimed.imageRef, + buildId: claimed.buildId, + providerImageId: claimed.providerImageId, + }) + .onConflictDoNothing() + logger.warn('Provider refused a sandbox image delete; restored the row for retry', { + specHash, + error: getErrorMessage(error), + }) + return 'failed' + } + + // Deliberately past the catch above. The template is gone for good by now, so a + // failure here must not restore the row: that path puts back a `ready` row whose + // imageRef points at nothing, which is the one state resolution cannot repair. + await rebuildIfReadopted(providerId, specHash, claimed.spec as SandboxSpec) + return 'released' +} + +/** Most rows one sweep will touch. The next run picks up whatever is left. */ +const CLEANUP_BATCH_LIMIT = 200 + +/** Provider deletes issued at once. Bounded so a sweep cannot stampede the API. */ +const CLEANUP_CONCURRENCY = 8 + +/** + * Removes build rows that no `workspace_sandbox` still references and that have + * gone unused past the retention window. + * + * The query below only nominates candidates. Every row is re-checked and claimed + * atomically by {@link claimAndDeleteImage} before its image is touched, because + * this sweep reads up to {@link CLEANUP_BATCH_LIMIT} rows and then works through + * them a chunk of network calls at a time — leaving minutes in which a workspace + * could declare one of those package lists, inherit the `ready` row, and lose the + * template underneath it. A candidate that stops qualifying in that window simply + * fails its claim and is skipped. + * + * A provider delete that fails restores the row, so the next sweep retries rather + * than orphaning a remote template nothing points at any more. + * + * Progress is committed per chunk. A sweep that is killed part-way — by a route + * timeout or a redeploy — therefore keeps what it already deleted, instead of + * re-issuing every provider delete next run and never draining the backlog. + */ +export async function cleanupSandboxImages(retentionDays: number): Promise<{ + deleted: number + failed: number +}> { + const provider = resolveProvider() + if (provider.dependencyStrategy !== 'prebuilt' || !provider.images) { + return { deleted: 0, failed: 0 } + } + const images = provider.images + + const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000) + const stale = await db + .select({ + id: sandboxImage.id, + specHash: sandboxImage.specHash, + imageRef: sandboxImage.imageRef, + buildId: sandboxImage.buildId, + providerImageId: sandboxImage.providerImageId, + }) + .from(sandboxImage) + .where( + and( + eq(sandboxImage.provider, provider.id), + sql`coalesce(${sandboxImage.lastUsedAt}, ${sandboxImage.createdAt}) < ${cutoff}`, + sql`not exists (select 1 from workspace_sandbox ws where ws.spec_hash = ${sandboxImage.specHash})` + ) + ) + .limit(CLEANUP_BATCH_LIMIT) + + if (stale.length === CLEANUP_BATCH_LIMIT) { + logger.info('Sandbox image sweep hit its batch limit; the rest waits for the next run', { + limit: CLEANUP_BATCH_LIMIT, + }) + } + + let deleted = 0 + let failed = 0 + + for (let offset = 0; offset < stale.length; offset += CLEANUP_CONCURRENCY) { + const chunk = stale.slice(offset, offset + CLEANUP_CONCURRENCY) + const outcomes = await Promise.all( + chunk.map((row) => + claimAndDeleteImage(provider.id, images, row.specHash, [ + sql`coalesce(${sandboxImage.lastUsedAt}, ${sandboxImage.createdAt}) < ${cutoff}`, + ]) + ) + ) + + deleted += outcomes.filter((outcome) => outcome === 'released').length + failed += outcomes.filter((outcome) => outcome === 'failed').length + } + + if (deleted > 0) invalidateSandboxResolution() + return { deleted, failed } +} diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index 1bbc80dabb..c99a6333e5 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -1,9 +1,14 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { env } from '@/lib/core/config/env' -import { daytonaProvider } from '@/lib/execution/remote-sandbox/daytona' -import { e2bProvider } from '@/lib/execution/remote-sandbox/e2b' import { resolvePiSandboxLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime' +import { resolveProvider } from '@/lib/execution/remote-sandbox/provider' +import { + provisionRuntimeDependencies, + type ResolvedSandbox, + RUNTIME_INSTALL_TIMEOUT_MS, + repairMissingSandboxImage, + resolveWorkspaceSandbox, +} from '@/lib/execution/remote-sandbox/resolve' import type { CreateSandboxOptions, SandboxCommandResult, @@ -12,8 +17,6 @@ import type { SandboxFile, SandboxHandle, SandboxKind, - SandboxProvider, - SandboxProviderId, SandboxShellExecutionRequest, } from '@/lib/execution/remote-sandbox/types' @@ -26,43 +29,6 @@ export type { const logger = createLogger('RemoteSandbox') -/** - * The known sandbox providers. Keyed by {@link SandboxProviderId}, so adding an - * adapter is one entry here plus one member on the id union — the type makes an - * unhandled provider a compile error, not a runtime surprise. - */ -const PROVIDERS: Record = { - e2b: e2bProvider, - daytona: daytonaProvider, -} - -const DEFAULT_PROVIDER: SandboxProviderId = 'e2b' - -/** - * Resolves which provider serves this execution from the `SANDBOX_PROVIDER` env - * var (defaulting to {@link DEFAULT_PROVIDER}). - * - * Selection is deliberately resolved ONCE, before the sandbox is created, and is - * never revisited mid-execution: user code has side effects (HTTP calls, S3 - * writes, DB mutations), so retrying a partially-executed run on another provider - * could duplicate them. Changing providers is a config change — set - * `SANDBOX_PROVIDER` and redeploy; in-flight executions are unaffected. - */ -function resolveProvider(): SandboxProvider { - // Normalize casing identically to env-flags' availability gate — otherwise a - // value like `Daytona` would pass the gate (which lowercases) but miss this - // lowercase-keyed map and throw at create time. - const configured = env.SANDBOX_PROVIDER?.toLowerCase() - if (!configured) return PROVIDERS[DEFAULT_PROVIDER] - const provider = PROVIDERS[configured as SandboxProviderId] - if (!provider) { - throw new Error( - `Unknown SANDBOX_PROVIDER "${env.SANDBOX_PROVIDER}" (expected one of: ${Object.keys(PROVIDERS).join(', ')})` - ) - } - return provider -} - async function createSandbox( kind: SandboxKind, options?: CreateSandboxOptions @@ -73,6 +39,31 @@ async function createSandbox( return sandbox } +/** + * Creates a sandbox, turning "that image is gone" into a rebuild rather than a + * failure the author has to resolve by hand. + * + * Create is the only step that observes whether the provider image really exists, + * which is why the repair hangs off it: the registry row and the remote template + * are two systems with no shared transaction, so keeping them in step is always + * best-effort, while checking at the point of use is not. Any other failure is + * rethrown untouched. + */ +async function createSelectedSandbox( + kind: SandboxKind, + options: CreateSandboxOptions, + selected: ResolvedSandbox | null +): Promise { + try { + return await createSandbox(kind, options) + } catch (error) { + if (!selected) throw error + const rebuilding = await repairMissingSandboxImage(selected, error) + if (!rebuilding) throw error + throw new Error(rebuilding) + } +} + /** * Materializes sandbox input files before user code runs. `content` entries are written inline; * `url` entries are fetched from inside the sandbox via `curl` — their bytes never pass through the @@ -267,19 +258,87 @@ async function collectExportedFiles( } } +/** + * Floor on what is left for the user's code after a runtime install. Below this + * the run is not worth attempting — but reporting "your code timed out" would + * still be the wrong story, so the install's own budget is capped to leave it. + */ +const MIN_CODE_BUDGET_MS = 15_000 + +/** + * How long a runtime dependency install may take before it must yield to the + * code it is installing for. Capped by {@link RUNTIME_INSTALL_TIMEOUT_MS} and by + * whatever the caller's budget leaves after reserving {@link MIN_CODE_BUDGET_MS}. + */ +function installBudgetMs(timeoutMs: number): number { + return Math.max(0, Math.min(RUNTIME_INSTALL_TIMEOUT_MS, timeoutMs - MIN_CODE_BUDGET_MS)) +} + +/** + * Installs a runtime sandbox's dependencies out of the caller's budget and + * reports what it spent, so the code that follows can be given the remainder. + * + * Deliberately times ONLY the install. Mount materialization is not deducted — + * it predates this accounting and can legitimately run long for a large + * presigned fetch, so charging it here would shorten the code budget of existing + * prebuilt-strategy workflows that never had an install step at all. + */ +async function provisionWithinBudget( + sandbox: SandboxHandle, + selected: ResolvedSandbox | null, + timeoutMs: number +): Promise { + if (!selected) return 0 + const startedAt = Date.now() + await provisionRuntimeDependencies(sandbox, selected, { timeoutMs: installBudgetMs(timeoutMs) }) + return Date.now() - startedAt +} + +/** What remains of the caller's budget once the install has taken its share. */ +function remainingBudgetMs(timeoutMs: number, installMs: number): number { + return Math.max(MIN_CODE_BUDGET_MS, timeoutMs - installMs) +} + export async function executeInSandbox( req: SandboxExecutionRequest ): Promise { const { code, language, timeoutMs } = req + const kind = req.sandboxKind ?? 'code' - const sandbox = await createSandbox(req.sandboxKind ?? 'code', { language }) + // Resolved before the sandbox is created so a selection that cannot be honored + // fails without spending a provider create. + const selected = await resolveWorkspaceSandbox({ + kind, + language, + workspaceId: req.workspaceId, + sandboxId: req.sandboxId, + }) + + const sandbox = await createSelectedSandbox( + kind, + { language, imageRef: selected?.imageRef }, + selected + ) const sandboxId = sandbox.sandboxId try { - // Inside the try so a failed mount still kills the sandbox via the finally below. + // Inside the try so a failed install or mount still kills the sandbox via the + // finally below. Dependencies land before the inputs so user code and its + // mounts always see a complete environment. + // + // The install is spent OUT OF the caller's budget, not on top of it. Our + // caller aborts the whole request at `timeoutMs` (see `tools/index.ts`), so + // an install that ran to its own separate 240s ceiling would blow past that + // and surface a bare "Request timed out" instead of the classified install + // error. Under the prebuilt strategy provisioning returns immediately, so + // this arithmetic is a no-op there. + const installMs = await provisionWithinBudget(sandbox, selected, timeoutMs) await writeSandboxInputs(sandbox, req.sandboxFiles, {}) - const execution = await sandbox.runCode(code, { timeoutMs }) + const execution = await sandbox.runCode(code, { + timeoutMs: remainingBudgetMs(timeoutMs, installMs), + ...(selected?.envs ? { envs: selected.envs } : {}), + }) if (execution.error) { const errorMessage = `${execution.error.name}: ${execution.error.value}` @@ -338,20 +397,33 @@ export async function executeShellInSandbox( req: SandboxShellExecutionRequest ): Promise { const { code, envs, timeoutMs } = req + const kind = req.sandboxKind ?? 'shell' - const sandbox = await createSandbox(req.sandboxKind ?? 'shell') + // No language is passed: a shell execution runs commands rather than a language + // runtime, so whichever language the sandbox carries is the one it installs. + const selected = await resolveWorkspaceSandbox({ + kind, + workspaceId: req.workspaceId, + sandboxId: req.sandboxId, + }) + + const sandbox = await createSelectedSandbox(kind, { imageRef: selected?.imageRef }, selected) const sandboxId = sandbox.sandboxId try { - // Inside the try so a failed mount still kills the sandbox via the finally below. + // Inside the try so a failed install or mount still kills the sandbox via the + // finally below. The install shares the caller's budget rather than adding to + // it — see the note in `executeInSandbox`. + const installMs = await provisionWithinBudget(sandbox, selected, timeoutMs) await writeSandboxInputs(sandbox, req.sandboxFiles, { rootUser: true }) const result = await sandbox.runCommand(code, { envs: { + ...selected?.envs, ...envs, PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin', }, - timeoutMs, + timeoutMs: remainingBudgetMs(timeoutMs, installMs), rootUser: true, }) diff --git a/apps/sim/lib/execution/remote-sandbox/provider.ts b/apps/sim/lib/execution/remote-sandbox/provider.ts new file mode 100644 index 0000000000..1d662194df --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/provider.ts @@ -0,0 +1,41 @@ +import { env } from '@/lib/core/config/env' +import { daytonaProvider } from '@/lib/execution/remote-sandbox/daytona' +import { e2bProvider } from '@/lib/execution/remote-sandbox/e2b' +import type { SandboxProvider, SandboxProviderId } from '@/lib/execution/remote-sandbox/types' + +/** + * The known sandbox providers. Keyed by {@link SandboxProviderId}, so adding an + * adapter is one entry here plus one member on the id union — the type makes an + * unhandled provider a compile error, not a runtime surprise. + */ +const PROVIDERS: Record = { + e2b: e2bProvider, + daytona: daytonaProvider, +} + +const DEFAULT_PROVIDER: SandboxProviderId = 'e2b' + +/** + * Resolves which provider serves this execution from the `SANDBOX_PROVIDER` env + * var (defaulting to {@link DEFAULT_PROVIDER}). + * + * Selection is deliberately resolved ONCE, before the sandbox is created, and is + * never revisited mid-execution: user code has side effects (HTTP calls, S3 + * writes, DB mutations), so retrying a partially-executed run on another provider + * could duplicate them. Changing providers is a config change — set + * `SANDBOX_PROVIDER` and redeploy; in-flight executions are unaffected. + */ +export function resolveProvider(): SandboxProvider { + // Normalize casing identically to env-flags' availability gate — otherwise a + // value like `Daytona` would pass the gate (which lowercases) but miss this + // lowercase-keyed map and throw at create time. + const configured = env.SANDBOX_PROVIDER?.toLowerCase() + if (!configured) return PROVIDERS[DEFAULT_PROVIDER] + const provider = PROVIDERS[configured as SandboxProviderId] + if (!provider) { + throw new Error( + `Unknown SANDBOX_PROVIDER "${env.SANDBOX_PROVIDER}" (expected one of: ${Object.keys(PROVIDERS).join(', ')})` + ) + } + return provider +} diff --git a/apps/sim/lib/execution/remote-sandbox/resolve.test.ts b/apps/sim/lib/execution/remote-sandbox/resolve.test.ts new file mode 100644 index 0000000000..928374e65f --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/resolve.test.ts @@ -0,0 +1,433 @@ +/** + * @vitest-environment node + * + * Resolution is the fail-closed boundary: a selection that cannot be honored has + * to surface as an explicit error, never as a baffling ModuleNotFoundError + * inside the user's code. These cases pin that contract down. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CodeLanguage } from '@/lib/execution/languages' + +const { mockSelect, mockUpdate, mockProviderStrategy, mockEnsureSandboxImage, mockIsMissingImage } = + vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockUpdate: vi.fn(), + mockProviderStrategy: { current: 'prebuilt' as 'prebuilt' | 'runtime' }, + mockEnsureSandboxImage: vi.fn(), + mockIsMissingImage: vi.fn(), + })) + +vi.mock('@/lib/execution/remote-sandbox/image-registry', () => ({ + ensureSandboxImage: mockEnsureSandboxImage, + FAILED_BUILD_RETRY_COOLDOWN_MS: 600_000, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + update: mockUpdate, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + workspaceSandbox: { + id: 'id', + workspaceId: 'workspace_id', + name: 'name', + language: 'language', + dependencies: 'dependencies', + specHash: 'spec_hash', + }, + sandboxImage: { + provider: 'provider', + specHash: 'spec_hash', + status: 'status', + imageRef: 'image_ref', + errorMessage: 'error_message', + lastUsedAt: 'last_used_at', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: (...args: unknown[]) => args, + eq: (...args: unknown[]) => args, +})) + +vi.mock('@/lib/execution/remote-sandbox/provider', () => ({ + resolveProvider: () => ({ + id: 'e2b', + get dependencyStrategy() { + return mockProviderStrategy.current + }, + get images() { + return mockProviderStrategy.current === 'prebuilt' + ? { isMissingImage: mockIsMissingImage } + : undefined + }, + }), +})) + +import { + invalidateSandboxResolution, + provisionRuntimeDependencies, + repairMissingSandboxImage, + resolveWorkspaceSandbox, +} from '@/lib/execution/remote-sandbox/resolve' + +/** Queues the rows each successive `db.select()` chain resolves to. */ +function queueSelects(...results: unknown[][]) { + mockSelect.mockReset() + for (const rows of results) { + mockSelect.mockReturnValueOnce({ + from: () => ({ where: () => ({ limit: () => Promise.resolve(rows) }) }), + }) + } +} + +const SANDBOX_ROW = { + id: 'sbx-1', + name: 'bigquery-etl', + language: 'python', + dependencies: ['pandas'], + specHash: 'hash-1', +} + +beforeEach(() => { + vi.clearAllMocks() + invalidateSandboxResolution() + mockProviderStrategy.current = 'prebuilt' + mockUpdate.mockReturnValue({ set: () => ({ where: () => Promise.resolve() }) }) + mockEnsureSandboxImage.mockResolvedValue(undefined) +}) + +describe('resolveWorkspaceSandbox', () => { + it('returns null when nothing is selected, leaving current behavior unchanged', async () => { + const resolved = await resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + }) + expect(resolved).toBeNull() + expect(mockSelect).not.toHaveBeenCalled() + }) + + it.each(['doc', 'pi'] as const)('ignores a selection for the %s kind', async (kind) => { + const resolved = await resolveWorkspaceSandbox({ + kind, + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + expect(resolved).toBeNull() + expect(mockSelect).not.toHaveBeenCalled() + }) + + it('passes the ready image ref under the prebuilt strategy', async () => { + queueSelects([SANDBOX_ROW], [{ status: 'ready', imageRef: 'sim-sbx-abc', errorMessage: null }]) + + const resolved = await resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + expect(resolved).toMatchObject({ strategy: 'prebuilt', imageRef: 'sim-sbx-abc' }) + // Python needs no NODE_PATH; only JavaScript does. + expect(resolved?.envs).toBeUndefined() + }) + + it('carries NODE_PATH for javascript so installed packages resolve', async () => { + queueSelects( + [{ ...SANDBOX_ROW, language: 'javascript', dependencies: ['axios'] }], + [{ status: 'ready', imageRef: 'sim-sbx-abc', errorMessage: null }] + ) + + const resolved = await resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.JavaScript, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + expect(resolved?.envs?.NODE_PATH).toContain('node_modules') + }) + + it.each([ + ['building', /still building/], + ['pending', /still building/], + ['failed', /failed to build/], + ])('fails closed when the build is %s', async (status, expected) => { + queueSelects([SANDBOX_ROW], [{ status, imageRef: null, errorMessage: 'pandas not found' }]) + + await expect( + resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + ).rejects.toThrow(expected) + }) + + it('fails closed when the build row does not exist yet', async () => { + queueSelects([SANDBOX_ROW], []) + + await expect( + resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + ).rejects.toThrow(/no completed build/) + }) + + /** + * A sandbox whose definition is fine but whose image is gone must not require + * the user to re-save it in Settings to become runnable again. Resolution + * re-queues the build so the next execution succeeds on its own. + */ + it.each([ + ['a failed build', [{ status: 'failed', imageRef: null, errorMessage: 'pandas not found' }]], + ['a missing build row', []], + ])('re-queues the build for %s', async (_label, imageRows) => { + queueSelects([SANDBOX_ROW], imageRows) + + await expect( + resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + ).rejects.toThrow(/queued/) + + // The cooldown is what stops a scheduled workflow re-enqueueing a build that + // fails in seconds on every single run. + expect(mockEnsureSandboxImage).toHaveBeenCalledWith( + { language: 'python', dependencies: ['pandas'] }, + 'hash-1', + { minFailureAgeMs: 600_000 } + ) + }) + + it('does not re-queue while a healthy build is still in flight', async () => { + queueSelects([SANDBOX_ROW], [{ status: 'building', imageRef: null, errorMessage: null }]) + + await expect( + resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + ).rejects.toThrow(/still building/) + + // The registry's own conflict guard decides whether a stale in-flight row is + // re-claimable, so calling it here is safe — but the message must not + // promise a rebuild that the guard will refuse. + expect(mockEnsureSandboxImage).toHaveBeenCalled() + }) + + it('keeps the build error when the repair itself fails', async () => { + queueSelects([SANDBOX_ROW], [{ status: 'failed', imageRef: null, errorMessage: 'pandas gone' }]) + mockEnsureSandboxImage.mockRejectedValue(new Error('trigger.dev unreachable')) + + await expect( + resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + ).rejects.toThrow(/pandas gone/) + }) + + it('never re-queues when the image is usable', async () => { + queueSelects([SANDBOX_ROW], [{ status: 'ready', imageRef: 'sim-sbx-abc', errorMessage: null }]) + + await resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + expect(mockEnsureSandboxImage).not.toHaveBeenCalled() + }) + + it('rejects a deleted or cross-workspace sandbox', async () => { + queueSelects([]) + + await expect( + resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-other', + sandboxId: 'sbx-1', + }) + ).rejects.toThrow(/no longer exists in this workspace/) + }) + + it('rejects a language mismatch instead of installing the wrong dependency set', async () => { + queueSelects([SANDBOX_ROW], [{ status: 'ready', imageRef: 'sim-sbx-abc', errorMessage: null }]) + + await expect( + resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.JavaScript, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + ).rejects.toThrow(/installs python dependencies, but this block runs javascript/) + }) + + it('never touches the build registry under the runtime strategy', async () => { + mockProviderStrategy.current = 'runtime' + queueSelects([SANDBOX_ROW]) + + const resolved = await resolveWorkspaceSandbox({ + kind: 'code', + language: CodeLanguage.Python, + workspaceId: 'ws-1', + sandboxId: 'sbx-1', + }) + + expect(resolved).toMatchObject({ strategy: 'runtime' }) + expect(resolved?.imageRef).toBeUndefined() + expect(mockSelect).toHaveBeenCalledTimes(1) + expect(mockEnsureSandboxImage).not.toHaveBeenCalled() + }) +}) + +describe('provisionRuntimeDependencies', () => { + function fakeSandbox(commandResult: { stdout: string; stderr: string; exitCode: number }) { + return { + sandboxId: 'sb_1', + writeFile: vi.fn().mockResolvedValue(undefined), + runCommand: vi.fn().mockResolvedValue(commandResult), + runCode: vi.fn(), + readFile: vi.fn(), + kill: vi.fn(), + } + } + + const RUNTIME_PY = { + id: 'sbx-1', + name: 'etl', + language: CodeLanguage.Python, + dependencies: ['pandas'], + strategy: 'runtime' as const, + } + + it('writes the manifest through the filesystem API, never a shell argument', async () => { + const sandbox = fakeSandbox({ stdout: 'ok', stderr: '', exitCode: 0 }) + + await provisionRuntimeDependencies(sandbox, RUNTIME_PY) + + expect(sandbox.writeFile).toHaveBeenCalledWith('/tmp/sim-requirements.txt', 'pandas\n') + const [command] = sandbox.runCommand.mock.calls.at(-1) as [string] + expect(command).toContain('-r /tmp/sim-requirements.txt') + expect(command).not.toContain('pandas') + }) + + it('installs javascript packages from a package.json into the shared prefix', async () => { + const sandbox = fakeSandbox({ stdout: 'ok', stderr: '', exitCode: 0 }) + + await provisionRuntimeDependencies(sandbox, { + ...RUNTIME_PY, + language: CodeLanguage.JavaScript, + dependencies: ['axios@^1.7.0'], + }) + + const manifestCall = sandbox.writeFile.mock.calls[0] as [string, string] + expect(manifestCall[0]).toMatch(/package\.json$/) + expect(JSON.parse(manifestCall[1]).dependencies).toEqual({ axios: '^1.7.0' }) + }) + + it('aborts with the classified error so user code never runs half-installed', async () => { + const sandbox = fakeSandbox({ + stdout: '', + stderr: 'ERROR: No matching distribution found for pandsa', + exitCode: 1, + }) + + await expect(provisionRuntimeDependencies(sandbox, RUNTIME_PY)).rejects.toThrow( + /Package "pandsa" was not found on PyPI/ + ) + }) + + it('does nothing under the prebuilt strategy', async () => { + const sandbox = fakeSandbox({ stdout: '', stderr: '', exitCode: 0 }) + + await provisionRuntimeDependencies(sandbox, { ...RUNTIME_PY, strategy: 'prebuilt' }) + + expect(sandbox.writeFile).not.toHaveBeenCalled() + expect(sandbox.runCommand).not.toHaveBeenCalled() + }) + + it('uses a timeout of its own, so a slow install cannot eat the code budget', async () => { + const sandbox = fakeSandbox({ stdout: 'ok', stderr: '', exitCode: 0 }) + + await provisionRuntimeDependencies(sandbox, RUNTIME_PY) + + const [, options] = sandbox.runCommand.mock.calls.at(-1) as [string, { timeoutMs: number }] + expect(options.timeoutMs).toBeGreaterThan(0) + }) +}) + +/** + * The registry and the provider template are two systems with no shared + * transaction, so every attempt to keep them in step leaves some window. Create is + * the one step that observes the truth, which is why the repair hangs off it. + */ +describe('repairMissingSandboxImage', () => { + const SELECTED = { + id: 'sbx-1', + name: 'bigquery-etl', + language: CodeLanguage.Python, + dependencies: ['pandas'], + specHash: 'hash-1', + strategy: 'prebuilt' as const, + imageRef: 'sim-sbx-abc', + } + + it('rebuilds without a cooldown, because create observed the image is gone', async () => { + mockIsMissingImage.mockResolvedValue(true) + + const message = await repairMissingSandboxImage(SELECTED, new Error('404')) + + expect(message).toMatch(/being rebuilt/) + expect(mockEnsureSandboxImage).toHaveBeenCalledWith( + { language: 'python', dependencies: ['pandas'] }, + 'hash-1', + { imageKnownGone: true } + ) + }) + + /** + * The classifier has to stay narrow: treating an auth or rate-limit failure as a + * missing image would rebuild every sandbox on a provider outage. + */ + it('leaves any other provider failure alone', async () => { + mockIsMissingImage.mockResolvedValue(false) + + const message = await repairMissingSandboxImage(SELECTED, new Error('rate limited')) + + expect(message).toBeNull() + expect(mockEnsureSandboxImage).not.toHaveBeenCalled() + }) + + it('does nothing for a runtime-strategy sandbox, which has no image to miss', async () => { + mockIsMissingImage.mockResolvedValue(true) + + const message = await repairMissingSandboxImage( + { ...SELECTED, strategy: 'runtime', imageRef: undefined }, + new Error('404') + ) + + expect(message).toBeNull() + expect(mockEnsureSandboxImage).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/resolve.ts b/apps/sim/lib/execution/remote-sandbox/resolve.ts new file mode 100644 index 0000000000..87cd62486d --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/resolve.ts @@ -0,0 +1,464 @@ +import { createLogger } from '@sim/logger' +import { CodeLanguage } from '@/lib/execution/languages' +import { classifyInstallOutput, tailBuildLog } from '@/lib/execution/remote-sandbox/build-errors' +import { resolveProvider } from '@/lib/execution/remote-sandbox/provider' +import { + isSandboxLanguage, + renderDependencyManifest, + type SandboxLanguage, + SIM_DEPS_DIR, + SIM_NODE_MODULES_DIR, + SIM_PACKAGE_JSON_PATH, + SIM_REQUIREMENTS_PATH, +} from '@/lib/execution/remote-sandbox/sandbox-spec' +import type { + SandboxDependencyStrategy, + SandboxHandle, + SandboxKind, +} from '@/lib/execution/remote-sandbox/types' + +const logger = createLogger('SandboxResolve') + +/** + * The DB is reached lazily so the sandbox barrel stays importable without one. + * `withPiSandbox`, the copilot doc compilers, and `verify-sandbox-parity.ts` all + * pull in this module through `remote-sandbox/index.ts` but never select a + * workspace sandbox — a static `@sim/db` import would make every one of them + * throw at module load when `DATABASE_URL` is unset. + */ +async function sandboxDb() { + const [{ db }, schema, orm] = await Promise.all([ + import('@sim/db'), + import('@sim/db/schema'), + import('drizzle-orm'), + ]) + return { + db, + sandboxImage: schema.sandboxImage, + workspaceSandbox: schema.workspaceSandbox, + and: orm.and, + eq: orm.eq, + } +} + +/** + * Ceiling on a dependency install. + * + * This is an upper bound, not a separate allowance: the caller carves the actual + * budget out of the execution timeout it is itself willing to wait for (see + * `installBudgetMs` in the sandbox barrel). Letting the install run past that + * would only produce a bare client-side "Request timed out" in place of the + * classified installer error. + */ +export const RUNTIME_INSTALL_TIMEOUT_MS = 240_000 + +/** + * How long a resolved BUILD stays cached in-process. + * + * Only the content-addressed half is cached: `sandbox_image` is keyed by + * `(provider, specHash)`, and a spec hash names one immutable dependency set, so + * a hit can never describe the wrong packages. The `workspace_sandbox` row is + * re-read every time (one indexed lookup) because it is the mutable half — that + * is what makes a delete or an edit take effect immediately on EVERY replica + * rather than only the one that served the mutation. + */ +const IMAGE_TTL_MS = 30_000 + +/** `lastUsedAt` is a retention signal, not an audit trail — hourly is precise enough. */ +const LAST_USED_DEBOUNCE_MS = 60 * 60 * 1000 + +/** Only these kinds honor a workspace sandbox; see {@link resolveWorkspaceSandbox}. */ +const SANDBOX_AWARE_KINDS: ReadonlySet = new Set(['code', 'shell']) + +export interface ResolvedSandbox { + id: string + name: string + language: SandboxLanguage + dependencies: string[] + /** Content address of the package set, so a failed create can rebuild it. */ + specHash: string + strategy: SandboxDependencyStrategy + /** Provider image to create from. Present under the `prebuilt` strategy. */ + imageRef?: string + /** Environment the execution must carry for the dependencies to be importable. */ + envs?: Record +} + +/** A `sandbox_image` row, cached by its content address. */ +interface CachedImage { + status: string + imageRef: string | null + errorMessage: string | null +} + +interface CacheEntry { + expiresAt: number + value: CachedImage +} + +/** + * Both maps are process-lifetime and keyed by an unbounded space (every spec + * hash ever executed), so each drops its oldest entry rather than growing for + * the life of the worker. + */ +const IMAGE_CACHE_LIMIT = 1000 +const LAST_USED_CACHE_LIMIT = 1000 + +const imageCache = new Map() +const lastUsedWrites = new Map() + +/** + * JavaScript packages live outside the default resolution roots, so Node needs + * `NODE_PATH` to find them. Both strategies install into the same directory, so + * both hand back the same environment. + */ +function envsFor(language: SandboxLanguage): Record | undefined { + return language === CodeLanguage.JavaScript ? { NODE_PATH: SIM_NODE_MODULES_DIR } : undefined +} + +/** + * Records that a build was used, so the retention sweep can tell a live image + * from an abandoned one. Debounced and fire-and-forget: this is bookkeeping, and + * a failed write must never fail an execution. + */ +function touchImage(specHash: string, provider: string): void { + const key = `${provider}:${specHash}` + const now = Date.now() + const written = lastUsedWrites.get(key) + if (written && now - written < LAST_USED_DEBOUNCE_MS) return + if (lastUsedWrites.size >= LAST_USED_CACHE_LIMIT) lastUsedWrites.clear() + lastUsedWrites.set(key, now) + void sandboxDb() + .then(({ db, sandboxImage, and, eq }) => + db + .update(sandboxImage) + .set({ lastUsedAt: new Date() }) + .where(and(eq(sandboxImage.provider, provider), eq(sandboxImage.specHash, specHash))) + ) + .catch((error) => logger.warn('Failed to record sandbox image use', { specHash, error })) +} + +/** + * Resolves the sandbox an execution should run against, or `null` when none is + * selected (today's behavior: the env-configured template, no install step). + * + * Fails closed rather than degrading. A selection that cannot be honored — + * deleted, cross-workspace, wrong language, or a build that is not `ready` — + * throws with the reason, because the alternative is a baffling + * `ModuleNotFoundError` inside the user's code. + * + * Deliberately not plan-gated: the gate covers creating and editing sandboxes, + * so a workspace that downgrades keeps executing the ones it already has. + */ +export async function resolveWorkspaceSandbox(args: { + kind: SandboxKind + /** + * The language the caller will execute. Omitted by the shell path, which runs + * commands rather than a language runtime and so has nothing to mismatch. + */ + language?: CodeLanguage + workspaceId?: string + sandboxId?: string +}): Promise { + const { kind, language, workspaceId, sandboxId } = args + if (!sandboxId) return null + // `doc` and `pi` keep their vetted images unconditionally. + if (!SANDBOX_AWARE_KINDS.has(kind)) return null + if (!workspaceId) { + throw new Error('A sandbox was selected but this execution has no workspace to resolve it in') + } + + const provider = resolveProvider() + const { db, sandboxImage, workspaceSandbox, and, eq } = await sandboxDb() + const [row] = await db + .select({ + id: workspaceSandbox.id, + name: workspaceSandbox.name, + language: workspaceSandbox.language, + dependencies: workspaceSandbox.dependencies, + specHash: workspaceSandbox.specHash, + }) + .from(workspaceSandbox) + .where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId))) + .limit(1) + + if (!row) { + throw new Error( + `The selected sandbox no longer exists in this workspace. Pick another one, or clear the selection to run on the default image.` + ) + } + if (!isSandboxLanguage(row.language)) { + throw new Error(`Sandbox "${row.name}" has an unsupported language (${row.language})`) + } + + const base = { + id: row.id, + name: row.name, + language: row.language, + dependencies: row.dependencies ?? [], + specHash: row.specHash, + envs: envsFor(row.language), + } + + let resolved: ResolvedSandbox + if (base.dependencies.length === 0) { + // A sandbox with no packages declares nothing to build and nothing to + // install, so it resolves to the base image under either strategy. Looking + // for a build row here would fail closed on an image that never existed. + resolved = { ...base, strategy: provider.dependencyStrategy } + } else if (provider.dependencyStrategy === 'runtime') { + resolved = { ...base, strategy: 'runtime' } + } else { + const image = await readImage(provider.id, row.specHash) + + if (!image || image.status !== 'ready' || !image.imageRef) { + await scheduleImageRepair(base, row.specHash) + throw new Error(describeUnusableImage(row.name, image?.status, image?.errorMessage)) + } + touchImage(row.specHash, provider.id) + resolved = { ...base, strategy: 'prebuilt', imageRef: image.imageRef } + } + + assertLanguageMatches(resolved, language) + return resolved +} + +/** + * Reads a build row, memoized on its content address. + * + * Editing a sandbox produces a different hash and deleting one is caught by the + * `workspace_sandbox` read that always runs, so those cannot serve a stale hit. A + * non-ready row is NOT cached either: it is precisely the value that flips + * underneath us while a build completes, and caching it would keep a just-finished + * build unusable. + * + * A `ready` row is no longer strictly terminal, though, and this cache is + * per-process. `releaseSandboxImage` clears only the replica that ran it, so + * another replica can serve a cached `ready` image for up to {@link IMAGE_TTL_MS} + * after its template was deleted — and because the hit looks healthy, resolution + * hands back a dead `imageRef` instead of reaching the repair path. Sandbox + * creation then fails on that replica until the entry expires and the row read + * finds nothing. Bounded and self-healing, but real; closing it needs either + * cross-replica invalidation or a provider-error path that invalidates on + * "template not found". + */ +async function readImage(providerId: string, specHash: string): Promise { + const cacheKey = `${providerId}:${specHash}` + const cached = imageCache.get(cacheKey) + if (cached) { + if (cached.expiresAt > Date.now()) return cached.value + imageCache.delete(cacheKey) + } + + const { db, sandboxImage, and, eq } = await sandboxDb() + const [image] = await db + .select({ + status: sandboxImage.status, + imageRef: sandboxImage.imageRef, + errorMessage: sandboxImage.errorMessage, + }) + .from(sandboxImage) + .where(and(eq(sandboxImage.provider, providerId), eq(sandboxImage.specHash, specHash))) + .limit(1) + + if (image?.status === 'ready' && image.imageRef) { + if (imageCache.size >= IMAGE_CACHE_LIMIT) { + const oldest = imageCache.keys().next() + if (!oldest.done) imageCache.delete(oldest.value) + } + imageCache.set(cacheKey, { expiresAt: Date.now() + IMAGE_TTL_MS, value: image }) + } + return image +} + +/** + * Re-enqueues a build for a sandbox whose image is unusable. + * + * `ensureSandboxImage` otherwise runs only when a sandbox is saved, which left + * three states permanently stuck until someone re-saved it in Settings: a build + * that failed, a build whose worker died mid-flight, and — after switching a + * deployment from a `runtime` provider to a `prebuilt` one — every sandbox + * created while the old provider was active, since `runtime` writes no image + * rows at all. Repairing here costs an execution that was going to fail either + * way and lets the next one succeed, instead of making the user reconfigure a + * sandbox whose definition was never wrong. + * + * Rate-limited, unlike the save path. This fires once per execution, and a bad + * package name fails in seconds, so re-claiming a failed row on sight would let a + * per-minute schedule enqueue a per-minute build of something that will never + * succeed. The cooldown caps that at one attempt per window while a save — an + * explicit request from a person — still retries immediately. Executions arriving + * during a healthy build enqueue nothing either way. + * + * Imported dynamically for the same reason as {@link sandboxDb} — the registry + * pulls `@sim/db` into the static import graph, which this module keeps out of + * the executor bundle. A repair that fails must never replace the caller's + * message, which is the one naming the sandbox and its build error. + */ +async function scheduleImageRepair( + spec: { language: SandboxLanguage; dependencies: string[] }, + specHash: string, + options?: { imageKnownGone?: boolean } +): Promise { + try { + const { ensureSandboxImage, FAILED_BUILD_RETRY_COOLDOWN_MS } = await import( + '@/lib/execution/remote-sandbox/image-registry' + ) + await ensureSandboxImage( + { language: spec.language, dependencies: spec.dependencies }, + specHash, + // A create that just failed on a missing image has observed the truth, so it + // reclaims whatever the row says and skips the cooldown. Resolution reading a + // row it cannot verify only gets the rate-limited retry. + options?.imageKnownGone + ? { imageKnownGone: true } + : { minFailureAgeMs: FAILED_BUILD_RETRY_COOLDOWN_MS } + ) + } catch (error) { + logger.warn('Failed to schedule sandbox image repair', { specHash, error }) + } +} + +/** + * Repairs a sandbox whose image turned out to be gone when the provider was asked + * to create from it. + * + * This is the backstop the registry cannot be: the row and the provider template + * are two systems with no shared transaction, so every attempt to keep them in step + * leaves some window — a released image adopted mid-delete, a stale cache on another + * replica, a rebuild that did not take. Create is the one place that observes ground + * truth, so a `ready` row pointing at nothing repairs itself here on first use + * instead of needing someone to re-save the sandbox. + * + * Returns the message to fail this execution with, or `null` when the failure was + * anything else and must surface unchanged. + */ +export async function repairMissingSandboxImage( + selected: ResolvedSandbox, + error: unknown +): Promise { + if (selected.strategy !== 'prebuilt' || !selected.imageRef) return null + + const provider = resolveProvider() + if (!provider.images) return null + if (!(await provider.images.isMissingImage(error))) return null + + invalidateSandboxResolution() + await scheduleImageRepair(selected, selected.specHash, { imageKnownGone: true }) + logger.warn('Sandbox image was missing at create; rebuilding it', { + sandbox: selected.name, + specHash: selected.specHash, + }) + + return `Sandbox "${selected.name}" is being rebuilt because its image is no longer available. Run again in a moment.` +} + +function assertLanguageMatches(sandbox: ResolvedSandbox, language?: CodeLanguage): void { + if (!language || sandbox.language === language) return + throw new Error( + `Sandbox "${sandbox.name}" installs ${sandbox.language} dependencies, but this block runs ${language}. Select a ${language} sandbox or clear the selection.` + ) +} + +function describeUnusableImage( + name: string, + status: string | undefined, + errorMessage: string | null | undefined +): string { + if (status === 'failed') { + return `Sandbox "${name}" failed to build: ${errorMessage ?? 'installation failed'}. A rebuild has been queued — run again in a moment. If it keeps failing, fix its dependencies in Settings → Sandboxes.` + } + if (status === 'pending' || status === 'building') { + return `Sandbox "${name}" is still building. Wait for it to finish, then run again.` + } + return `Sandbox "${name}" has no completed build yet. A build has been queued — run again in a moment.` +} + +/** + * Clears the in-process build cache. + * + * Best-effort only, and no longer load-bearing: it clears one process, so on a + * multi-replica deployment the others keep their entries. Correctness comes from + * what is NOT cached — the `workspace_sandbox` row is re-read on every resolve, + * and the cache is keyed by content address, so a stale entry can only ever + * describe a build that is still exactly what its hash says it is. This just + * lets the replica that served a mutation pick up a rebuild a little sooner. + */ +export function invalidateSandboxResolution(): void { + imageCache.clear() +} + +function installCommandFor(language: SandboxLanguage): string { + if (language === CodeLanguage.Python) { + return `pip install --no-input --disable-pip-version-check -r ${SIM_REQUIREMENTS_PATH}` + } + // `--prefix` is the install target; the manifest is copied in as root first, + // because the filesystem API cannot write into a root-owned directory. + return `cp ${SIM_PACKAGE_JSON_PATH} ${SIM_DEPS_DIR}/package.json && npm install --prefix ${SIM_DEPS_DIR} --no-audit --no-fund --omit=dev` +} + +/** + * Installs a runtime-strategy sandbox's dependencies before user code runs. + * + * The dependency list reaches the sandbox as a file written through the + * filesystem API, never interpolated into a shell command, so a package name is + * never parsed as shell syntax. The installer's own output is returned to the + * caller rather than merged into the execution's stdout, so a package whose name + * contains the `__SIM_RESULT__` marker cannot corrupt the parsed result. + * + * A non-zero exit throws: user code must never run against a half-installed + * environment and report a confusing `ModuleNotFoundError` instead of the real + * installation failure. + */ +export async function provisionRuntimeDependencies( + sandbox: SandboxHandle, + resolved: ResolvedSandbox, + options?: { timeoutMs?: number } +): Promise { + if (resolved.strategy !== 'runtime' || resolved.dependencies.length === 0) return + + const installTimeoutMs = options?.timeoutMs ?? RUNTIME_INSTALL_TIMEOUT_MS + if (installTimeoutMs <= 0) { + throw new Error( + `Sandbox "${resolved.name}" installs its packages at run time, which needs more time than this block's timeout allows. Raise the block's timeout and try again.` + ) + } + + const manifest = renderDependencyManifest({ + language: resolved.language, + dependencies: resolved.dependencies, + }) + const manifestPath = + resolved.language === CodeLanguage.Python ? SIM_REQUIREMENTS_PATH : SIM_PACKAGE_JSON_PATH + + await sandbox.writeFile(manifestPath, manifest) + if (resolved.language === CodeLanguage.JavaScript) { + await sandbox.runCommand(`mkdir -p ${SIM_DEPS_DIR}`, { timeoutMs: 30_000, rootUser: true }) + } + + const started = Date.now() + const result = await sandbox.runCommand(installCommandFor(resolved.language), { + timeoutMs: installTimeoutMs, + rootUser: true, + }) + + if (result.exitCode !== 0) { + // Daytona merges both streams into stdout, so fall back to it for the real output. + const output = result.stderr || result.stdout || `installer exited ${result.exitCode}` + const classified = classifyInstallOutput(resolved.language, output) + logger.error('Runtime dependency install failed', { + sandboxId: sandbox.sandboxId, + sandbox: resolved.name, + code: classified.code, + exitCode: result.exitCode, + }) + throw new Error(`${classified.message}\n\n${tailBuildLog(output)}`) + } + + logger.info('Installed sandbox dependencies at run time', { + sandboxId: sandbox.sandboxId, + sandbox: resolved.name, + dependencyCount: resolved.dependencies.length, + durationMs: Date.now() - started, + }) +} diff --git a/apps/sim/lib/execution/remote-sandbox/sandbox-spec.test.ts b/apps/sim/lib/execution/remote-sandbox/sandbox-spec.test.ts new file mode 100644 index 0000000000..ba8589a93a --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/sandbox-spec.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { CodeLanguage } from '@/lib/execution/languages' +import { + canonicalizeDependencies, + hashSandboxSpec, + MAX_SANDBOX_DEPENDENCIES, + parseJsDependency, + quoteDependency, + renderDependencyManifest, + validateDependencies, +} from '@/lib/execution/remote-sandbox/sandbox-spec' + +const PY = CodeLanguage.Python +const JS = CodeLanguage.JavaScript + +function accepted(language: typeof PY | typeof JS, input: string): string[] { + const result = validateDependencies(language, input) + if (!result.ok) { + throw new Error(`expected acceptance, got: ${JSON.stringify(result.issues)}`) + } + return result.dependencies +} + +function rejection(language: typeof PY | typeof JS, input: string) { + const result = validateDependencies(language, input) + if (result.ok) throw new Error(`expected rejection of ${input}`) + return result.issues +} + +describe('validateDependencies (python)', () => { + it.each([ + 'Django==5.0', + 'google-cloud-bigquery[pandas]>=3', + 'pandas', + 'pyairtable>=3.0', + 'requests>=2.0,<3.0', + 'numpy~=1.26', + 'urllib3!=2.0.0', + ])('accepts %s', (value) => { + expect(accepted(PY, value)).toHaveLength(1) + }) + + it.each([ + ['git+https://github.com/psf/requests', 'URLs and VCS references are not allowed'], + ['-e .', 'installer flags are not allowed (remove the leading dash)'], + ['--index-url http://evil', 'installer flags are not allowed (remove the leading dash)'], + ['foo; rm -rf /', 'a dependency cannot contain spaces'], + ['../local-package', 'local paths are not allowed'], + ['https://example.com/pkg.whl', 'URLs and VCS references are not allowed'], + ])('rejects %s', (value, reason) => { + const issues = rejection(PY, value) + expect(issues).toHaveLength(1) + expect(issues[0].reason).toBe(reason) + }) + + it('reports the offending line number against the submitted row', () => { + const issues = rejection(PY, ['pandas', '', '# a comment', 'git+https://evil', 'requests']) + expect(issues).toHaveLength(1) + expect(issues[0].line).toBe(4) + expect(issues[0].value).toBe('git+https://evil') + }) + + it('strips comments and blank lines', () => { + expect(accepted(PY, '# deps\n\npandas\n\n # trailing\nrequests\n')).toEqual([ + 'pandas', + 'requests', + ]) + }) + + it('rejects more than the dependency cap and marks every entry past it', () => { + const list = Array.from({ length: MAX_SANDBOX_DEPENDENCIES + 1 }, (_, i) => `pkg-${i}`) + const issues = rejection(PY, list) + expect(issues).toHaveLength(1) + expect(issues[0].line).toBe(MAX_SANDBOX_DEPENDENCIES + 1) + }) + + it('rejects an over-long entry', () => { + const issues = rejection(PY, `pkg${'a'.repeat(300)}`) + expect(issues[0].reason).toContain('longer than') + }) +}) + +describe('validateDependencies (javascript)', () => { + it.each([ + 'axios@^1.7.0', + '@aws-sdk/client-s3', + '@aws-sdk/client-s3@^3.600.0', + 'zod', + 'lodash@4.17.21', + 'left-pad@latest', + ])('accepts %s', (value) => { + expect(accepted(JS, value)).toHaveLength(1) + }) + + it.each([ + ['git+https://github.com/axios/axios', 'URLs and VCS references are not allowed'], + ['file:../local', 'local and alias specifiers are not allowed'], + ['link:../sibling', 'local and alias specifiers are not allowed'], + ['workspace:*', 'local and alias specifiers are not allowed'], + ['npm:alias@1.0.0', 'local and alias specifiers are not allowed'], + ['axios@>=1.2 <2', 'a dependency cannot contain spaces'], + ])('rejects %s', (value, reason) => { + const issues = rejection(JS, value) + expect(issues[0].reason).toBe(reason) + }) +}) + +describe('canonicalization and hashing', () => { + it('is stable under reordering', () => { + const a = hashSandboxSpec({ language: PY, dependencies: ['requests', 'pandas'] }) + const b = hashSandboxSpec({ language: PY, dependencies: ['pandas', 'requests'] }) + expect(a).toBe(b) + }) + + it('normalizes python names per PEP 503, so casing and separators do not fork a build', () => { + const a = hashSandboxSpec({ language: PY, dependencies: ['Google_Cloud.BigQuery'] }) + const b = hashSandboxSpec({ language: PY, dependencies: ['google-cloud-bigquery'] }) + expect(a).toBe(b) + expect(canonicalizeDependencies(PY, ['Google_Cloud.BigQuery'])).toEqual([ + 'google-cloud-bigquery', + ]) + }) + + it('leaves npm names verbatim, because the registry serves React and react separately', () => { + expect(canonicalizeDependencies(JS, ['React'])).toEqual(['React']) + expect(hashSandboxSpec({ language: JS, dependencies: ['React'] })).not.toBe( + hashSandboxSpec({ language: JS, dependencies: ['react'] }) + ) + }) + + it('de-duplicates', () => { + expect(canonicalizeDependencies(PY, ['pandas', 'pandas', 'PANDAS'])).toEqual(['pandas']) + }) + + it('hashes the same list differently under different languages', () => { + expect(hashSandboxSpec({ language: PY, dependencies: ['lodash'] })).not.toBe( + hashSandboxSpec({ language: JS, dependencies: ['lodash'] }) + ) + }) + + it('preserves the version specifier while normalizing only the name', () => { + expect(canonicalizeDependencies(PY, ['Google_Cloud.BigQuery[Pandas]>=3.0'])).toEqual([ + 'google-cloud-bigquery[Pandas]>=3.0', + ]) + }) +}) + +describe('shell quoting', () => { + it('quotes specifier characters that a shell would otherwise interpret', () => { + expect(quoteDependency('django>=5.0')).toBe("'django>=5.0'") + }) + + it('refuses to quote a value that never passed validation', () => { + expect(() => quoteDependency("foo' ; rm -rf /")).toThrow(/unvalidated dependency/) + }) +}) + +describe('manifest rendering', () => { + it('renders a requirements.txt for python', () => { + expect(renderDependencyManifest({ language: PY, dependencies: ['requests', 'pandas'] })).toBe( + 'pandas\nrequests\n' + ) + }) + + it('renders a package.json dependency map for javascript', () => { + const manifest = renderDependencyManifest({ + language: JS, + dependencies: ['axios@^1.7.0', '@aws-sdk/client-s3', 'zod@3.23.8'], + }) + expect(JSON.parse(manifest).dependencies).toEqual({ + '@aws-sdk/client-s3': '*', + axios: '^1.7.0', + zod: '3.23.8', + }) + }) + + it('splits a scoped name from its range without mistaking the scope for one', () => { + expect(parseJsDependency('@aws-sdk/client-s3')).toEqual({ + name: '@aws-sdk/client-s3', + range: '*', + }) + expect(parseJsDependency('@aws-sdk/client-s3@^3.0.0')).toEqual({ + name: '@aws-sdk/client-s3', + range: '^3.0.0', + }) + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts b/apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts new file mode 100644 index 0000000000..ae93ddeb88 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/sandbox-spec.ts @@ -0,0 +1,294 @@ +import { createHash } from 'node:crypto' +import { CodeLanguage } from '@/lib/execution/languages' + +/** + * The languages a sandbox can carry dependencies for. Shell is excluded: a shell + * execution has no package manager of its own, it borrows whichever sandbox the + * `code` kind resolved. + */ +export type SandboxLanguage = `${CodeLanguage.JavaScript}` | `${CodeLanguage.Python}` + +export const SANDBOX_LANGUAGES = [CodeLanguage.JavaScript, CodeLanguage.Python] as const + +export function isSandboxLanguage(value: string): value is SandboxLanguage { + return value === CodeLanguage.JavaScript || value === CodeLanguage.Python +} + +/** + * The package registry a language installs from, named as a user would recognize + * it. Single source of truth so validation errors, build failures, and the wand + * prompt all call it the same thing. + */ +export function registryFor(language: SandboxLanguage): string { + return language === CodeLanguage.Python ? 'PyPI' : 'npm' +} + +/** + * A canonical, content-addressable dependency set. `dependencies` is always the + * output of {@link canonicalizeDependencies}, so two specs that mean the same + * thing are byte-identical and therefore hash identically. + */ +export interface SandboxSpec { + language: SandboxLanguage + dependencies: string[] +} + +/** Upper bound on how many packages one sandbox may declare. */ +export const MAX_SANDBOX_DEPENDENCIES = 50 + +/** Upper bound on the length of a single dependency entry. */ +export const MAX_DEPENDENCY_LENGTH = 200 + +/** + * Where the installed JavaScript packages live inside a sandbox, for both the + * prebuilt (baked into the E2B template) and runtime (installed per execution) + * strategies. Unlike pip, an npm install is not automatically importable — Node + * resolves from `NODE_PATH` or the install location — so producer and consumer + * must agree on one directory. They read it from here. + */ +export const SIM_DEPS_DIR = '/opt/sim-deps' + +export const SIM_NODE_MODULES_DIR = `${SIM_DEPS_DIR}/node_modules` + +/** + * Where the runtime strategy writes the dependency manifests it installs from. + * + * Both live under `/tmp`, not {@link SIM_DEPS_DIR}: the install target is created + * as root, while `SandboxHandle.writeFile` goes through the provider filesystem + * API as the sandbox's default user — writing the manifest into the root-owned + * directory fails with EACCES. + */ +export const SIM_REQUIREMENTS_PATH = '/tmp/sim-requirements.txt' + +export const SIM_PACKAGE_JSON_PATH = '/tmp/sim-package.json' + +/** A rejected dependency line, addressed back to the row the user typed it on. */ +export interface DependencyIssue { + /** 1-indexed line in the submitted list, so the editor can mark the exact row. */ + line: number + value: string + reason: string +} + +export type DependencyValidation = + | { ok: true; dependencies: string[] } + | { ok: false; issues: DependencyIssue[] } + +/** + * PEP 508 distribution name followed by optional extras and an optional PEP 440 + * version specifier set. Deliberately whitespace-free — see + * {@link quoteDependency} for why every accepted character has to be inert. + */ +const PYTHON_SPECIFIER = '(===|==|!=|<=|>=|~=|<|>)[A-Za-z0-9][A-Za-z0-9.*+!-]*' +const PYTHON_DEPENDENCY_PATTERN = new RegExp( + '^[A-Za-z0-9][A-Za-z0-9._-]*' + + '(\\[[A-Za-z0-9][A-Za-z0-9._-]*(,[A-Za-z0-9][A-Za-z0-9._-]*)*\\])?' + + `(${PYTHON_SPECIFIER}(,${PYTHON_SPECIFIER})*)?$` +) + +/** An npm package name, optionally scoped, with an optional `@range` suffix. */ +const JS_DEPENDENCY_PATTERN = + /^(@[a-z0-9][a-z0-9._-]*\/)?[a-zA-Z0-9][a-zA-Z0-9._-]*(@[\^~<>=]{0,2}[A-Za-z0-9*][A-Za-z0-9.*+-]*)?$/ + +/** + * Prefixes and substrings rejected ahead of the shape check purely so the error + * names the actual problem. The patterns above would reject all of these anyway. + */ +const DEPENDENCY_REJECTIONS: ReadonlyArray<{ match: RegExp; reason: string }> = [ + { match: /^-/, reason: 'installer flags are not allowed (remove the leading dash)' }, + { match: /^git\+|:\/\//, reason: 'URLs and VCS references are not allowed' }, + { + match: /^(file|link|workspace|npm|portal|patch):/i, + reason: 'local and alias specifiers are not allowed', + }, + { match: /[\s]/, reason: 'a dependency cannot contain spaces' }, + { match: /[;&|`$(){}"'\\]/, reason: 'contains characters that are not valid in a package name' }, + { match: /^[./~]/, reason: 'local paths are not allowed' }, +] + +interface SourceLine { + line: number + value: string +} + +/** + * Splits a submitted dependency list into meaningful entries while keeping each + * one addressed to the row it was typed on, so a rejection can be marked inline + * rather than reported against a shifted index. + */ +function readDependencyLines(input: string | readonly string[]): SourceLine[] { + const rawLines = typeof input === 'string' ? input.split('\n') : input + const entries: SourceLine[] = [] + rawLines.forEach((raw, index) => { + const value = (raw ?? '').trim() + if (!value || value.startsWith('#')) return + entries.push({ line: index + 1, value }) + }) + return entries +} + +/** + * Normalizes a Python distribution name per PEP 503, which PyPI treats as + * case- and separator-insensitive. `Google_Cloud.BigQuery` and + * `google-cloud-bigquery` are the same distribution, so they must reach the same + * spec hash and share one build. + * + * npm names get no such treatment: the registry serves `React` and `react` as + * distinct packages, so folding case there could install something the user did + * not ask for. + */ +function normalizePythonName(name: string): string { + return name.toLowerCase().replace(/[-_.]+/g, '-') +} + +function canonicalizeEntry(language: SandboxLanguage, value: string): string { + if (language !== CodeLanguage.Python) return value + const boundary = value.search(/[[<>=!~]/) + if (boundary === -1) return normalizePythonName(value) + return normalizePythonName(value.slice(0, boundary)) + value.slice(boundary) +} + +/** + * Reduces a validated dependency list to its canonical form: normalized, + * de-duplicated, and sorted. Two lists that install the same thing collapse to + * the same array, which is what makes {@link hashSandboxSpec} content-addressed. + */ +export function canonicalizeDependencies( + language: SandboxLanguage, + dependencies: readonly string[] +): string[] { + const seen = new Set() + for (const dependency of dependencies) { + seen.add(canonicalizeEntry(language, dependency.trim())) + } + return [...seen].sort() +} + +/** + * Validates a submitted dependency list and returns its canonical form. + * + * This is the security boundary for the whole feature. Every accepted entry is + * eventually handed to pip or npm, on the E2B build path as a shell argument, so + * the grammar here is a strict allowlist rather than a denylist: no whitespace, + * no quoting, no redirection, no path or URL forms. Everything downstream — + * {@link quoteDependency}, the runtime manifests — assumes that invariant holds. + */ +export function validateDependencies( + language: SandboxLanguage, + input: string | readonly string[] +): DependencyValidation { + const entries = readDependencyLines(input) + const issues: DependencyIssue[] = [] + + // Reported once, against the first line over the cap. Marking every remaining + // row would bury the real message under hundreds of identical ones and bloat + // the response for a paste that is simply too long. + const overflow = entries[MAX_SANDBOX_DEPENDENCIES] + if (overflow) { + issues.push({ + line: overflow.line, + value: overflow.value, + reason: `a sandbox can declare at most ${MAX_SANDBOX_DEPENDENCIES} dependencies (this list has ${entries.length})`, + }) + } + + const pattern = + language === CodeLanguage.Python ? PYTHON_DEPENDENCY_PATTERN : JS_DEPENDENCY_PATTERN + const registryName = registryFor(language) + + for (const entry of entries.slice(0, MAX_SANDBOX_DEPENDENCIES)) { + if (entry.value.length > MAX_DEPENDENCY_LENGTH) { + issues.push({ + line: entry.line, + value: entry.value, + reason: `a dependency cannot be longer than ${MAX_DEPENDENCY_LENGTH} characters`, + }) + continue + } + + const rejection = DEPENDENCY_REJECTIONS.find((candidate) => candidate.match.test(entry.value)) + if (rejection) { + issues.push({ line: entry.line, value: entry.value, reason: rejection.reason }) + continue + } + + if (!pattern.test(entry.value)) { + issues.push({ + line: entry.line, + value: entry.value, + reason: `not a valid ${registryName} package name or version specifier`, + }) + } + } + + if (issues.length > 0) return { ok: false, issues } + return { + ok: true, + dependencies: canonicalizeDependencies( + language, + entries.map((e) => e.value) + ), + } +} + +/** + * Content address for a canonical spec. Keyed on language as well as + * dependencies so the same package list under Python and JavaScript never + * collides onto one build. + */ +export function hashSandboxSpec(spec: SandboxSpec): string { + const canonical = JSON.stringify({ + language: spec.language, + dependencies: canonicalizeDependencies(spec.language, spec.dependencies), + }) + return createHash('sha256').update(canonical, 'utf-8').digest('hex') +} + +/** + * Quotes a validated dependency for an argv position in a shell command. + * + * The E2B template builder composes `pip install`/`npm install` as a shell + * string, so an unquoted `django>=5.0` would redirect stdout to a file named + * `=5.0` and silently install an unpinned Django. Validation already rejects + * quotes and whitespace, so wrapping in single quotes is total — but the guard + * stays, because this function is what makes that assumption load-bearing. + */ +export function quoteDependency(dependency: string): string { + // Quotes and whitespace would break out of the argv slot; a leading dash would + // stay inside it and be read by pip/npm as a FLAG (`--index-url=...`, `-t/`) + // rather than a package. Specs are re-read from `sandbox_image.spec` JSONB long + // after validation ran, so this check cannot lean on the writer having run it. + if (/^-/.test(dependency) || /['"\s]/.test(dependency)) { + throw new Error(`Refusing to shell-quote an unvalidated dependency: ${dependency}`) + } + return `'${dependency}'` +} + +/** + * Splits an npm spec into the name and range halves a `package.json` + * `dependencies` map needs. The scope's leading `@` is skipped when looking for + * the range separator so `@aws-sdk/client-s3` is not read as a range. + */ +export function parseJsDependency(dependency: string): { name: string; range: string } { + const separator = dependency.lastIndexOf('@') + if (separator <= 0) return { name: dependency, range: '*' } + return { name: dependency.slice(0, separator), range: dependency.slice(separator + 1) || '*' } +} + +/** + * Renders the canonical list as the manifest the runtime strategy installs from. + * The bytes are delivered through the sandbox filesystem API, never a shell, so + * this is the second line of defense behind {@link validateDependencies}. + */ +export function renderDependencyManifest(spec: SandboxSpec): string { + const dependencies = canonicalizeDependencies(spec.language, spec.dependencies) + if (spec.language === CodeLanguage.Python) { + return `${dependencies.join('\n')}\n` + } + const map: Record = {} + for (const dependency of dependencies) { + const { name, range } = parseJsDependency(dependency) + map[name] = range + } + return `${JSON.stringify({ name: 'sim-sandbox-deps', private: true, dependencies: map }, null, 2)}\n` +} diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts index 69772f49e4..e155b2adb7 100644 --- a/apps/sim/lib/execution/remote-sandbox/types.ts +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -1,4 +1,6 @@ import type { CodeLanguage } from '@/lib/execution/languages' +import type { SandboxBuildError } from '@/lib/execution/remote-sandbox/build-errors' +import type { SandboxSpec } from '@/lib/execution/remote-sandbox/sandbox-spec' /** * Which vetted image a sandbox runs in. Every kind fails closed when its @@ -30,6 +32,10 @@ export interface SandboxExecutionRequest { * (mothership-docs) that has python-pptx/docx/openpyxl/reportlab installed. */ sandboxKind?: 'code' | 'doc' + /** Scope for {@link sandboxId}; a sandbox from another workspace is rejected. */ + workspaceId?: string + /** Workspace sandbox whose dependency set this execution runs against. */ + sandboxId?: string } export interface SandboxShellExecutionRequest { @@ -45,6 +51,10 @@ export interface SandboxShellExecutionRequest { * they run in the doc image (mothership-docs). */ sandboxKind?: 'shell' | 'doc' + /** Scope for {@link sandboxId}; a sandbox from another workspace is rejected. */ + workspaceId?: string + /** Workspace sandbox whose dependency set this execution runs against. */ + sandboxId?: string } export interface SandboxExecutionResult { @@ -106,7 +116,10 @@ export interface SandboxHandle { * override — passing `javascript` to its `codeRun` executes the source through * Python instead. We create one sandbox per execution, so binding costs nothing. */ - runCode(code: string, options: { timeoutMs: number }): Promise + runCode( + code: string, + options: { timeoutMs: number; envs?: Record } + ): Promise runCommand(command: string, options: RunCommandOptions): Promise readFile(path: string): Promise /** @@ -121,6 +134,13 @@ export interface SandboxHandle { export interface CreateSandboxOptions { /** Bound at creation — see {@link SandboxHandle.runCode}. */ language?: CodeLanguage + /** + * Provider image to create from, overriding the env-configured template. + * Honored for `code` and `shell` only: `doc` and `pi` keep their vetted images + * unconditionally, so a user's dependency set can never displace the document + * compiler's or the coding agent's. + */ + imageRef?: string /** * How long the provider may keep the sandbox alive before reaping it. Only * E2B honours this: its default is five minutes, so anything longer-running @@ -131,7 +151,61 @@ export interface CreateSandboxOptions { lifetimeMs?: number } +/** + * How a provider materializes a custom dependency set. + * + * `prebuilt` bakes it into a reusable image ahead of time (E2B, whose templates + * have no count limit and layer cheaply). `runtime` installs it inside the + * sandbox before user code runs (Daytona, whose 30-snapshot organization quota + * does not scale with tier and so cannot hold per-workspace images). + */ +export type SandboxDependencyStrategy = 'prebuilt' | 'runtime' + +export type SandboxImageStatus = 'pending' | 'building' | 'ready' | 'failed' + +/** Handle to an in-flight or completed provider build. */ +export interface SandboxImageBuild { + /** The value passed back as {@link CreateSandboxOptions.imageRef}. */ + imageRef: string + buildId: string + /** Provider-side image identifier, when it differs from the human-facing ref. */ + providerImageId?: string +} + +export interface SandboxImageBuildStatus { + status: SandboxImageStatus + error?: SandboxBuildError + /** Provider log tail, kept for the failure disclosure. */ + logs?: string +} + +/** + * Build side of a `prebuilt` provider. Split from {@link SandboxProvider} so a + * `runtime` provider simply omits it and the type makes that unambiguous. + */ +export interface SandboxImageBuilder { + startBuild(spec: SandboxSpec, specHash: string): Promise + /** `spec` is carried through so a failure can be classified against the right registry. */ + getBuildStatus(build: SandboxImageBuild, spec: SandboxSpec): Promise + /** Removes a built image from the provider. Used by the retention sweep. */ + deleteImage(build: SandboxImageBuild): Promise + /** + * Whether a failure from {@link SandboxProvider.create} means the image itself + * is gone, rather than anything else that can go wrong reaching the provider. + * + * Only a `prebuilt` provider can answer this, and it is what makes a dead + * `imageRef` self-correcting: the registry and the provider are two systems with + * no shared transaction, so instead of trying to keep them in step, the create + * that observes the truth reports it. Must stay narrow — treating an auth or + * rate-limit failure as a missing image would rebuild on every outage. + */ + isMissingImage(error: unknown): Promise +} + export interface SandboxProvider { readonly id: SandboxProviderId + readonly dependencyStrategy: SandboxDependencyStrategy + /** Present exactly when {@link dependencyStrategy} is `prebuilt`. */ + readonly images?: SandboxImageBuilder create(kind: SandboxKind, options?: CreateSandboxOptions): Promise } diff --git a/apps/sim/lib/execution/remote-sandbox/wand-enricher.ts b/apps/sim/lib/execution/remote-sandbox/wand-enricher.ts new file mode 100644 index 0000000000..16c038b500 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/wand-enricher.ts @@ -0,0 +1,170 @@ +import { db } from '@sim/db' +import { workspaceSandbox } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { CodeLanguage } from '@/lib/execution/languages' +import { + MAX_SANDBOX_DEPENDENCIES, + parseJsDependency, + registryFor, + type SandboxLanguage, +} from '@/lib/execution/remote-sandbox/sandbox-spec' + +const logger = createLogger('SandboxWandEnricher') + +/** Registry lookups are a nice-to-have; a slow one must never delay generation. */ +const REGISTRY_TIMEOUT_MS = 2_000 + +/** + * Package metadata barely changes, so a process-lifetime cache is enough — but it + * is keyed by an unbounded space (every package any workspace ever declares), so + * it evicts oldest-first rather than growing forever. + */ +const METADATA_CACHE_LIMIT = 500 +const metadataCache = new Map() + +function rememberMetadata(key: string, metadata: PackageMetadata | null): void { + if (metadataCache.size >= METADATA_CACHE_LIMIT) { + const oldest = metadataCache.keys().next() + if (!oldest.done) metadataCache.delete(oldest.value) + } + metadataCache.set(key, metadata) +} + +interface PackageMetadata { + name: string + version?: string + summary?: string +} + +/** Strips the version specifier, leaving the bare distribution/package name. */ +function bareName(language: SandboxLanguage, dependency: string): string { + if (language === CodeLanguage.Python) { + const boundary = dependency.search(/[[<>=!~]/) + return boundary === -1 ? dependency : dependency.slice(0, boundary) + } + return parseJsDependency(dependency).name +} + +async function fetchJson(url: string, signal: AbortSignal): Promise { + // boundary-raw-fetch: external-origin request to a public package registry + const response = await fetch(url, { signal, headers: { accept: 'application/json' } }) + if (!response.ok) return null + return response.json() +} + +/** + * Fetches name, resolved version, and one-line summary from the public registry. + * + * `/api/wand` is a single-shot completion with no tools, so live documentation + * retrieval is not available. This grounds *which* library and *which* version + * without an LLM tool loop. Any failure returns null and the caller degrades to + * the bare name — a registry hiccup must not fail the generation. + */ +async function fetchPackageMetadata( + language: SandboxLanguage, + name: string, + signal: AbortSignal +): Promise { + const cacheKey = `${language}:${name}` + const cached = metadataCache.get(cacheKey) + if (cached !== undefined) return cached + + let metadata: PackageMetadata | null = null + try { + if (language === CodeLanguage.Python) { + const body = (await fetchJson( + `https://pypi.org/pypi/${encodeURIComponent(name)}/json`, + signal + )) as { info?: { name?: string; version?: string; summary?: string } } | null + if (body?.info) { + metadata = { + name: body.info.name ?? name, + version: body.info.version, + summary: body.info.summary ?? undefined, + } + } + } else { + // `/latest` rather than the full packument: the root document carries every + // published version's manifest and runs to megabytes for a popular package, + // which would blow the timeout budget below just to read three fields. + const body = (await fetchJson( + `https://registry.npmjs.org/${name.split('/').map(encodeURIComponent).join('/')}/latest`, + signal + )) as { name?: string; version?: string; description?: string } | null + if (body?.name) { + metadata = { + name: body.name, + version: body.version, + summary: body.description ?? undefined, + } + } + } + } catch (error) { + logger.warn('Package registry lookup failed', { name, error }) + } + + // Only successes are cached. A miss here is usually the shared abort deadline + // below firing, and caching that would permanently degrade the prompt for a + // package that is perfectly resolvable on the next attempt. + if (metadata) rememberMetadata(cacheKey, metadata) + return metadata +} + +function describe(dependency: string, metadata: PackageMetadata | null): string { + if (!metadata) return `- ${dependency}` + const version = metadata.version ? ` (latest ${metadata.version})` : '' + const summary = metadata.summary ? ` — ${metadata.summary}` : '' + return `- ${dependency}${version}${summary}` +} + +/** + * Wand enricher that tells the model which packages the block can actually + * import. With no sandbox selected it returns null, leaving today's prompt + * unchanged. + */ +export async function enrichSandboxPackages( + workspaceId: string | null, + context: Record +): Promise { + const sandboxId = context.sandboxId as string | undefined + if (!sandboxId || !workspaceId) return null + + const [sandbox] = await db + .select({ + name: workspaceSandbox.name, + language: workspaceSandbox.language, + dependencies: workspaceSandbox.dependencies, + }) + .from(workspaceSandbox) + .where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId))) + .limit(1) + + if (!sandbox) return null + const language = sandbox.language as SandboxLanguage + const dependencies = (sandbox.dependencies ?? []).slice(0, MAX_SANDBOX_DEPENDENCIES) + if (dependencies.length === 0) return null + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS) + let metadata: (PackageMetadata | null)[] + try { + metadata = await Promise.all( + dependencies.map((dependency) => + fetchPackageMetadata(language, bareName(language, dependency), controller.signal).catch( + () => null + ) + ) + ) + } finally { + clearTimeout(timer) + } + + const registry = registryFor(language) + const lines = dependencies.map((dependency, index) => describe(dependency, metadata[index])) + return [ + `This block runs in the "${sandbox.name}" sandbox, which has these ${registry} packages installed:`, + ...lines, + 'You may import any of them. Do NOT import a package that is not on this list — it is not installed and the code will fail.', + ].join('\n') +} diff --git a/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts new file mode 100644 index 0000000000..bbea96adcd --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts @@ -0,0 +1,206 @@ +import { db } from '@sim/db' +import { sandboxImage, workspaceSandbox } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' +import type { Sandbox } from '@/lib/api/contracts/sandboxes' +import { ensureSandboxImage } from '@/lib/execution/remote-sandbox/image-registry' +import { resolveProvider } from '@/lib/execution/remote-sandbox/provider' +import { invalidateSandboxResolution } from '@/lib/execution/remote-sandbox/resolve' +import { + type DependencyIssue, + hashSandboxSpec, + type SandboxLanguage, + validateDependencies, +} from '@/lib/execution/remote-sandbox/sandbox-spec' +import type { SandboxDependencyStrategy } from '@/lib/execution/remote-sandbox/types' + +/** 403 copy for a workspace whose plan does not include sandbox authoring. */ +export const MAX_PLAN_REQUIRED = 'Sandboxes require an active Max or Enterprise plan.' + +export const SANDBOX_ADMIN_REQUIRED = 'Only workspace admins can manage sandboxes' + +/** + * The unique index that actually arbitrates sandbox-name collisions. Named here + * so a write path can recognize losing the race and answer 409 rather than 500. + */ +export const WORKSPACE_SANDBOX_NAME_INDEX = 'workspace_sandbox_workspace_name_unique' + +/** + * Builds cost provider compute, so every mutation shares one per-workspace + * budget rather than giving each admin a full allowance of their own. + */ +export const SANDBOX_MUTATION_LIMIT = { + maxTokens: 20, + refillRate: 10, + refillIntervalMs: 60_000, +} as const + +/** Thrown when a submitted dependency list has lines the editor should mark. */ +export class SandboxDependencyError extends Error { + constructor(readonly issues: DependencyIssue[]) { + super(issues[0]?.reason ?? 'Invalid dependency list') + this.name = 'SandboxDependencyError' + } +} + +export interface SandboxSpecUpdate { + language: SandboxLanguage + dependencies: string[] + specHash: string +} + +/** + * Validates a submitted list against the target language and returns the + * canonical spec. Called on every write, including a language change, so a list + * that was valid Python does not survive a switch to JavaScript unchecked. + */ +export function buildSpecUpdate( + language: SandboxLanguage, + submitted: readonly string[] +): SandboxSpecUpdate { + const validation = validateDependencies(language, submitted) + if (!validation.ok) throw new SandboxDependencyError(validation.issues) + return { + language, + dependencies: validation.dependencies, + specHash: hashSandboxSpec({ language, dependencies: validation.dependencies }), + } +} + +export function currentSandboxStrategy(): SandboxDependencyStrategy { + return resolveProvider().dependencyStrategy +} + +interface SandboxRow { + id: string + name: string + language: string + dependencies: string[] | null + createdAt: Date + updatedAt: Date +} + +interface ImageRow { + status: string + errorCode: string | null + errorMessage: string | null + errorDetail: string | null + updatedAt: Date +} + +function toSandbox(row: SandboxRow, image: ImageRow | undefined): Sandbox { + return { + id: row.id, + name: row.name, + language: row.language as Sandbox['language'], + dependencies: row.dependencies ?? [], + // A runtime-strategy deployment has no build, so the status is genuinely absent + // rather than pending — the UI branches on that to explain the tradeoff. + buildStatus: (image?.status as Sandbox['buildStatus']) ?? null, + errorCode: image?.errorCode ?? null, + errorMessage: image?.errorMessage ?? null, + errorDetail: image?.errorDetail ?? null, + builtAt: image?.status === 'ready' ? image.updatedAt.toISOString() : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** + * Joins the build registry onto a set of sandbox rows. + * + * `sandbox_image` is content-addressed and shared across every workspace, so it + * MUST be filtered by the spec hashes actually in play — selecting the whole + * provider's rows would read the entire platform's build table (log tails and + * all) to render one workspace's few sandboxes. + */ +async function attachBuildStatus(rows: (SandboxRow & { specHash: string })[]): Promise { + const provider = resolveProvider() + if (provider.dependencyStrategy !== 'prebuilt' || rows.length === 0) { + return rows.map((row) => toSandbox(row, undefined)) + } + + const specHashes = [...new Set(rows.map((row) => row.specHash))] + const images = await db + .select({ + specHash: sandboxImage.specHash, + status: sandboxImage.status, + errorCode: sandboxImage.errorCode, + errorMessage: sandboxImage.errorMessage, + errorDetail: sandboxImage.errorDetail, + updatedAt: sandboxImage.updatedAt, + }) + .from(sandboxImage) + .where(and(eq(sandboxImage.provider, provider.id), inArray(sandboxImage.specHash, specHashes))) + + const byHash = new Map(images.map((image) => [image.specHash, image])) + return rows.map((row) => toSandbox(row, byHash.get(row.specHash))) +} + +const SANDBOX_COLUMNS = { + id: workspaceSandbox.id, + name: workspaceSandbox.name, + language: workspaceSandbox.language, + dependencies: workspaceSandbox.dependencies, + specHash: workspaceSandbox.specHash, + createdAt: workspaceSandbox.createdAt, + updatedAt: workspaceSandbox.updatedAt, +} as const + +/** + * Lists a workspace's sandboxes with their build status. Under a runtime + * provider the registry is never consulted, because it is never written. + */ +export async function listWorkspaceSandboxes(workspaceId: string): Promise { + const rows = await db + .select(SANDBOX_COLUMNS) + .from(workspaceSandbox) + .where(eq(workspaceSandbox.workspaceId, workspaceId)) + .orderBy(workspaceSandbox.name) + + return attachBuildStatus(rows) +} + +/** + * Reads one sandbox back, scoped to its workspace. Fetches the single row rather + * than filtering a full list — this runs after every create and update. + */ +export async function readWorkspaceSandbox( + workspaceId: string, + sandboxId: string +): Promise { + const rows = await db + .select(SANDBOX_COLUMNS) + .from(workspaceSandbox) + .where(and(eq(workspaceSandbox.id, sandboxId), eq(workspaceSandbox.workspaceId, workspaceId))) + .limit(1) + + const [sandbox] = await attachBuildStatus(rows) + return sandbox ?? null +} + +/** + * Enqueues a build for a spec, if the active provider prebuilds. Invalidating + * the resolution cache first means an execution started right after a save never + * reads the previous image for the edited sandbox. + */ +export async function scheduleSandboxBuild(spec: SandboxSpecUpdate): Promise { + invalidateSandboxResolution() + await ensureSandboxImage( + { language: spec.language, dependencies: spec.dependencies }, + spec.specHash + ) +} + +/** True when a name is already taken in the workspace by a different sandbox. */ +export async function isSandboxNameTaken( + workspaceId: string, + name: string, + excludeId?: string +): Promise { + const [existing] = await db + .select({ id: workspaceSandbox.id }) + .from(workspaceSandbox) + .where(and(eq(workspaceSandbox.workspaceId, workspaceId), eq(workspaceSandbox.name, name))) + .limit(1) + return Boolean(existing && existing.id !== excludeId) +} diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index 9d340d0e9d..ff18c4129b 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -20,6 +20,7 @@ import { isSubBlockFeatureEnabled, isSubBlockHidden, isSubBlockVisibleForMode, + isToolInputOnlySubBlock, isTriggerModeSubBlock, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' @@ -172,6 +173,10 @@ function getVisiblePreviewSubBlockCount(block: BlockState): number { return blockConfig.subBlocks.filter((subBlock) => { if (subBlock.hidden || subBlock.hideFromPreview) return false if (!isSubBlockFeatureEnabled(subBlock)) return false + // Never rendered on the canvas, so it must not contribute to node height — + // this filter has to agree with `workflow-block.tsx`'s or blocks lay out + // with a phantom row of space. + if (isToolInputOnlySubBlock(subBlock)) return false if (isSubBlockHidden(subBlock)) return false if (effectiveTrigger) { diff --git a/apps/sim/lib/workflows/subblocks/display.test.ts b/apps/sim/lib/workflows/subblocks/display.test.ts index 310af5d370..5be68fcca0 100644 --- a/apps/sim/lib/workflows/subblocks/display.test.ts +++ b/apps/sim/lib/workflows/subblocks/display.test.ts @@ -15,6 +15,7 @@ import { getDisplayValue, resolveDropdownLabel, resolveFilterFieldLabel, + resolveSandboxLabel, resolveSkillsLabel, resolveToolsLabel, resolveVariablesLabel, @@ -33,6 +34,7 @@ const workflowMulti = { const variablesInput = { id: 'variables', type: 'variables-input' } as SubBlockConfig const toolInput = { id: 'tools', type: 'tool-input' } as SubBlockConfig const skillInput = { id: 'skills', type: 'skill-input' } as SubBlockConfig +const sandboxPicker = { id: 'sandboxId', type: 'combobox' } as SubBlockConfig describe('summarizeNames', () => { it('formats 0, 1, 2, and 2+N name lists', () => { @@ -184,6 +186,28 @@ describe('resolveSkillsLabel', () => { }) }) +describe('resolveSandboxLabel', () => { + const sandboxes = [{ id: '443f4934-26ab-44ab-8000-000000000000', name: 'Test' }] + + it('resolves the stored id to the name so the card never shows a uuid', () => { + expect(resolveSandboxLabel(sandboxPicker, sandboxes[0].id, sandboxes)).toBe('Test') + }) + + it('returns null for an id the workspace no longer has', () => { + expect(resolveSandboxLabel(sandboxPicker, 'sbx-deleted', sandboxes)).toBeNull() + }) + + it('returns null before the list loads, and for an empty selection', () => { + expect(resolveSandboxLabel(sandboxPicker, sandboxes[0].id, [])).toBeNull() + expect(resolveSandboxLabel(sandboxPicker, '', sandboxes)).toBeNull() + }) + + it('ignores other comboboxes so it cannot relabel an unrelated field', () => { + const other = { id: 'model', type: 'combobox' } as SubBlockConfig + expect(resolveSandboxLabel(other, sandboxes[0].id, sandboxes)).toBeNull() + }) +}) + describe('resolveDropdownLabel', () => { const dropdown = { id: 'mode', diff --git a/apps/sim/lib/workflows/subblocks/display.ts b/apps/sim/lib/workflows/subblocks/display.ts index 26088c25ff..17689f6881 100644 --- a/apps/sim/lib/workflows/subblocks/display.ts +++ b/apps/sim/lib/workflows/subblocks/display.ts @@ -544,3 +544,26 @@ export function resolveSkillsLabel( return summarizeNames(names) } + +/** + * Resolves the Function block's stored sandbox id to the sandbox name. + * + * Unlike its siblings there is no dedicated subblock type to match on: the picker + * is a plain `combobox` whose options load asynchronously, so its static + * `options` array is empty and {@link resolveDropdownLabel} finds nothing — + * leaving the block card printing a raw UUID. Matching the field id is what + * narrows this to the one picker that needs it. + * + * An id with no matching sandbox returns `null` rather than a guess, so a deleted + * sandbox falls through to the caller's own placeholder. + */ +export function resolveSandboxLabel( + subBlock: SubBlockConfig | undefined, + rawValue: unknown, + sandboxes: Array<{ id: string; name: string }> +): string | null { + if (subBlock?.id !== 'sandboxId' || subBlock.type !== 'combobox') return null + if (typeof rawValue !== 'string' || !rawValue) return null + + return sandboxes.find((sandbox) => sandbox.id === rawValue)?.name ?? null +} diff --git a/apps/sim/lib/workflows/subblocks/options.ts b/apps/sim/lib/workflows/subblocks/options.ts index 526dcb0d82..9c6dc78216 100644 --- a/apps/sim/lib/workflows/subblocks/options.ts +++ b/apps/sim/lib/workflows/subblocks/options.ts @@ -1,6 +1,14 @@ +import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api' import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { + environmentKeys, + PERSONAL_ENVIRONMENT_STALE_TIME, + WORKSPACE_ENVIRONMENT_STALE_TIME, +} from '@/hooks/queries/environment' +import { getSandboxListQueryOptions, type SandboxListResponse } from '@/hooks/queries/sandboxes' import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' interface SubBlockOption { label: string @@ -30,3 +38,113 @@ export async function fetchWorkspaceWorkflowOptions(options?: { ) .map((workflow) => ({ id: workflow.id, label: workflow.name })) } + +/** + * Loads the active workspace's secret NAMES for the Function block's secret-scope + * picker. Names only — values stay server-side and are injected at execution, the + * same discipline the copilot's workspace context uses. + */ +export async function fetchWorkspaceSecretNameOptions(): Promise { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + + const [workspace, personal] = await Promise.all([ + getQueryClient().fetchQuery({ + queryKey: environmentKeys.workspace(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchWorkspaceEnvironment(workspaceId, signal), + staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, + }), + getQueryClient().fetchQuery({ + queryKey: environmentKeys.personal(), + queryFn: ({ signal }: { signal?: AbortSignal }) => fetchPersonalEnvironment(signal), + staleTime: PERSONAL_ENVIRONMENT_STALE_TIME, + }), + ]) + + // Personal variables shadow workspace ones at execution, so both are offered + // under one de-duplicated list of names. + const names = new Set([ + ...Object.keys(workspace?.workspace?.variables ?? {}), + ...Object.keys(personal ?? {}), + ]) + return [...names].sort().map((name) => ({ id: name, label: name })) +} + +/** + * Labels a sandbox for the picker. The name is what identifies it, so that is all + * the label carries by default — the block's own list is already scoped to one + * language, where repeating it on every row is noise. + * + * `showLanguage` serves the one caller that cannot filter: agent tool-input + * renders this field under a synthetic id where the sibling `language` value is + * unreachable, so its list spans both languages and the name alone is ambiguous. + * + * A failed build is always marked. It is the difference between a selection that + * runs and one that does not, so it is not decoration. + */ +function toSandboxOption( + sandbox: { + id: string + name: string + language: string + buildStatus: string | null + }, + options?: { showLanguage?: boolean } +): SubBlockOption { + const parts = [sandbox.name] + if (options?.showLanguage) { + parts.push(sandbox.language === 'python' ? 'Python' : 'JavaScript') + } + if (sandbox.buildStatus === 'failed') parts.push('build failed') + return { id: sandbox.id, label: parts.join(' · ') } +} + +async function loadWorkspaceSandboxes(): Promise { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + const data = await getQueryClient().fetchQuery(getSandboxListQueryOptions(workspaceId)) + return data.sandboxes +} + +/** + * Loads the sandboxes a Function block can run in, scoped to the language its + * sibling `language` subblock selects — a Python block must never be offered an + * npm sandbox. The block re-fetches when `language` changes (`dependsOn`). + */ +export async function fetchWorkspaceSandboxOptions(blockId: string): Promise { + const language = useSubBlockStore.getState().getValue(blockId, 'language') + const sandboxes = await loadWorkspaceSandboxes() + // The missing `language` that makes filtering impossible is exactly what makes + // the language worth showing, so the two stay in lockstep. + const showLanguage = !language + return sandboxes + .filter((sandbox) => !language || sandbox.language === language) + .map((sandbox) => toSandboxOption(sandbox, { showLanguage })) +} + +/** + * Hydrates a stored sandbox id to its label before the option list loads. + * + * A selection left over from before a language switch is still shown, flagged + * rather than hidden. Returning `null` here would drop the field back to its + * "Default image" placeholder while the value stayed stored + * and stayed fatal at execution — the field would read as cleared and the run + * would still fail, with nothing to point at. Labelling it is what lets the + * author see what to fix. + */ +export async function fetchWorkspaceSandboxOption( + blockId: string, + optionId: string +): Promise { + const language = useSubBlockStore.getState().getValue(blockId, 'language') + const sandboxes = await loadWorkspaceSandboxes() + const sandbox = sandboxes.find((candidate) => candidate.id === optionId) + if (!sandbox) return null + + const option = toSandboxOption(sandbox, { showLanguage: !language }) + if (language && sandbox.language !== language) { + return { ...option, label: `${option.label} · wrong language for this block` } + } + return option +} diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index b64fc025a2..ef588530a0 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -518,12 +518,37 @@ export function resolveDependencyValue( return values[dependencyKey] } +/** + * Whether a subblock only applies when the block is used as an agent tool. + * + * `paramVisibility` filters what appears *inside* tool-input but cannot hide a + * subblock from the canvas, so this is a separate axis rather than another + * visibility level. + */ +export function isToolInputOnlySubBlock(subBlock: Pick): boolean { + return subBlock.context === 'tool-input' +} + +/** + * Whether any env var named by an env-gate spec is truthy. + * + * A gate may name several vars, comma-separated, meaning "any of these" — that + * is what lets a renamed flag ship without every existing deployment losing the + * field until it sets the new var. Shared by both gates so the two cannot + * interpret their value differently. + */ +function anyEnvSet(spec: string): boolean { + return spec.split(',').some((name) => isTruthy(getEnv(name.trim()))) +} + /** * Check if a subblock is gated by a feature flag. */ -export function isSubBlockFeatureEnabled(subBlock: SubBlockConfig): boolean { +export function isSubBlockFeatureEnabled( + subBlock: Pick +): boolean { if (!subBlock.showWhenEnvSet) return true - return isTruthy(getEnv(subBlock.showWhenEnvSet)) + return anyEnvSet(subBlock.showWhenEnvSet) } /** @@ -539,6 +564,6 @@ export function isSubBlockHidden( ): boolean { const hosted = options?.hosted ?? isHosted if (subBlock.hideWhenHosted && hosted) return true - if (subBlock.hideWhenEnvSet && isTruthy(getEnv(subBlock.hideWhenEnvSet))) return true + if (subBlock.hideWhenEnvSet && anyEnvSet(subBlock.hideWhenEnvSet)) return true return false } diff --git a/apps/sim/lib/workspaces/admin-move.ts b/apps/sim/lib/workspaces/admin-move.ts index fa6243fdaa..ef6fcf160c 100644 --- a/apps/sim/lib/workspaces/admin-move.ts +++ b/apps/sim/lib/workspaces/admin-move.ts @@ -16,7 +16,12 @@ import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' import { and, asc, count, eq, ilike, inArray, isNull, ne, or, sql } from 'drizzle-orm' import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { isEnterprise } from '@/lib/billing/plan-helpers' import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer' +import { + ENTITLED_SUBSCRIPTION_STATUSES, + hasPaidSubscriptionStatus, +} from '@/lib/billing/subscriptions/utils' import { enqueueOutboxEvent, type OutboxHandler } from '@/lib/core/outbox/service' import type { DbOrTx } from '@/lib/db/types' import { getInvitationById } from '@/lib/invitations/core' @@ -31,7 +36,6 @@ import { import { WORKSPACE_MODE } from '@/lib/workspaces/policy' const logger = createLogger('AdminWorkspaceMove') -const ENTITLED_STATUSES = ['active', 'past_due'] as const export class WorkspaceMoveError extends Error { constructor( @@ -203,7 +207,7 @@ export async function getWorkspaceMovePreflight( .where( and( eq(subscription.referenceId, destinationOrganizationId), - inArray(subscription.status, [...ENTITLED_STATUSES]) + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) ) ) .limit(1), @@ -546,7 +550,7 @@ function getEnterpriseSeatCapacity(row?: { status: string | null metadata: unknown }): number | null { - if (!row || row.plan !== 'enterprise' || !ENTITLED_STATUSES.includes(row.status as 'active')) { + if (!row || !isEnterprise(row.plan) || !hasPaidSubscriptionStatus(row.status)) { return null } if (!row.metadata || typeof row.metadata !== 'object') return null diff --git a/apps/sim/lib/workspaces/policy.test.ts b/apps/sim/lib/workspaces/policy.test.ts index 806ede152f..82918d27bd 100644 --- a/apps/sim/lib/workspaces/policy.test.ts +++ b/apps/sim/lib/workspaces/policy.test.ts @@ -143,6 +143,76 @@ describe('getWorkspaceCreationPolicy', () => { expect(result.currentWorkspaceCount).toBe(5) }) + // The Max cap previously read `isMax`, which required `isPro` and so excluded + // both `team_25000` and `enterprise`. Those tiers fell to the `isPro ? 3 : 1` + // branch and got ONE personal workspace — fewer than a plain Pro's three. + it('gives the team plan at the Max credit tier the same ten personal workspaces as Max', async () => { + mockGetUserOrganization.mockResolvedValue({ + organizationId: 'org-1', + role: 'owner', + memberId: 'member-1', + }) + // A past_due org subscription is not `hasUsableSubscriptionStatus`, so the + // organization branch does not apply and the personal cap decides. + mockGetOrganizationSubscription.mockResolvedValue({ + id: 'sub-1', + plan: 'team_25000', + status: 'past_due', + }) + mockGetHighestPrioritySubscription.mockResolvedValueOnce({ + id: 'sub-1', + plan: 'team_25000', + status: 'past_due', + }) + queueTableRows(workspace, [{ value: 5 }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) + + expect(result.canCreate).toBe(true) + expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL) + expect(result.maxWorkspaces).toBe(10) + }) + + it('gives an enterprise payer ten personal workspaces despite carrying no credit suffix', async () => { + mockGetHighestPrioritySubscription.mockResolvedValueOnce({ + id: 'sub-1', + plan: 'enterprise', + status: 'active', + }) + queueTableRows(workspace, [{ value: 5 }]) + + const result = await getWorkspaceCreationPolicy({ userId: 'user-1' }) + + expect(result.canCreate).toBe(true) + expect(result.workspaceMode).toBe(WORKSPACE_MODE.PERSONAL) + expect(result.maxWorkspaces).toBe(10) + }) + + // The personal cap is only a fallback: an enterprise org admin is routed to + // organization mode and is uncapped, which is why the bug above stayed hidden. + it('leaves enterprise organization workspaces uncapped for org admins', async () => { + mockGetUserOrganization.mockResolvedValueOnce({ + organizationId: 'org-1', + role: 'owner', + memberId: 'member-1', + }) + mockGetOrganizationSubscription.mockResolvedValueOnce({ + id: 'sub-1', + plan: 'enterprise', + status: 'active', + }) + queueTableRows(member, [{ userId: 'owner-1' }]) + + const result = await getWorkspaceCreationPolicy({ + userId: 'user-1', + activeOrganizationId: 'org-1', + }) + + expect(result.canCreate).toBe(true) + expect(result.workspaceMode).toBe(WORKSPACE_MODE.ORGANIZATION) + expect(result.maxWorkspaces).toBeNull() + }) + it('blocks max users once they already own ten personal workspaces', async () => { mockGetHighestPrioritySubscription.mockResolvedValueOnce({ id: 'sub-1', diff --git a/apps/sim/lib/workspaces/policy.ts b/apps/sim/lib/workspaces/policy.ts index 70d3cdb06d..6fc9218487 100644 --- a/apps/sim/lib/workspaces/policy.ts +++ b/apps/sim/lib/workspaces/policy.ts @@ -7,7 +7,7 @@ import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { getUserOrganization } from '@/lib/billing/organizations/membership' import type { PlanCategory } from '@/lib/billing/plan-helpers' -import { getPlanType, isEnterprise, isMax, isPro, isTeam } from '@/lib/billing/plan-helpers' +import { getPlanType, isEnterprise, isMaxTier, isPro, isTeam } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/env-flags' import type { DbOrTx } from '@/lib/db/types' @@ -411,7 +411,17 @@ export async function getWorkspaceCreationPolicy({ const highestPrioritySubscription = await getHighestPrioritySubscription(userId) const plan = highestPrioritySubscription?.plan - const maxWorkspaces = isMax(plan) ? 10 : isPro(plan) ? 3 : 1 + /** + * Personal (non-organization) workspace cap. Organization workspaces are + * uncapped and returned above, so this is only reached when the org branch does + * not apply — including when a Team/Enterprise org's subscription is `past_due` + * and therefore not `hasUsableSubscriptionStatus`. + * + * Deliberately tier-only: `getHighestPrioritySubscription` already admits + * `past_due`, and delinquency is enforced by the billing-blocked gates rather + * than by shrinking the cap, which would only obstruct recovery. + */ + const maxWorkspaces = isMaxTier(plan) ? 10 : isPro(plan) ? 3 : 1 const currentWorkspaceCount = await countNonOrganizationOwnedWorkspaces(userId) if (currentWorkspaceCount >= maxWorkspaces) { diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 2bb1a9d31c..1538846e58 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -129,6 +129,13 @@ export interface ProviderToolConfig { required: string[] } usageControl?: ToolUsageControl + /** + * Params the model may never supply, because the tool declares them + * `user-only` or `hidden`. Stripped from the model's arguments before they + * merge with the user's — omitting them from {@link ProviderToolConfig.parameters} + * alone does not stop a model from emitting one anyway. + */ + modelBlockedParams?: string[] /** Block-level params transformer — converts SubBlock values to tool-ready params */ paramsTransform?: (params: Record) => Record } diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 05d60e37a1..5b2f0febaf 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -581,6 +581,45 @@ function buildCustomBlockInputMappingSchema( * @param options Additional options including dependencies and selected operation * @returns The provider tool config or null if transform fails */ +/** + * Drops model-supplied arguments for params the tool declares off-limits to the + * model (`user-only` / `hidden`). + * + * Deliberately keyed on the declared visibility rather than on "absent from + * `parameters.properties`": an MCP or custom tool may legitimately accept keys + * beyond its advertised properties (`additionalProperties`), and silently + * dropping those would truncate its arguments. Only a param the tool itself + * marked as not-for-the-model is removed. + */ +function stripModelBlockedParams( + blockedParams: string[] | undefined, + llmArgs: Record +): Record { + if (!blockedParams?.length) return llmArgs + const blocked = new Set(blockedParams) + const filtered: Record = {} + for (const [key, value] of Object.entries(llmArgs)) { + if (!blocked.has(key)) filtered[key] = value + } + return filtered +} + +/** Reads a multi-select value that may still be JSON-encoded from `StoredTool.params`. */ +function readMountedSecretNames(raw: unknown): string[] { + let value = raw + if (typeof value === 'string') { + try { + value = JSON.parse(value) + } catch { + // A bare name rather than a list — treat it as a single entry. + return value ? [value as string] : [] + } + } + return Array.isArray(value) + ? value.filter((name): name is string => typeof name === 'string' && name.length > 0) + : [] +} + export async function transformBlockTool( block: any, options: { @@ -715,10 +754,11 @@ export async function transformBlockTool( const userProvidedParams = block.params || {} - const { schema: llmSchema, enrichedDescription } = await createLLMToolSchema( - toolConfig, - userProvidedParams - ) + const { + schema: llmSchema, + enrichedDescription, + modelBlockedParams, + } = await createLLMToolSchema(toolConfig, userProvidedParams) const canonicalGroups: CanonicalGroup[] = blockDef?.subBlocks ? Object.values(buildCanonicalIndex(blockDef.subBlocks).groupsById).filter(isCanonicalPair) @@ -747,6 +787,16 @@ export async function transformBlockTool( toolDescription = workflowMetadata.description } } + } else if (toolId === 'function_execute' && resolvedResourceParams.secretScope === 'selected') { + // Scoping alone would leave the model guessing: the secrets are injected + // server-side and nothing else advertises them. Names only — values never + // enter the provider request, matching the copilot's workspace-context rule. + // `StoredTool.params` holds strings, so a multi-select arrives JSON-encoded; + // the executor's paramsTransform parses it later, but this runs before that. + const mounted = readMountedSecretNames(resolvedResourceParams.mountedSecrets) + toolDescription = mounted.length + ? `${toolDescription}\n\nWorkspace secrets available to this code: ${mounted.join(', ')}. Reference one as {{NAME}} or environmentVariables['NAME']. No other secrets are readable.` + : `${toolDescription}\n\nThis code has no access to workspace secrets.` } else if (toolId.startsWith('knowledge_') && resolvedResourceParams.knowledgeBaseId) { uniqueToolId = `${toolConfig.id}_${resolvedResourceParams.knowledgeBaseId}` } else if (toolId.startsWith('table_') && resolvedResourceParams.tableId) { @@ -812,6 +862,7 @@ export async function transformBlockTool( description: toolDescription, params: userProvidedParams, parameters: llmSchema, + modelBlockedParams, paramsTransform, } } @@ -1408,6 +1459,7 @@ export function prepareToolExecution( tool: { params?: Record parameters?: Record + modelBlockedParams?: string[] paramsTransform?: (params: Record) => Record }, llmArgs: Record, @@ -1428,7 +1480,16 @@ export function prepareToolExecution( toolParams: Record executionParams: Record } { - let toolParams = mergeToolParameters(tool.params || {}, llmArgs) as Record + // Providers are supposed to emit only declared arguments, but nothing enforces + // it on the parsed tool call — and `mergeToolParameters` seeds its result from + // the model's args, so an undeclared key survives whenever the user's value is + // empty. That is a privilege escalation for `user-only` params: a Function tool + // scoped to "Selected secrets" with an empty list is an explicit deny, and a + // model emitting `mountedSecrets: ['STRIPE_KEY']` would otherwise mount it. + let toolParams = mergeToolParameters( + tool.params || {}, + stripModelBlockedParams(tool.modelBlockedParams, llmArgs) + ) as Record if (tool.paramsTransform) { try { diff --git a/apps/sim/scripts/verify-sandbox-parity.ts b/apps/sim/scripts/verify-sandbox-parity.ts index 1820a291b8..2732a2fff2 100644 --- a/apps/sim/scripts/verify-sandbox-parity.ts +++ b/apps/sim/scripts/verify-sandbox-parity.ts @@ -20,6 +20,11 @@ * DAYTONA_DOC_SNAPSHOT_ID=mothership-docs: \ * bun run apps/sim/scripts/verify-sandbox-parity.ts * + * # Include the dependency-set cases (needs real workspace_sandbox rows): + * SANDBOX_PARITY_WORKSPACE_ID=... \ + * SANDBOX_PARITY_PYTHON_SANDBOX_ID=... # a Python sandbox declaring `pandas` + * SANDBOX_PARITY_JS_SANDBOX_ID=... # a JavaScript sandbox declaring `axios` + * * Exits non-zero if any case fails, so it can be wired to a schedule later. */ @@ -33,9 +38,20 @@ interface Case { name: string /** Skipped unless the doc image is configured for the active provider. */ needsDoc?: boolean + /** + * Skipped unless `SANDBOX_PARITY_PYTHON_SANDBOX_ID` / + * `SANDBOX_PARITY_JS_SANDBOX_ID` name a real workspace sandbox — resolving one + * needs a DB row, so it cannot be synthesized here. + */ + needsSandbox?: 'python' | 'javascript' run: () => Promise<{ ok: boolean; detail: string }> } +/** A workspace + sandbox to resolve the dependency-set cases against. */ +const PARITY_WORKSPACE_ID = process.env.SANDBOX_PARITY_WORKSPACE_ID +const PARITY_PYTHON_SANDBOX_ID = process.env.SANDBOX_PARITY_PYTHON_SANDBOX_ID +const PARITY_JS_SANDBOX_ID = process.env.SANDBOX_PARITY_JS_SANDBOX_ID + const CASES: Case[] = [ { name: 'python: result marker round-trip', @@ -104,6 +120,38 @@ const CASES: Case[] = [ return { ok: platform === 'linux', detail: res.error ?? `platform=${platform}` } }, }, + { + // Prebuilt on E2B, runtime install on Daytona — the same selection must make + // the same import succeed on both. + name: 'python: a selected sandbox makes its packages importable', + needsSandbox: 'python', + run: async () => { + const res = await executeInSandbox({ + code: `import json, pandas\nprint("${SIM_RESULT_PREFIX}" + json.dumps({"v": pandas.__version__}))`, + language: CodeLanguage.Python, + timeoutMs: 300_000, + workspaceId: PARITY_WORKSPACE_ID, + sandboxId: PARITY_PYTHON_SANDBOX_ID, + }) + const version = (res.result as { v?: string } | null)?.v + return { ok: Boolean(version), detail: res.error ?? `pandas=${version}` } + }, + }, + { + name: 'javascript: a selected sandbox resolves its packages', + needsSandbox: 'javascript', + run: async () => { + const res = await executeInSandbox({ + code: `const axios = require('axios')\nconsole.log('${SIM_RESULT_PREFIX}' + JSON.stringify({ ok: typeof axios.get === 'function' }))`, + language: CodeLanguage.JavaScript, + timeoutMs: 300_000, + workspaceId: PARITY_WORKSPACE_ID, + sandboxId: PARITY_JS_SANDBOX_ID, + }) + const ok = (res.result as { ok?: boolean } | null)?.ok + return { ok: ok === true, detail: res.error ?? `resolved=${ok}` } + }, + }, { name: 'shell: env vars + user-authored marker', run: async () => { @@ -169,6 +217,13 @@ async function main() { skipped++ continue } + const sandboxId = + testCase.needsSandbox === 'python' ? PARITY_PYTHON_SANDBOX_ID : PARITY_JS_SANDBOX_ID + if (testCase.needsSandbox && !(PARITY_WORKSPACE_ID && sandboxId)) { + console.log(`SKIP ${testCase.name} (no ${testCase.needsSandbox} sandbox configured)`) + skipped++ + continue + } const started = Date.now() try { const { ok, detail } = await testCase.run() diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index f018f509ce..73c2b9f145 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -12,6 +12,7 @@ import { isNonEmptyValue, isSubBlockFeatureEnabled, isSubBlockHidden, + isToolInputOnlySubBlock, resolveCanonicalMode, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' @@ -52,6 +53,11 @@ function shouldSerializeSubBlock( canonicalModeOverrides?: CanonicalModeOverrides ): boolean { if (!isSubBlockFeatureEnabled(subBlockConfig)) return false + // Only meaningful when the block is invoked as an agent tool, where the + // value lives on the tool entry rather than the block. Serializing it here + // would let a non-UI writer (copilot, YAML import) set an invisible secret + // scope that the executor's env inlining does not honor. + if (isToolInputOnlySubBlock(subBlockConfig)) return false if (isSubBlockHidden(subBlockConfig)) return false if (subBlockConfig.mode === 'trigger') { diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index 857799f65e..b9e19b891a 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -85,6 +85,24 @@ export const functionExecuteTool: ToolConfig } + /** Workspace sandbox whose dependency set this execution runs against. */ + sandboxId?: string + /** + * Which workspace secrets the code may read. Unset and `'all'` both mean every + * secret, resolved at execution so ones added later are included. + */ + secretScope?: 'all' | 'selected' + /** Secret names visible to the code when {@link secretScope} is `'selected'`. */ + mountedSecrets?: string[] envVars?: Record workflowVariables?: Record blockData?: Record diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 1ffc78914a..9ea7d81b1b 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -6,6 +6,7 @@ import { type CanonicalModeOverrides, evaluateSubBlockCondition, isCanonicalPair, + isSubBlockFeatureEnabled, isSubBlockHidden, isTriggerModeSubBlock, resolveCanonicalMode, @@ -152,6 +153,14 @@ export interface UserToolSchemaOptions { export interface LLMToolSchemaResult { schema: ToolSchema enrichedDescription?: string + /** + * Params the model is never allowed to supply, because the tool declares them + * `user-only` or `hidden`. Omitting them from {@link schema} is not enough on + * its own — nothing stops a model from emitting an undeclared key, and the + * merge downstream seeds from the model's args — so the names travel with the + * schema for `prepareToolExecution` to strip. + */ + modelBlockedParams?: string[] } export interface ValidationResult { @@ -637,6 +646,13 @@ export async function createLLMToolSchema( required: [], } + // Derived from the declarations rather than from which branch below skipped a + // param: the loop's `continue`s also skip params the user simply filled in, + // and those are not off-limits to the model. + const modelBlockedParams = Object.entries(toolConfig.params) + .filter(([, param]) => param.visibility === 'user-only' || param.visibility === 'hidden') + .map(([paramId]) => paramId) + for (const [paramId, param] of Object.entries(toolConfig.params)) { const enrichmentConfig = toolConfig.schemaEnrichment?.[paramId] @@ -705,12 +721,13 @@ export async function createLLMToolSchema( return { schema: enriched.parameters as ToolSchema, enrichedDescription: enriched.description, + modelBlockedParams, } } } } - return { schema } + return { schema, modelBlockedParams } } /** @@ -1142,6 +1159,11 @@ export function getSubBlocksForToolInput( // Hide tool API key fields when running on hosted Sim or when env var is set if (isSubBlockHidden(sb)) continue + // A field the deployment has switched off is not offerable here either — + // the canvas already hides it, and offering it in tool-input lets an author + // pick a value the executor will refuse (e.g. Python with no sandbox provider). + if (!isSubBlockFeatureEnabled(sb)) continue + // Determine the effective param ID (canonical or subblock id) const effectiveParamId = sb.canonicalParamId || sb.id diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index 14df76348b..d167b0cad4 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: sim description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents type: application -version: 1.3.0 +version: 1.4.0 appVersion: "v0.7.44" kubeVersion: ">=1.25.0-0" home: https://sim.ai diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 5fb76fd931..72f8cf5255 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -1374,6 +1374,18 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 + # Deletes prebuilt sandbox images that no workspace sandbox references and that + # have gone unused past the retention window, from the provider and locally. + # A no-op on deployments whose sandbox provider installs at run time. + cleanupSandboxImages: + enabled: true + name: cleanup-sandbox-images + schedule: "30 4 * * *" + path: "/api/cron/cleanup-sandbox-images" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + # Processes the transactional outbox (deployment side-effects retry / dead-letter) # and reaps stale workspace-fork background-work rows. Both are safety nets behind # the immediate post-commit processing, so frequent runs keep retries timely. diff --git a/packages/db/migrations/0277_workspace_sandboxes.sql b/packages/db/migrations/0277_workspace_sandboxes.sql new file mode 100644 index 0000000000..ef603ac206 --- /dev/null +++ b/packages/db/migrations/0277_workspace_sandboxes.sql @@ -0,0 +1,39 @@ +CREATE TYPE "public"."sandbox_image_status" AS ENUM('pending', 'building', 'ready', 'failed');--> statement-breakpoint +CREATE TYPE "public"."sandbox_language" AS ENUM('javascript', 'python');--> statement-breakpoint +CREATE TABLE "sandbox_image" ( + "id" text PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "spec_hash" text NOT NULL, + "spec" jsonb NOT NULL, + "status" "sandbox_image_status" DEFAULT 'pending' NOT NULL, + "image_ref" text, + "provider_image_id" text, + "build_id" text, + "error_code" text, + "error_message" text, + "error_detail" text, + "last_used_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workspace_sandbox" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "name" text NOT NULL, + "language" "sandbox_language" NOT NULL, + "dependencies" jsonb DEFAULT '[]'::jsonb NOT NULL, + "spec_hash" text NOT NULL, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspace_sandbox" ADD CONSTRAINT "workspace_sandbox_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_sandbox" ADD CONSTRAINT "workspace_sandbox_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "sandbox_image_provider_spec_unique" ON "sandbox_image" USING btree ("provider","spec_hash");--> statement-breakpoint +CREATE INDEX "sandbox_image_status_idx" ON "sandbox_image" USING btree ("status");--> statement-breakpoint +CREATE INDEX "sandbox_image_last_used_idx" ON "sandbox_image" USING btree ("last_used_at");--> statement-breakpoint +CREATE UNIQUE INDEX "workspace_sandbox_workspace_name_unique" ON "workspace_sandbox" USING btree ("workspace_id","name");--> statement-breakpoint +CREATE INDEX "workspace_sandbox_workspace_idx" ON "workspace_sandbox" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "workspace_sandbox_spec_hash_idx" ON "workspace_sandbox" USING btree ("spec_hash"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0277_snapshot.json b/packages/db/migrations/meta/0277_snapshot.json new file mode 100644 index 0000000000..c6924f1fb9 --- /dev/null +++ b/packages/db/migrations/meta/0277_snapshot.json @@ -0,0 +1,18285 @@ +{ + "id": "3944d26f-f7b1-48e8-87a5-fd9bc577e10a", + "prevId": "100afbc7-1a7f-4f05-9950-b2dbd6996c15", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 43d813aafe..d9cc85fe28 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1933,6 +1933,13 @@ "when": 1785352177983, "tag": "0276_drop_legacy_folder_tables", "breakpoints": true + }, + { + "idx": 277, + "version": "7", + "when": 1785358812851, + "tag": "0277_workspace_sandboxes", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 778f1aa842..6f872b2d3a 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -4272,3 +4272,86 @@ export const dataDrainRuns = pgTable( drainStartedIdx: index('data_drain_runs_drain_started_idx').on(table.drainId, table.startedAt), }) ) + +export const sandboxLanguageEnum = pgEnum('sandbox_language', ['javascript', 'python']) + +export type SandboxLanguageValue = (typeof sandboxLanguageEnum.enumValues)[number] + +export const sandboxImageStatusEnum = pgEnum('sandbox_image_status', [ + 'pending', + 'building', + 'ready', + 'failed', +]) + +export type SandboxImageStatusValue = (typeof sandboxImageStatusEnum.enumValues)[number] + +/** + * A workspace's named library of dependency sets. Provider-agnostic: the same + * row drives a prebuilt E2B template and a Daytona runtime install, and only the + * materialization step differs. `specHash` is the content address shared with + * `sandboxImage`, so editing dependencies points the sandbox at a new build + * while the old one stays valid for in-flight executions. + */ +export const workspaceSandbox = pgTable( + 'workspace_sandbox', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id') + .notNull() + .references(() => workspace.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + language: sandboxLanguageEnum('language').notNull(), + dependencies: jsonb('dependencies').$type().notNull().default(sql`'[]'::jsonb`), + specHash: text('spec_hash').notNull(), + createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + workspaceNameUnique: uniqueIndex('workspace_sandbox_workspace_name_unique').on( + table.workspaceId, + table.name + ), + workspaceIdx: index('workspace_sandbox_workspace_idx').on(table.workspaceId), + specHashIdx: index('workspace_sandbox_spec_hash_idx').on(table.specHash), + }) +) + +/** + * Build registry for the prebuilt strategy, keyed by content address so two + * workspaces declaring the same dependency set share one build. Never written + * under a runtime-strategy provider. + */ +export const sandboxImage = pgTable( + 'sandbox_image', + { + id: text('id').primaryKey(), + provider: text('provider').notNull(), + specHash: text('spec_hash').notNull(), + spec: jsonb('spec').notNull(), + status: sandboxImageStatusEnum('status').notNull().default('pending'), + /** Passed to the provider at create time once `status` is `ready`. */ + imageRef: text('image_ref'), + /** Provider-side image identifier, when it differs from `imageRef`. */ + providerImageId: text('provider_image_id'), + buildId: text('build_id'), + /** Classified taxonomy code; see lib/execution/remote-sandbox/build-errors.ts. */ + errorCode: text('error_code'), + /** User-facing copy rendered from the code at classification time. */ + errorMessage: text('error_message'), + /** Installer log tail, shown behind a disclosure. */ + errorDetail: text('error_detail'), + lastUsedAt: timestamp('last_used_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + providerSpecUnique: uniqueIndex('sandbox_image_provider_spec_unique').on( + table.provider, + table.specHash + ), + statusIdx: index('sandbox_image_status_idx').on(table.status), + lastUsedIdx: index('sandbox_image_last_used_idx').on(table.lastUsedAt), + }) +) diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index d8e3ecd699..d62fd10271 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -29,6 +29,7 @@ export interface EnvFlagsMockState { isAccessControlEnabled: boolean isOrganizationsEnabled: boolean isInboxEnabled: boolean + isSandboxesEnabled: boolean isWhitelabelingEnabled: boolean isAuditLogsEnabled: boolean isDataRetentionEnabled: boolean @@ -76,6 +77,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { // `true` so upgrades do not remove a feature. See // ENTERPRISE_FEATURE_LEGACY_DEFAULTS. isInboxEnabled: true, + isSandboxesEnabled: true, isWhitelabelingEnabled: true, isSessionPoliciesEnabled: true, isAuditLogsEnabled: false, diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index 5cccc2c8af..e0803f29e0 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -242,6 +242,33 @@ export const schemaMock = { createdAt: 'createdAt', updatedAt: 'updatedAt', }, + workspaceSandbox: { + id: 'id', + workspaceId: 'workspaceId', + name: 'name', + language: 'language', + dependencies: 'dependencies', + specHash: 'specHash', + createdBy: 'createdBy', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, + sandboxImage: { + id: 'id', + provider: 'provider', + specHash: 'specHash', + spec: 'spec', + status: 'status', + imageRef: 'imageRef', + providerImageId: 'providerImageId', + buildId: 'buildId', + errorCode: 'errorCode', + errorMessage: 'errorMessage', + errorDetail: 'errorDetail', + lastUsedAt: 'lastUsedAt', + createdAt: 'createdAt', + updatedAt: 'updatedAt', + }, workspaceBYOKKeys: { id: 'id', workspaceId: 'workspaceId', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 8df1ad2a51..6c84d43eca 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 997, - zodRoutes: 997, + totalRoutes: 1000, + zodRoutes: 1000, nonZodRoutes: 0, } as const @@ -68,6 +68,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/cron/cleanup-tasks/route.ts', 'apps/sim/app/api/cron/cleanup-soft-deletes/route.ts', 'apps/sim/app/api/cron/cleanup-stale-executions/route.ts', + 'apps/sim/app/api/cron/cleanup-sandbox-images/route.ts', 'apps/sim/app/api/cron/renew-subscriptions/route.ts', 'apps/sim/app/api/cron/reconcile-billing-seats/route.ts', 'apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts', diff --git a/scripts/setup/checks.ts b/scripts/setup/checks.ts index d304234926..1d1a196b59 100644 --- a/scripts/setup/checks.ts +++ b/scripts/setup/checks.ts @@ -385,6 +385,26 @@ function checkCoherence(ctx: CheckContext): Finding[] { }) } + // NEXT_PUBLIC_SANDBOX_ENABLED is not a 1:1 twin: remote execution is available + // under E2B_ENABLED or, when SANDBOX_PROVIDER=daytona, DAYTONA_API_KEY. Without + // it the Function block hides its language dropdown and sandbox selector even + // though the server would happily run Python. + const sandboxProvider = (sim.vars.get('SANDBOX_PROVIDER') || 'e2b').toLowerCase() + const remoteSandboxAvailable = + sandboxProvider === 'daytona' + ? Boolean(sim.vars.get('DAYTONA_API_KEY')) + : isTruthy(sim.vars.get('E2B_ENABLED')) + if (remoteSandboxAvailable && !isTruthy(sim.vars.get('NEXT_PUBLIC_SANDBOX_ENABLED'))) { + findings.push({ + group: 'coherence', + status: 'fail', + message: + 'remote sandboxes are configured but NEXT_PUBLIC_SANDBOX_ENABLED is unset — the Function block will hide its language and sandbox controls', + fix: 'doctor --fix sets NEXT_PUBLIC_SANDBOX_ENABLED=true', + autofix: () => writeEnvValues(sim.target, { NEXT_PUBLIC_SANDBOX_ENABLED: 'true' }), + }) + } + const disableAuth = sim.vars.get('DISABLE_AUTH') if ( isTruthy(disableAuth) &&