feat: support workspace forks on cloud using parent workspace limits (#9864)

* feat: support workspace forks on cloud using parent workspace limits

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: clarify count_paid_seats approximates rather than mirrors billing seats

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: non-admin fork UI, attach cap, and fork-count for cloud forks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: cloud fork billing cache on rename, usage display, attach cap edge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: fork count in cloud quotas + fork billing points to parent

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: invalidate billing/fork caches on fork deletion for id reuse

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: gate fork usage remap on CLOUD_HOSTED, not just the cloud feature

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: note cloud feature vs CLOUD_HOSTED gating in backend guide

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: reserve fork-cap slots for an attach candidate's whole subtree

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: invalidate team-plan cache on delete, raise fork depth cap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: cap fork nesting depth (MAX_FORK_DEPTH, default 5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: fork count/height robust to cycles and deleted intermediates

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): reset fork button loading state on creation error

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: invalidate billing cache for attached fork subtree; helper auth docs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-07-02 23:59:02 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d15033cde6
commit 7c7d7474cc
20 changed files with 860 additions and 52 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+10
View File
@@ -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/<pid>/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).
+166 -11
View File
@@ -629,11 +629,13 @@ async fn list_pending_invites(
}
async fn is_premium(
authed: ApiAuthed,
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
) -> JsonResult<bool> {
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::<i64>().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::<i64>().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<String>,
Json(nw): Json<CreateWorkspaceFork>,
) -> JsonResult<Vec<Uuid>> {
// 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<String> {
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<DB>,
Path(parent_workspace_id): Path<String>,
Json(nw): Json<CreateWorkspaceFork>,
) -> Result<String> {
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<String> {
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<DB>, Path(w_id): Path<String>) -> Result<String> {
// 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,
}))
}
@@ -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<String> = 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))
}
+3
View File
@@ -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:
+211 -5
View File
@@ -347,9 +347,215 @@ lazy_static::lazy_static! {
pub static ref PUBLIC_APP_RATE_LIMIT_CACHE: Cache<String, (Option<i32>, 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<String, (String, i64)> = 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<String> {
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<i64> {
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<i64> {
// 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<Vec<String>> {
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<i64> {
// 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<i64> {
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<TeamPlanStatus> {
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<TeamPl
WHERE
w.id = $1
"#,
_w_id
billing_w_id
)
.fetch_optional(_db)
.await
@@ -380,13 +586,13 @@ pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result<TeamPl
)
.notify(|err, dur| {
tracing::error!(
"Failed to get team plan status for workspace {_w_id} (will retry in {dur:?}): {err:#}"
"Failed to get team plan status for workspace {billing_w_id} (will retry in {dur:?}): {err:#}"
);
})
.await
.map_err(|err| {
Error::internal_err(format!(
"Failed to get team plan status for workspace {_w_id} after 10 retries: {err:#}"
"Failed to get team plan status for workspace {billing_w_id} after 10 retries: {err:#}"
))
})?
.unwrap_or_else(|| TeamPlanStatus {
@@ -395,7 +601,7 @@ pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result<TeamPl
max_tolerated_executions: None,
});
TEAM_PLAN_CACHE.insert(_w_id.to_string(), team_plan_info.clone());
TEAM_PLAN_CACHE.insert(billing_w_id, team_plan_info.clone());
Ok(team_plan_info)
}
@@ -0,0 +1,211 @@
#![cfg(feature = "cloud")]
//! Tests for the fork/dev "billing workspace" resolution and the fork-cap seat/count helpers.
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::{
count_paid_seats, count_workspace_forks, fork_chain_depth, fork_subtree_height,
get_billing_workspace_id, invalidate_billing_workspace_cache, list_fork_descendants,
};
async fn insert_ws(db: &Pool<Postgres>, 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<Postgres>,
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<Postgres>) {
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<Postgres>) {
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<Postgres>) {
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<Postgres>) {
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<Postgres>) {
// 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);
}
+28 -7
View File
@@ -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
@@ -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)
@@ -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,
@@ -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 }
]
</script>
@@ -217,7 +217,7 @@
</MenuItem>
</div>
{/if}
{#if !strictWorkspaceSelect && !isCloudHosted() && canCreateFork($userStore) && $workspaceStore !== 'admins'}
{#if !strictWorkspaceSelect && (!isCloudHosted() || $isPremiumStore) && canCreateFork($userStore) && $workspaceStore !== 'admins'}
<div class="py-1" role="none">
<MenuItem
class={itemClass}
@@ -228,20 +228,31 @@
}
async function completeFork(prefixed_id: string): Promise<void> {
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(
@@ -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)
@@ -1377,7 +1377,16 @@
/>
<WorkspaceRulesets />
{:else if tab == 'premium'}
<PremiumInfo {customer_id} {plan} />
{#if currentWsForDevTab?.parent_workspace_id}
<Alert type="info" title="Billing is managed on the parent workspace">
This workspace is a fork of <b>{currentWsForDevTab.parent_workspace_id}</b>. 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.
</Alert>
{:else}
<PremiumInfo {customer_id} {plan} />
{/if}
{:else if tab == 'slack'}
<SettingsPageHeader
title="Workspace connections to Slack and Teams"