diff --git a/backend/.sqlx/query-27a131537cee699ae53088cfd710b70eb81b4e6b8f79d57db9d1d12d7f3ffde2.json b/backend/.sqlx/query-27a131537cee699ae53088cfd710b70eb81b4e6b8f79d57db9d1d12d7f3ffde2.json new file mode 100644 index 0000000000..508eb32ed6 --- /dev/null +++ b/backend/.sqlx/query-27a131537cee699ae53088cfd710b70eb81b4e6b8f79d57db9d1d12d7f3ffde2.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, chain.depth + 1\n FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT COALESCE(MAX(depth), 0)::bigint AS \"depth!\" FROM chain\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "depth!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "27a131537cee699ae53088cfd710b70eb81b4e6b8f79d57db9d1d12d7f3ffde2" +} diff --git a/backend/.sqlx/query-2eb746c4cc5c65e277d4c3a12e0bc9b6a776a4941e68d8e2b31904c677493e2d.json b/backend/.sqlx/query-2eb746c4cc5c65e277d4c3a12e0bc9b6a776a4941e68d8e2b31904c677493e2d.json new file mode 100644 index 0000000000..44d9f8f36d --- /dev/null +++ b/backend/.sqlx/query-2eb746c4cc5c65e277d4c3a12e0bc9b6a776a4941e68d8e2b31904c677493e2d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH RECURSIVE tree AS (\n SELECT id, deleted, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.deleted, tree.depth + 1 FROM workspace w\n JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT COALESCE(MAX(depth) FILTER (WHERE NOT deleted), 0)::bigint AS \"height!\" FROM tree\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2eb746c4cc5c65e277d4c3a12e0bc9b6a776a4941e68d8e2b31904c677493e2d" +} diff --git a/backend/.sqlx/query-453c8dc05f4946a30fbbc517b5beaaee42e954e0b4ed0bd5ba6a5b308894d6ba.json b/backend/.sqlx/query-453c8dc05f4946a30fbbc517b5beaaee42e954e0b4ed0bd5ba6a5b308894d6ba.json new file mode 100644 index 0000000000..cb6bb20397 --- /dev/null +++ b/backend/.sqlx/query-453c8dc05f4946a30fbbc517b5beaaee42e954e0b4ed0bd5ba6a5b308894d6ba.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH RECURSIVE tree AS (\n SELECT id, deleted, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.deleted, tree.depth + 1 FROM workspace w\n JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT COUNT(DISTINCT id) AS \"count!\" FROM tree WHERE id != $1 AND NOT deleted\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "453c8dc05f4946a30fbbc517b5beaaee42e954e0b4ed0bd5ba6a5b308894d6ba" +} diff --git a/backend/.sqlx/query-71ee328f016bac75a9050dc3c13dc898c0b40fe6f1c4e5c631b6f784f7e73a3d.json b/backend/.sqlx/query-71ee328f016bac75a9050dc3c13dc898c0b40fe6f1c4e5c631b6f784f7e73a3d.json new file mode 100644 index 0000000000..acfa08d0bf --- /dev/null +++ b/backend/.sqlx/query-71ee328f016bac75a9050dc3c13dc898c0b40fe6f1c4e5c631b6f784f7e73a3d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH RECURSIVE tree AS (\n SELECT id, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, tree.depth + 1 FROM workspace w\n JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT id AS \"id!\" FROM tree WHERE id != $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "71ee328f016bac75a9050dc3c13dc898c0b40fe6f1c4e5c631b6f784f7e73a3d" +} diff --git a/backend/.sqlx/query-bcb36b5d38a07dbdfed71983aec1af05c5a6882f988ba5e0ec1f63828353987b.json b/backend/.sqlx/query-bcb36b5d38a07dbdfed71983aec1af05c5a6882f988ba5e0ec1f63828353987b.json new file mode 100644 index 0000000000..514024eb8d --- /dev/null +++ b/backend/.sqlx/query-bcb36b5d38a07dbdfed71983aec1af05c5a6882f988ba5e0ec1f63828353987b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, chain.depth + 1\n FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT id AS \"id!\" FROM chain WHERE parent_workspace_id IS NULL LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "bcb36b5d38a07dbdfed71983aec1af05c5a6882f988ba5e0ec1f63828353987b" +} diff --git a/backend/.sqlx/query-ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b.json b/backend/.sqlx/query-ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b.json new file mode 100644 index 0000000000..1930427b94 --- /dev/null +++ b/backend/.sqlx/query-ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS \"developers!\",\n COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS \"operators!\"\n FROM usr WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "developers!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "operators!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b" +} diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 086e251962..ff5d9b2f22 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -11,3 +11,13 @@ cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh ``` Re-run after clean builds or when `target/debug/libwindmill_duckdb_ffi_internal.*` is missing. + +## Cloud vs self-hosted gating + +The `cloud` cargo feature is compiled into **all** EE builds, so `#[cfg(feature = "cloud")]` is **not** a "cloud-only" runtime gate — it only means the code is present. The real gate for behavior specific to the managed cloud (app.windmill.dev) is the runtime flag `*CLOUD_HOSTED` (`windmill_common::worker::CLOUD_HOSTED`, from the `CLOUD_HOSTED` env var; note it's loaded from `.env` via `dotenv`, so it won't show in `/proc//environ` — check the running behavior, not the exec env). + +Cloud-only logic must be behind `if *CLOUD_HOSTED { ... }`: feature-gate the helper so it compiles, then **runtime-gate the call**. `#[cfg(feature = "cloud")]` on its own is only sufficient for: +- pure helper/struct definitions (they only run when a gated caller invokes them), +- code already inside an `if *CLOUD_HOSTED { ... }` block, +- handlers that early-return on `!*CLOUD_HOSTED`, +- idempotent no-ops that are harmless off-cloud (e.g. cache invalidation). diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index bb77a50973..171ec94560 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -629,11 +629,13 @@ async fn list_pending_invites( } async fn is_premium( - authed: ApiAuthed, + _authed: ApiAuthed, Extension(_db): Extension, Path(_w_id): Path, ) -> JsonResult { - require_admin(authed.is_admin, &authed.username)?; + // Any workspace member (not just admins) may read whether the workspace is on a paid plan: it's a + // single boolean, and the frontend needs it to decide whether to surface premium-gated affordances + // (e.g. forking) to non-admin developers too. The `_authed` extractor still enforces membership. #[cfg(feature = "cloud")] let premium = windmill_common::workspaces::get_team_plan_status(&_db, &_w_id) .await? @@ -3797,6 +3799,24 @@ lazy_static::lazy_static! { } }; + // Cloud only: how many forks a premium workspace may have per paid (developer) seat. + pub static ref MAX_FORKS_PER_SEAT: i64 = std::env::var("MAX_FORKS_PER_SEAT") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v >= 0) + .unwrap_or(5); + + // How deep a fork chain may nest (root = depth 0, a direct fork = depth 1). A general guardrail + // for all builds, independent of the cloud per-seat cap: deep fork chains are a footgun and no + // real use case needs them. Clamped to [1, 20] so it's always a real limit and can never exceed + // the fork-walk recursion backstop (20) that billing/count resolution uses (a chain deeper than + // the backstop would truncate and mis-resolve its root). + pub static ref MAX_FORK_DEPTH: i64 = std::env::var("MAX_FORK_DEPTH") + .ok() + .and_then(|v| v.parse::().ok()) + .map(|v| v.clamp(1, 20)) + .unwrap_or(5); + } async fn create_workspace_require_superadmin() -> String { @@ -4996,10 +5016,12 @@ async fn create_workspace_fork_branch( Path(w_id): Path, Json(nw): Json, ) -> JsonResult> { + // Pre-check the fork guards before creating any git branch, so we don't leave orphaned branches + // behind when the follow-up create_workspace_fork would be rejected anyway. + enforce_fork_depth(&db, &w_id, 0).await?; + #[cfg(feature = "cloud")] if *CLOUD_HOSTED { - return Err(Error::BadRequest(format!( - "Forking workspaces is not available on app.windmill.dev" - ))); + enforce_cloud_fork_cap(&db, &w_id).await?; } if *DISABLE_WORKSPACE_FORK { @@ -5171,16 +5193,83 @@ async fn apply_forked_datatable( Ok(()) } +/// Cloud: require the fork/dev's root (billing) workspace be premium; returns the resolved root id. +#[cfg(feature = "cloud")] +async fn require_cloud_fork_premium(db: &DB, parent_workspace_id: &str) -> Result { + let root = + windmill_common::workspaces::get_billing_workspace_id(db, parent_workspace_id).await?; + if !windmill_common::workspaces::get_team_plan_status(db, &root) + .await? + .premium + { + return Err(Error::BadRequest( + "Creating a fork or dev workspace on the cloud requires a paid team plan. Upgrade the workspace first.".to_string(), + )); + } + Ok(root) +} + +/// Cloud: reject if adding `incoming` fork/dev workspaces would push `root`'s family over its per-seat +/// allotment. `incoming` is the number of workspaces the operation adds to the family — 1 for a plain +/// create, but `1 + candidate_subtree` for an attach whose candidate already has child forks. +#[cfg(feature = "cloud")] +async fn enforce_cloud_fork_count(db: &DB, root: &str, incoming: i64) -> Result<()> { + let seats = windmill_common::workspaces::count_paid_seats(db, root).await?; + let per_seat = *MAX_FORKS_PER_SEAT; + // Any premium workspace has at least one paid seat, so floor the seat count at 1. + let allowed = seats.max(1) * per_seat; + + let existing = windmill_common::workspaces::count_workspace_forks(db, root).await?; + let projected = existing + incoming; + if projected > allowed { + return Err(Error::BadRequest(format!( + "Fork limit reached: this would bring the workspace family to {projected} fork(s), over the cap of {allowed} ({seats} paid seat(s) × {per_seat} per seat). Delete a fork or add seats." + ))); + } + + Ok(()) +} + +/// Cloud-only guard for creating a fork/dev workspace. Forks piggyback on the parent's plan (a fork +/// inherits the root's premium and meters its usage into the root's bill), so forking is limited to +/// premium workspaces and capped at `MAX_FORKS_PER_SEAT` per paid (developer) seat of the root. +#[cfg(feature = "cloud")] +async fn enforce_cloud_fork_cap(db: &DB, parent_workspace_id: &str) -> Result<()> { + let root = require_cloud_fork_premium(db, parent_workspace_id).await?; + enforce_cloud_fork_count(db, &root, 1).await +} + +/// General guardrail (all builds): reject creating a fork/dev under `parent` when it would nest deeper +/// than `MAX_FORK_DEPTH`. `added_subtree_height` is the height of the subtree grafted below the new +/// node — 0 for a plain fork, or the candidate's own subtree height for an attach. +async fn enforce_fork_depth( + db: &DB, + parent_workspace_id: &str, + added_subtree_height: i64, +) -> Result<()> { + let parent_depth = + windmill_common::workspaces::fork_chain_depth(db, parent_workspace_id).await?; + // The new node sits one level below the parent; its deepest descendant adds the grafted height. + let resulting_depth = parent_depth + 1 + added_subtree_height; + if resulting_depth > *MAX_FORK_DEPTH { + return Err(Error::BadRequest(format!( + "Fork depth limit reached: forks can be nested at most {} level(s) deep, but this would create a fork at depth {}. Fork from a workspace closer to the root instead.", + *MAX_FORK_DEPTH, resulting_depth + ))); + } + Ok(()) +} + async fn create_workspace_fork( authed: ApiAuthed, Extension(db): Extension, Path(parent_workspace_id): Path, Json(nw): Json, ) -> Result { + enforce_fork_depth(&db, &parent_workspace_id, 0).await?; + #[cfg(feature = "cloud")] if *CLOUD_HOSTED { - return Err(Error::BadRequest(format!( - "Forking workspaces is not available on app.windmill.dev" - ))); + enforce_cloud_fork_cap(&db, &parent_workspace_id).await?; } if nw.is_dev_workspace { @@ -5347,10 +5436,30 @@ async fn attach_dev_workspace( ) -> Result { require_admin(authed.is_admin, &authed.username)?; + // Attaching grafts the candidate (and its own fork subtree) under prod, so enforce the general + // depth limit on the deepest resulting node. + let candidate_height = + windmill_common::workspaces::fork_subtree_height(&db, &req.dev_workspace_id).await?; + enforce_fork_depth(&db, &prod_w_id, candidate_height).await?; + + // Attaching reparents a workspace under prod (one dev per prod, admin-gated) and it then draws + // prod's plan, so hold it to the same premium requirement as creating a fork. Only enforce the + // per-seat count when the attach actually adds a new workspace to the family: re-designating a + // workspace already under this root as its dev doesn't increase the descendant count. + #[cfg(feature = "cloud")] if *CLOUD_HOSTED { - return Err(Error::BadRequest( - "Dev workspaces are not available on app.windmill.dev".to_string(), - )); + let root = require_cloud_fork_premium(&db, &prod_w_id).await?; + // Only count against the cap when this attach adds workspaces to the family (candidate not + // already under this root). The candidate may itself have child forks, so reserve slots for its + // whole incoming subtree (the candidate + its descendants), not just one. + if windmill_common::workspaces::get_billing_workspace_id(&db, &req.dev_workspace_id).await? + != root + { + let incoming = + 1 + windmill_common::workspaces::count_workspace_forks(&db, &req.dev_workspace_id) + .await?; + enforce_cloud_fork_count(&db, &root, incoming).await?; + } } let dev_w_id = req.dev_workspace_id; @@ -5468,6 +5577,17 @@ async fn attach_dev_workspace( // The dev workspace's parent just changed (none -> prod); drop its cached fork->parent mapping // so per-workspace job tags route to the prod family immediately rather than after the TTL. windmill_queue::tags::invalidate_fork_parent_cache(&dev_w_id); + // Same reparent invalidates the billing-workspace mapping so its usage meters to prod at once. The + // candidate can bring its own fork subtree, whose descendants had resolved their (now-stale) root + // to the candidate's old family; invalidate them too so they meter to prod without waiting out the + // 60s TTL. Their immediate fork->parent links don't move, so the tag-routing cache needs no change. + #[cfg(feature = "cloud")] + { + windmill_common::workspaces::invalidate_billing_workspace_cache(&dev_w_id); + for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? { + windmill_common::workspaces::invalidate_billing_workspace_cache(&id); + } + } if req.lock_prod_deploy || req.lock_prod_forking { windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id); @@ -6415,6 +6535,17 @@ async fn change_workspace_color( } async fn get_usage(Extension(db): Extension, Path(w_id): Path) -> Result { + // On cloud, a fork's executions meter against its billing root, so report the root's usage here too; + // otherwise the free-execs indicator would show the fork's own (often 0) count while enforcement + // applies the root's shared quota. Gated on `*CLOUD_HOSTED` (not just the `cloud` feature, which is + // compiled into all EE builds): self-hosted doesn't meter usage this way. Off-fork it resolves to + // `w_id` itself anyway. + #[cfg(feature = "cloud")] + let w_id = if *CLOUD_HOSTED { + windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await? + } else { + w_id + }; let usage = sqlx::query_scalar!( " SELECT usage.usage FROM usage @@ -8230,6 +8361,10 @@ struct CloudQuotas { apps: QuotaInfo, variables: QuotaInfo, resources: QuotaInfo, + /// Fork/dev workspaces under this workspace's billing root vs the per-seat cap. `limit` is 0 for a + /// non-premium root (forking is premium-only). Family-wide: resolves to the billing root, so it + /// reads the same whether viewed from the root or one of its forks. + forks: QuotaInfo, } async fn get_cloud_quotas( @@ -8310,12 +8445,32 @@ async fn get_cloud_quotas( .await? .unwrap_or(0); + // Fork/dev workspaces vs the per-seat cap, resolved to the billing root. Non-premium roots can't + // fork, so their allowance is 0. + #[cfg(feature = "cloud")] + let forks = { + use windmill_common::workspaces::{ + count_paid_seats, count_workspace_forks, get_billing_workspace_id, get_team_plan_status, + }; + let root = get_billing_workspace_id(&db, &w_id).await?; + let used = count_workspace_forks(&db, &root).await?; + let limit = if get_team_plan_status(&db, &root).await?.premium { + count_paid_seats(&db, &root).await?.max(1) * *MAX_FORKS_PER_SEAT + } else { + 0 + }; + QuotaInfo { used, limit, prunable: 0 } + }; + #[cfg(not(feature = "cloud"))] + let forks = QuotaInfo { used: 0, limit: 0, prunable: 0 }; + Ok(Json(CloudQuotas { scripts: QuotaInfo { used: scripts_used, limit: 5000, prunable: scripts_prunable }, flows: QuotaInfo { used: flows_used, limit: 1000, prunable: flows_prunable }, apps: QuotaInfo { used: apps_used, limit: 1000, prunable: apps_prunable }, variables: QuotaInfo { used: variables_used, limit: 10000, prunable: 0 }, resources: QuotaInfo { used: resources_used, limit: 10000, prunable: 0 }, + forks, })) } diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 47d8c24b92..56abe689d5 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -726,10 +726,13 @@ pub(crate) async fn change_workspace_id( tx.commit().await?; // The children's parent_workspace_id changed (old root -> new root); invalidate their fork-parent - // routing cache so jobs route under the renamed root rather than the old (archived) one until the - // 300s TTL would otherwise expire. + // routing cache and their billing-workspace mapping so jobs route + meter under the renamed root + // rather than the old (archived) one, instead of waiting for the caches' TTLs. Deeper descendants + // (fork-of-fork) self-heal via the 60s billing-cache TTL. for child in &reparented_children { windmill_queue::tags::invalidate_fork_parent_cache(child); + #[cfg(feature = "cloud")] + windmill_common::workspaces::invalidate_billing_workspace_cache(child); } // Archive old workspace: disable schedules, cancel remaining jobs, set deleted=true @@ -1017,6 +1020,16 @@ pub(crate) async fn delete_workspace( .await? .flatten(); + // Capture direct child forks before the delete: the FK is ON DELETE SET NULL, so they're about to + // be orphaned (their billing root changes from this workspace's root to themselves). We drop their + // cached mappings after commit alongside the deleted id itself. + let orphaned_children: Vec = sqlx::query_scalar!( + "SELECT id FROM workspace WHERE parent_workspace_id = $1", + &w_id + ) + .fetch_all(&mut *tx) + .await?; + sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id) .execute(&mut *tx) .await?; @@ -1050,6 +1063,22 @@ pub(crate) async fn delete_workspace( windmill_common::workspaces::invalidate_protection_rules_cache(&parent); } + // Workspace ids are reusable after permanent deletion, so drop every cached mapping keyed by the + // deleted id (and any just-orphaned children) — otherwise a recreated id could inherit the gone + // workspace's state within the caches' lifetimes. This covers fork->parent (tag routing) and + // fork->root (billing), plus the premium/team-plan status: TEAM_PLAN_CACHE has no TTL and is only + // evicted by the premium-change NOTIFY, so without this a reused id would keep the old workspace's + // premium indefinitely (free forks/usage). Deeper (grandchild) descendants self-heal via the 60s + // billing TTL. + for id in std::iter::once(&w_id).chain(orphaned_children.iter()) { + windmill_queue::tags::invalidate_fork_parent_cache(id); + #[cfg(feature = "cloud")] + { + windmill_common::workspaces::invalidate_billing_workspace_cache(id); + windmill_common::workspaces::invalidate_team_plan_cache(id); + } + } + Ok(format!("Deleted workspace {}", &w_id)) } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3b90927901..eec76ea009 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -6221,12 +6221,15 @@ paths: $ref: "#/components/schemas/QuotaInfo" resources: $ref: "#/components/schemas/QuotaInfo" + forks: + $ref: "#/components/schemas/QuotaInfo" required: - scripts - flows - apps - variables - resources + - forks /w/{workspace}/workspaces/prune_versions: post: diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index cd3693cd92..5593a565ef 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -347,9 +347,215 @@ lazy_static::lazy_static! { pub static ref PUBLIC_APP_RATE_LIMIT_CACHE: Cache, i64)> = Cache::new(1000); } +#[cfg(feature = "cloud")] +lazy_static::lazy_static! { + // Maps a workspace id to its root (billing) ancestor. Value: (root_id, expiry_timestamp). + // Reparenting (attach/detach dev) is rare and self-heals via the 60s TTL, so a brief stale + // mapping only mis-attributes usage for <60s across other instances. + pub static ref BILLING_WORKSPACE_CACHE: Cache = Cache::new(5000); +} + +/// Resolve the "billing" workspace for `w_id`: the root ancestor of the fork/dev chain (the +/// workspace whose plan and usage a fork draws from). Returns `w_id` unchanged for a standalone +/// workspace, an unknown id, or a (malformed) cyclic chain. +/// +/// Unauthenticated metering helper: it only reads the parent chain and returns another workspace id, +/// so callers must already be authorized for `w_id` (or run in trusted server-side code); `w_id` is +/// expected to be a server-side id, not raw user input. +#[cfg(feature = "cloud")] +pub async fn get_billing_workspace_id(db: &crate::DB, w_id: &str) -> Result { + let now = chrono::Utc::now().timestamp(); + if let Some((root, expiry)) = BILLING_WORKSPACE_CACHE.get(w_id) { + if expiry > now { + return Ok(root); + } + } + + // The depth bound is a cycle-safety backstop kept well above the enforced `MAX_FORK_DEPTH`, so a + // truncated (root-not-found) result — which would fall back to `w_id` and mis-attribute billing — + // is unreachable for any real hierarchy; only a malformed cycle could hit it. + let root = sqlx::query_scalar!( + r#" + WITH RECURSIVE chain AS ( + SELECT id, parent_workspace_id, 0 AS depth + FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.parent_workspace_id, chain.depth + 1 + FROM workspace w + JOIN chain ON w.id = chain.parent_workspace_id + WHERE chain.depth < 20 + ) + SELECT id AS "id!" FROM chain WHERE parent_workspace_id IS NULL LIMIT 1 + "#, + w_id + ) + .fetch_optional(db) + .await + .map_err(|e| Error::internal_err(format!("resolving billing workspace for {w_id}: {e:#}")))? + .unwrap_or_else(|| w_id.to_string()); + + BILLING_WORKSPACE_CACHE.insert(w_id.to_string(), (root.clone(), now + 60)); + Ok(root) +} + +/// Invalidate the billing-workspace mapping for a workspace (call after reparenting it). +#[cfg(feature = "cloud")] +pub fn invalidate_billing_workspace_cache(w_id: &str) { + BILLING_WORKSPACE_CACHE.remove(w_id); +} + +/// Invalidate the cached team-plan (premium/past-due) status for a workspace. `TEAM_PLAN_CACHE` has +/// no TTL — it's only evicted by the premium-change NOTIFY — so call this when a workspace id is +/// permanently deleted, otherwise a reused id could inherit the old workspace's premium status. +#[cfg(feature = "cloud")] +pub fn invalidate_team_plan_cache(w_id: &str) { + TEAM_PLAN_CACHE.remove(w_id); +} + +/// Depth of `w_id` in its fork chain: 0 for a root (no parent), 1 for a direct fork, and so on. Walks +/// the parent chain up to the root. The recursion bound is a cycle-safety backstop set well above the +/// enforced `MAX_FORK_DEPTH`; a (malformed) cyclic chain saturates it and so reads as "too deep", +/// which safely rejects rather than allows. +/// +/// Unauthenticated helper: reads workspace hierarchy for any `w_id`, so callers must already be +/// authorized for that workspace (or run in trusted server-side code). +pub async fn fork_chain_depth(db: &crate::DB, w_id: &str) -> Result { + let depth = sqlx::query_scalar!( + r#" + WITH RECURSIVE chain AS ( + SELECT id, parent_workspace_id, 0 AS depth + FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.parent_workspace_id, chain.depth + 1 + FROM workspace w + JOIN chain ON w.id = chain.parent_workspace_id + WHERE chain.depth < 20 + ) + SELECT COALESCE(MAX(depth), 0)::bigint AS "depth!" FROM chain + "#, + w_id + ) + .fetch_one(db) + .await + .map_err(|e| Error::internal_err(format!("computing fork depth for {w_id}: {e:#}")))?; + Ok(depth) +} + +/// Height of the fork subtree rooted at `w_id`: 0 when it has no live child forks, 1 with direct +/// children, and so on. Used so that attaching a candidate which already has its own child forks can't +/// push the family past the depth limit. +/// +/// Unauthenticated helper: reads workspace hierarchy for any `w_id`, so callers must already be +/// authorized for that workspace (or run in trusted server-side code). +pub async fn fork_subtree_height(db: &crate::DB, w_id: &str) -> Result { + // The `deleted` filter is applied in the outer aggregation (not the recursive step, matching + // count_workspace_forks) so a live descendant under a soft-deleted intermediate is still measured + // at its true depth rather than pruned — otherwise the height could be underestimated and let the + // resulting chain exceed the depth limit. + let height = sqlx::query_scalar!( + r#" + WITH RECURSIVE tree AS ( + SELECT id, deleted, 0 AS depth FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.deleted, tree.depth + 1 FROM workspace w + JOIN tree ON w.parent_workspace_id = tree.id + WHERE tree.depth < 20 + ) + SELECT COALESCE(MAX(depth) FILTER (WHERE NOT deleted), 0)::bigint AS "height!" FROM tree + "#, + w_id + ) + .fetch_one(db) + .await + .map_err(|e| Error::internal_err(format!("computing fork subtree height for {w_id}: {e:#}")))?; + Ok(height) +} + +/// Ids of every fork/dev workspace anywhere under `w_id` (excludes `w_id` itself), including live +/// descendants beneath a soft-deleted intermediate. Used to invalidate per-workspace caches for a +/// whole subtree after its ancestor is reparented. +/// +/// Unauthenticated helper: reads workspace hierarchy for any `w_id`, so callers must already be +/// authorized for that workspace (or run in trusted server-side code). +pub async fn list_fork_descendants(db: &crate::DB, w_id: &str) -> Result> { + let ids = sqlx::query_scalar!( + r#" + WITH RECURSIVE tree AS ( + SELECT id, 0 AS depth FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, tree.depth + 1 FROM workspace w + JOIN tree ON w.parent_workspace_id = tree.id + WHERE tree.depth < 20 + ) + SELECT id AS "id!" FROM tree WHERE id != $1 + "#, + w_id + ) + .fetch_all(db) + .await + .map_err(|e| Error::internal_err(format!("listing fork descendants of {w_id}: {e:#}")))?; + Ok(ids) +} + +/// Count non-deleted fork/dev workspaces anywhere under `root` (excludes `root` itself). +/// +/// Unauthenticated metering helper: it reads workspace hierarchy for any `root` id, so callers must +/// already be authorized for that workspace (or run in trusted server-side code). `root` is expected +/// to be a server-resolved id, never raw user input. +#[cfg(feature = "cloud")] +pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result { + // The `deleted` filter is on the outer SELECT (not the recursive step) so that a live sub-fork + // whose intermediate parent was soft-deleted is still counted rather than pruned with it. The + // depth bound is a cycle-safety backstop kept well above the enforced `MAX_FORK_DEPTH`, so + // descendants are never silently dropped from the cap count. + let count = sqlx::query_scalar!( + r#" + WITH RECURSIVE tree AS ( + SELECT id, deleted, 0 AS depth FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.deleted, tree.depth + 1 FROM workspace w + JOIN tree ON w.parent_workspace_id = tree.id + WHERE tree.depth < 20 + ) + SELECT COUNT(DISTINCT id) AS "count!" FROM tree WHERE id != $1 AND NOT deleted + "#, + root + ) + .fetch_one(db) + .await + .map_err(|e| Error::internal_err(format!("counting forks of {root}: {e:#}")))?; + Ok(count) +} + +/// Approximate paid seats of a workspace as `ceil(developers + operators/2)`, excluding disabled and +/// service-account members. Reuses billing's author/operator weighting, but counts provisioned +/// members rather than the active-user population billing meters, so it only ever loosens the fork +/// cap (never blocks a paid seat) — good enough for a soft guardrail. +/// +/// Unauthenticated metering helper: reads member counts for any `w_id`, so callers must already be +/// authorized for that workspace (or run in trusted server-side code). +#[cfg(feature = "cloud")] +pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result { + let row = sqlx::query!( + r#"SELECT + COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS "developers!", + COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS "operators!" + FROM usr WHERE workspace_id = $1"#, + w_id + ) + .fetch_one(db) + .await + .map_err(|e| Error::internal_err(format!("counting paid seats of {w_id}: {e:#}")))?; + Ok(((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64) +} + #[cfg(feature = "cloud")] pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result { - let cached = TEAM_PLAN_CACHE.get(_w_id); + // A fork/dev workspace draws its plan from the root (billing) workspace. Resolve to the root and + // key the cache by it: the premium-change NOTIFY is keyed by the workspace whose premium row + // changed (the root), so keying by root keeps invalidation correct and lets forks share it. + let billing_w_id = get_billing_workspace_id(_db, _w_id).await?; + let cached = TEAM_PLAN_CACHE.get(&billing_w_id); if let Some(cached) = cached { return Ok(cached); } @@ -368,7 +574,7 @@ pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result Result Result, id: &str, parent: Option<&str>, deleted: bool) { + sqlx::query( + "INSERT INTO workspace (id, name, owner, parent_workspace_id, deleted) + VALUES ($1, $1, 'test-user', $2, $3)", + ) + .bind(id) + .bind(parent) + .bind(deleted) + .execute(db) + .await + .expect("insert workspace"); + // The resolver caches per id (60s TTL) in a process-global cache shared across tests, so drop + // any stale mapping for this id before the test reads it. + invalidate_billing_workspace_cache(id); +} + +async fn insert_member( + db: &Pool, + w_id: &str, + email: &str, + operator: bool, + disabled: bool, + is_service_account: bool, +) { + // `usr.username` has a `proper_username` check (no `@`), so derive one from the email prefix. + let username = email.split('@').next().unwrap(); + sqlx::query( + "INSERT INTO usr (workspace_id, email, username, is_admin, operator, disabled, is_service_account, role) + VALUES ($1, $2, $3, false, $4, $5, $6, 'Developer')", + ) + .bind(w_id) + .bind(email) + .bind(username) + .bind(operator) + .bind(disabled) + .bind(is_service_account) + .execute(db) + .await + .expect("insert usr"); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn billing_workspace_resolves_to_root(db: Pool) { + insert_ws(&db, "bwt-root", None, false).await; + insert_ws(&db, "bwt-fork", Some("bwt-root"), false).await; + insert_ws(&db, "bwt-grandchild", Some("bwt-fork"), false).await; + + assert_eq!( + get_billing_workspace_id(&db, "bwt-root").await.unwrap(), + "bwt-root" + ); + assert_eq!( + get_billing_workspace_id(&db, "bwt-fork").await.unwrap(), + "bwt-root" + ); + assert_eq!( + get_billing_workspace_id(&db, "bwt-grandchild") + .await + .unwrap(), + "bwt-root" + ); + // Unknown / orphaned ids resolve to themselves. + assert_eq!( + get_billing_workspace_id(&db, "bwt-missing").await.unwrap(), + "bwt-missing" + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn billing_workspace_survives_cycles(db: Pool) { + insert_ws(&db, "bwc-a", None, false).await; + insert_ws(&db, "bwc-b", Some("bwc-a"), false).await; + // Introduce a cycle a -> b -> a; no row has a NULL parent, so resolution falls back to the input + // and the depth guard keeps it from looping forever. + sqlx::query("UPDATE workspace SET parent_workspace_id = 'bwc-b' WHERE id = 'bwc-a'") + .execute(&db) + .await + .unwrap(); + invalidate_billing_workspace_cache("bwc-a"); + invalidate_billing_workspace_cache("bwc-b"); + + assert_eq!( + get_billing_workspace_id(&db, "bwc-a").await.unwrap(), + "bwc-a" + ); + assert_eq!( + get_billing_workspace_id(&db, "bwc-b").await.unwrap(), + "bwc-b" + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn paid_seats_and_fork_count(db: Pool) { + insert_ws(&db, "seat-root", None, false).await; + // 2 developers + 2 operators counted -> ceil(2 + 0.5*2) = 3. + insert_member(&db, "seat-root", "dev1@w.dev", false, false, false).await; + insert_member(&db, "seat-root", "dev2@w.dev", false, false, false).await; + insert_member(&db, "seat-root", "op1@w.dev", true, false, false).await; + insert_member(&db, "seat-root", "op2@w.dev", true, false, false).await; + // These must NOT count towards seats. + insert_member(&db, "seat-root", "disabled@w.dev", false, true, false).await; + insert_member(&db, "seat-root", "svc@w.dev", false, false, true).await; + + assert_eq!(count_paid_seats(&db, "seat-root").await.unwrap(), 3); + + insert_ws(&db, "seat-fork1", Some("seat-root"), false).await; + insert_ws(&db, "seat-fork2", Some("seat-root"), false).await; + // A deleted fork itself is not counted... + insert_ws(&db, "seat-fork-deleted", Some("seat-root"), true).await; + // ...but a live sub-fork under it still is (deleted filter is on the outer SELECT, not the walk). + insert_ws(&db, "seat-deleted-child", Some("seat-fork-deleted"), false).await; + // A grandchild fork still counts. + insert_ws(&db, "seat-fork1-child", Some("seat-fork1"), false).await; + + // Live: fork1, fork2, fork1-child, deleted-child -> 4 (seat-fork-deleted excluded). + assert_eq!(count_workspace_forks(&db, "seat-root").await.unwrap(), 4); + // A standalone workspace has no forks. + assert_eq!(count_workspace_forks(&db, "seat-fork2").await.unwrap(), 0); + + // list_fork_descendants returns every descendant id (deleted included, for cache invalidation): + // fork1, fork2, fork-deleted, deleted-child, fork1-child -> 5. + let mut descendants = list_fork_descendants(&db, "seat-root").await.unwrap(); + descendants.sort(); + assert_eq!( + descendants, + vec![ + "seat-deleted-child", + "seat-fork-deleted", + "seat-fork1", + "seat-fork1-child", + "seat-fork2", + ] + ); + assert!(list_fork_descendants(&db, "seat-fork2") + .await + .unwrap() + .is_empty()); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn billing_cache_invalidation_reflects_reparent(db: Pool) { + insert_ws(&db, "inv-root-a", None, false).await; + insert_ws(&db, "inv-root-b", None, false).await; + insert_ws(&db, "inv-fork", Some("inv-root-a"), false).await; + + // Resolve + cache: fork -> root-a. + assert_eq!( + get_billing_workspace_id(&db, "inv-fork").await.unwrap(), + "inv-root-a" + ); + + // Reparent in the DB, as delete+recreate-under-another-root (or attach) would. + sqlx::query("UPDATE workspace SET parent_workspace_id = 'inv-root-b' WHERE id = 'inv-fork'") + .execute(&db) + .await + .unwrap(); + + // The cached mapping survives until invalidated (this is the staleness the delete/attach/rename + // paths must clear). + assert_eq!( + get_billing_workspace_id(&db, "inv-fork").await.unwrap(), + "inv-root-a" + ); + + // After invalidation (what delete_workspace / attach_dev_workspace / change_workspace_id now call), + // it re-resolves to the new root. + invalidate_billing_workspace_cache("inv-fork"); + assert_eq!( + get_billing_workspace_id(&db, "inv-fork").await.unwrap(), + "inv-root-b" + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn fork_depth_and_subtree_height(db: Pool) { + // Chain: root -> f1 -> f2 -> f3 + insert_ws(&db, "fd-root", None, false).await; + insert_ws(&db, "fd-f1", Some("fd-root"), false).await; + insert_ws(&db, "fd-f2", Some("fd-f1"), false).await; + insert_ws(&db, "fd-f3", Some("fd-f2"), false).await; + + // Depth walks up to the root: root = 0, each fork adds one. + assert_eq!(fork_chain_depth(&db, "fd-root").await.unwrap(), 0); + assert_eq!(fork_chain_depth(&db, "fd-f1").await.unwrap(), 1); + assert_eq!(fork_chain_depth(&db, "fd-f3").await.unwrap(), 3); + // An unknown id has no chain, so depth 0 (treated as a root by the guard). + assert_eq!(fork_chain_depth(&db, "fd-missing").await.unwrap(), 0); + + // Height walks down: the deepest live descendant below the node. + assert_eq!(fork_subtree_height(&db, "fd-root").await.unwrap(), 3); + assert_eq!(fork_subtree_height(&db, "fd-f2").await.unwrap(), 1); + assert_eq!(fork_subtree_height(&db, "fd-f3").await.unwrap(), 0); + + // A deleted leaf doesn't add to the height. + insert_ws(&db, "fd-f3-del", Some("fd-f3"), true).await; + assert_eq!(fork_subtree_height(&db, "fd-f3").await.unwrap(), 0); + + // ...but a LIVE descendant below a soft-deleted intermediate still counts at its true depth + // (the walk traverses through the deleted node; only the aggregation filters deleted). + insert_ws(&db, "fd-f3-live-gc", Some("fd-f3-del"), false).await; + assert_eq!(fork_subtree_height(&db, "fd-f3").await.unwrap(), 2); +} diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index c92cea9ef3..99f33da3bf 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -1914,22 +1914,31 @@ fn apply_completed_job_cloud_usage( tokio::task::spawn(async move { let additional_usage = _duration / 1000; let result = tokio::time::timeout(std::time::Duration::from_secs(10), async move { + // Fork/dev execution-seconds meter against the root (billing) workspace; resolves to + // `w_id` itself off-fork. + let billing_w_id = + windmill_common::workspaces::get_billing_workspace_id(&db, &w_id) + .await + .unwrap_or_else(|e| { + tracing::error!("Failed to resolve billing workspace for {w_id}: {e:#}"); + w_id.clone() + }); // Update workspace usage let workspace_result = sqlx::query!( "INSERT INTO usage (id, is_workspace, month_, usage) VALUES ($1, TRUE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), $2) ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + EXCLUDED.usage", - &w_id, + &billing_w_id, additional_usage as i32 ) .execute(&db) .await; if let Err(e) = workspace_result { - tracing::error!("Failed to update workspace usage for {}: {:#}", w_id, e); + tracing::error!("Failed to update workspace usage for {}: {:#}", billing_w_id, e); } - match windmill_common::workspaces::get_team_plan_status(&db, &w_id).await { + match windmill_common::workspaces::get_team_plan_status(&db, &billing_w_id).await { Ok(team_plan_status) => { // Update user usage for non-premium workspaces if !team_plan_status.premium { @@ -5124,8 +5133,13 @@ async fn push_inner<'c, 'd>( ) -> Result<(Uuid, Transaction<'c, Postgres>), Error> { #[cfg(feature = "cloud")] if *CLOUD_HOSTED { + // A fork/dev workspace draws its plan and usage from the root (billing) workspace, so its + // executions are metered against the parent's quota/bill. Resolves to `workspace_id` itself + // for a standalone workspace (no behavior change off-fork). + let billing_w_id = + windmill_common::workspaces::get_billing_workspace_id(db, workspace_id).await?; let team_plan_status = - windmill_common::workspaces::get_team_plan_status(db, workspace_id).await?; + windmill_common::workspaces::get_team_plan_status(db, &billing_w_id).await?; // we track only non flow steps let (workspace_usage, user_usage) = if !matches!( job_payload, @@ -5134,12 +5148,12 @@ async fn push_inner<'c, 'd>( // Check current usage with SELECT (fast, no row locks) // Only check user usage for non-premium workspaces let (current_workspace_usage, current_user_usage) = - check_usage_limits(db, workspace_id, email, !team_plan_status.premium).await?; + check_usage_limits(db, &billing_w_id, email, !team_plan_status.premium).await?; // Spawn async task to update usage counters in the background increment_usage_async( db.clone(), - workspace_id.to_string(), + billing_w_id.clone(), if !team_plan_status.premium { Some(email.to_string()) } else { @@ -5242,7 +5256,7 @@ async fn push_inner<'c, 'd>( WHERE is_workspace IS TRUE AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND id = $1", - workspace_id + billing_w_id ) .fetch_optional(db) .await? @@ -5269,6 +5283,13 @@ async fn push_inner<'c, 'd>( ))); } + // These two burst guards intentionally stay keyed to `workspace_id`, not the + // billing root: the shared caps are the monthly usage above (metered to the + // root) and the per-user in-queue/concurrent guards further up (keyed by email, + // global across the whole family). Keying these to the root would count the + // root's own queue rather than this workspace's load or the family's; a true + // family-shared burst cap would need a family-wide subquery, not worth the + // hot-path cost for this downgrade-only soft guard. let in_queue_workspace = sqlx::query_scalar!( "SELECT COUNT(id) FROM v2_job_queue WHERE workspace_id = $1", workspace_id diff --git a/frontend/src/lib/components/sessions/SessionForkBar.svelte b/frontend/src/lib/components/sessions/SessionForkBar.svelte index 982dc68c0f..a0aafee456 100644 --- a/frontend/src/lib/components/sessions/SessionForkBar.svelte +++ b/frontend/src/lib/components/sessions/SessionForkBar.svelte @@ -11,7 +11,7 @@ } from 'lucide-svelte' import { Button } from '$lib/components/common' import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte' - import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { isPremiumStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { goto } from '$lib/navigation' import { canCreateFork } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' @@ -52,9 +52,12 @@ const isFork = $derived(!!parentWorkspaceId) // Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar. - // When forking isn't available the diff/review surface is moot. + // When forking isn't available the diff/review surface is moot. On cloud, forking is a + // premium-only feature (backend caps it per paid seat). const forksAllowed = $derived( - !isCloudHosted() && canCreateFork($userStore) && $workspaceStore !== 'admins' + (!isCloudHosted() || $isPremiumStore) && + canCreateFork($userStore) && + $workspaceStore !== 'admins' ) let diffDrawer: ForkDiffDrawer | undefined = $state(undefined) diff --git a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte index 6987730b87..91a5922871 100644 --- a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte @@ -2,6 +2,7 @@ import { tick, type Snippet } from 'svelte' import { enterpriseLicense, + isPremiumStore, userStore, userWorkspaces, workspaceStore, @@ -114,12 +115,14 @@ rootRulesetsResource.loading || rootUserInfoResource.loading || !canDeployRoot ) - // Structural gate: hidden on cloud, in the admins workspace, or when the user - // can't fork. DisableWorkspaceForking on the active workspace (a locked prod) - // doesn't apply when there's a dev to fork from instead — the dev isn't - // locked, and devOfRoot only resolves when the user is a member of it. + // Structural gate: hidden in the admins workspace, or when the user can't fork; on cloud, forking + // is premium-only (backend caps it per paid seat). DisableWorkspaceForking on the active workspace + // (a locked prod) doesn't apply when there's a dev to fork from instead — the dev isn't locked, and + // devOfRoot only resolves when the user is a member of it. const forksGateOpen = $derived( - !isCloudHosted() && $workspaceStore !== 'admins' && (canCreateFork($userStore) || !!devOfRoot) + (!isCloudHosted() || $isPremiumStore) && + $workspaceStore !== 'admins' && + (canCreateFork($userStore) || !!devOfRoot) ) // A fork is a new workspace, so it's subject to the community-edition cap on // the number of non-'admins' workspaces (backend _check_nb_of_workspaces, diff --git a/frontend/src/lib/components/settings/CloudQuotas.svelte b/frontend/src/lib/components/settings/CloudQuotas.svelte index 61158f13ec..ec597101ee 100644 --- a/frontend/src/lib/components/settings/CloudQuotas.svelte +++ b/frontend/src/lib/components/settings/CloudQuotas.svelte @@ -18,6 +18,7 @@ apps: QuotaInfo variables: QuotaInfo resources: QuotaInfo + forks: QuotaInfo } | undefined = $state(undefined) @@ -87,7 +88,8 @@ { label: 'Flows', key: 'flows', prunable: true }, { label: 'Apps', key: 'apps', prunable: true }, { label: 'Variables', key: 'variables', prunable: false }, - { label: 'Resources', key: 'resources', prunable: false } + { label: 'Resources', key: 'resources', prunable: false }, + { label: 'Forks', key: 'forks', prunable: false } ] diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index 71e5636512..53ac410cc8 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -217,7 +217,7 @@ {/if} - {#if !strictWorkspaceSelect && !isCloudHosted() && canCreateFork($userStore) && $workspaceStore !== 'admins'} + {#if !strictWorkspaceSelect && (!isCloudHosted() || $isPremiumStore) && canCreateFork($userStore) && $workspaceStore !== 'admins'}
{ - let gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({ - workspace: $workspaceStore!, - requestBody: { - id: prefixed_id, - name, - color: colorEnabled && workspaceColor ? workspaceColor : undefined, - is_dev_workspace: createAsDevWorkspace, - // Send the lock intent in this first phase too so the backend can reject a non-admin's - // locked-dev request before any branch is created (avoids dangling branches). - lock_prod_deploy: createAsDevWorkspace && lockProdDeploy, - lock_prod_forking: createAsDevWorkspace && lockProdForking, - copy_members: copyMembers - } - }) + let gitSyncJobIds: string[] + try { + gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({ + workspace: $workspaceStore!, + requestBody: { + id: prefixed_id, + name, + color: colorEnabled && workspaceColor ? workspaceColor : undefined, + is_dev_workspace: createAsDevWorkspace, + // Send the lock intent in this first phase too so the backend can reject a non-admin's + // locked-dev request before any branch is created (avoids dangling branches). + lock_prod_deploy: createAsDevWorkspace && lockProdDeploy, + lock_prod_forking: createAsDevWorkspace && lockProdForking, + copy_members: copyMembers + } + }) + } catch (e) { + // The backend can reject here (fork cap, depth limit, premium, non-admin lock). Reset the + // loading state and surface the error rather than leaving the button spinning. + forkCreationError = `Failed to create fork '${prefixed_id}'` + errorMsgs.push(e?.body ?? e ?? 'Unknown error') + forkCreationLoading = false + sendUserToast(`Could not create fork '${prefixed_id}' ${e?.body ?? e}`, true) + return + } try { await Promise.all( diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index e91ad87583..030cf67fd0 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -147,8 +147,15 @@ } catch (e) { console.error('Could not persist username to local storage', e) } - if (isCloudHosted() && user?.is_admin) { - isPremiumStore.set(await WorkspaceService.getIsPremium({ workspace })) + // Populate for all members (not just admins) so non-admin developers also get premium-gated + // affordances like the fork entry points on cloud. The `is_premium` endpoint is a boolean + // and no longer admin-gated. Best-effort: a failure here must not block user-store init. + if (isCloudHosted()) { + try { + isPremiumStore.set(await WorkspaceService.getIsPremium({ workspace })) + } catch (e) { + console.error('Could not fetch premium status', e) + } } } else { userStore.set(undefined) diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 8fbec8f204..94628c8b17 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -1377,7 +1377,16 @@ /> {:else if tab == 'premium'} - + {#if currentWsForDevTab?.parent_workspace_id} + + This workspace is a fork of {currentWsForDevTab.parent_workspace_id}. It + runs on the parent's plan and its executions count toward the parent's usage and + bill, so there is no separate subscription here. Manage billing, seats, and quotas + from the parent workspace's settings. + + {:else} + + {/if} {:else if tab == 'slack'}