fix: walk the whole fork ancestry for app installations and fork conflicts (#11151)

* fix: walk the whole fork ancestry for app installations and fork conflicts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: describe the fork-conflict gate as ancestor-wide

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: update ee-repo-ref to d252afcc80e77fcc4f9a2a346b80908c8605a6c0

This commit updates the EE repository reference after PR #803 was merged in windmill-ee-private.

Previous ee-repo-ref: 5f68c8c351ffc92feccffe69a857b60be376464e

New ee-repo-ref: d252afcc80e77fcc4f9a2a346b80908c8605a6c0

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-09-16 10:45:34 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 windmill-internal-app[bot]
parent 57a99f66a8
commit 73dc892f9c
10 changed files with 301 additions and 103 deletions
+1 -1
View File
@@ -1 +1 @@
93433d7c9dc34f2c0f56a5297d3453aabd2f9472
d252afcc80e77fcc4f9a2a346b80908c8605a6c0
+46
View File
@@ -0,0 +1,46 @@
-- A three-deep fork chain whose schedule rows were cloned down at fork time.
-- The middle fork has since deleted its copy, so the leaf's schedule shares
-- its cron only with the root — the shape a direct-parent check misses.
INSERT INTO workspace (id, name, owner, parent_workspace_id) VALUES
('sfc-root', 'sfc-root', 'sfc-admin', NULL),
('sfc-mid', 'sfc-mid', 'sfc-admin', 'sfc-root'),
('sfc-leaf', 'sfc-leaf', 'sfc-admin', 'sfc-mid');
INSERT INTO workspace_key (workspace_id, kind, key) VALUES
('sfc-root', 'cloud', 'sfc-root-key'),
('sfc-mid', 'cloud', 'sfc-mid-key'),
('sfc-leaf', 'cloud', 'sfc-leaf-key');
INSERT INTO workspace_settings (workspace_id) VALUES
('sfc-root'), ('sfc-mid'), ('sfc-leaf');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('sfc-root', 'all', 'All users', '{}'),
('sfc-mid', 'all', 'All users', '{}'),
('sfc-leaf', 'all', 'All users', '{}');
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
VALUES ('sfc-admin@windmill.dev', 'x', 'password', true, true, 'SFC Admin', 'sfc-admin');
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
('sfc-root', 'sfc-admin@windmill.dev', 'sfc-admin', true, 'Admin'),
('sfc-mid', 'sfc-admin@windmill.dev', 'sfc-admin', true, 'Admin'),
('sfc-leaf', 'sfc-admin@windmill.dev', 'sfc-admin', true, 'Admin');
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
VALUES (encode(sha256('SFC_ADMIN_TOKEN'::bytea), 'hex'), 'SFC_ADMIN_', 'SFC_ADMIN_TOKEN', 'sfc-admin@windmill.dev', 't', true);
-- Enabling pushes the next run, which needs the scheduled script to exist.
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES
('sfc-leaf', 'sfc-admin', 'export async function main() { return "ok" }', '{}', '', '', 'f/shared/job', 7788001, 'deno', '');
INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, args, is_flow, email, timezone, extra_perms, permissioned_as)
VALUES
('sfc-root', 'f/shared/nightly', 'sfc-admin', NOW(), '0 0 0 * * *', true, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin'),
('sfc-leaf', 'f/shared/nightly', 'sfc-admin', NOW(), '0 0 0 * * *', false, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin'),
-- A path only the leaf has: nothing above shares it.
('sfc-leaf', 'f/shared/own', 'sfc-admin', NOW(), '0 0 0 * * *', false, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin');
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
+98 -2
View File
@@ -12,8 +12,8 @@
use sqlx::{Pool, Postgres};
use windmill_common::git_sync_ee::{
create_repo_webhook, git_credential_for_url, repo_provider, repo_supports_managed_git_features,
set_git_credential, GitProvider,
create_repo_webhook, git_app_installations_for, git_credential_for_url, managed_pr_base_branch,
repo_provider, repo_supports_managed_git_features, set_git_credential, GitProvider,
};
use windmill_common::workspaces::GitCredentialProvider;
@@ -278,3 +278,99 @@ async fn an_unreachable_gitlab_host_is_the_reported_error(
);
Ok(())
}
/// GitHub App installations are normally copied into a fork, but a workspace
/// attached as a dev workspace, or forked before its parent connected the App,
/// holds none, and neither does anything forked from it. The lookup reaches the
/// nearest workspace up the chain that holds some, and the background App path
/// (PR base resolution here) authenticates with that installation's token.
#[sqlx::test(fixtures("git_sync_fork_credential"))]
async fn app_installations_come_from_the_nearest_ancestor_holding_some(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use axum::{routing::get, Router};
use std::sync::{Arc, Mutex};
// A stand-in GitHub API: one repository, and a record of who asked for it.
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
let app = Router::new().route(
"/api/v3/repos/acme/repo",
get({
let seen = seen.clone();
move |headers: axum::http::HeaderMap| {
let seen = seen.clone();
async move {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
seen.lock().unwrap().push(auth);
axum::Json(serde_json::json!({ "default_branch": "trunk" }))
}
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let stub = format!("http://127.0.0.1:{port}");
// The root holds the installation, with a cached token so nothing is minted.
sqlx::query(
"UPDATE workspace_settings SET git_app_installations = $1::jsonb WHERE workspace_id = 'parent-ws'",
)
.bind(serde_json::json!([{
"installation_id": 42, "account_id": "acme", "jwt_token": "x",
"github_base_url": stub,
"installation_token": "root-token", "installation_token_expiration": 4102444800i64
}]))
.execute(&db)
.await?;
// The fork's copy of the resource names the App-backed repository.
sqlx::query("UPDATE resource SET value = $1::jsonb WHERE workspace_id = 'deep-fork-ws' AND path = 'u/admin/repo'")
.bind(serde_json::json!({ "url": format!("{stub}/acme/repo.git"), "is_github_app": true }))
.execute(&db)
.await?;
assert_eq!(
git_app_installations_for(&db, "deep-fork-ws").await?,
("parent-ws".to_string(), vec![(42, Some(stub.clone()))]),
"two levels down, the root's installations are the ones to use"
);
assert_eq!(
git_app_installations_for(&db, "orphan-ws").await?,
("orphan-ws".to_string(), vec![]),
"a workspace with nothing above it resolves nothing"
);
assert_eq!(
managed_pr_base_branch(&db, "deep-fork-ws", REPO)
.await?
.as_deref(),
Some("trunk"),
"the background App path reaches the repository through the root's installation"
);
let seen = seen.lock().unwrap().clone();
assert!(
!seen.is_empty() && seen.iter().all(|auth| auth == "Bearer root-token"),
"every call authenticated with the root's cached token: {seen:?}"
);
// A closer holder takes precedence over the root.
sqlx::query(
"UPDATE workspace_settings SET git_app_installations = $1::jsonb WHERE workspace_id = 'fork-ws'",
)
.bind(serde_json::json!([{
"installation_id": 7, "account_id": "acme", "jwt_token": "x",
"github_base_url": stub,
"installation_token": "mid-token", "installation_token_expiration": 4102444800i64
}]))
.execute(&db)
.await?;
assert_eq!(
git_app_installations_for(&db, "deep-fork-ws").await?.0,
"fork-ws",
"the nearest holder wins over the root"
);
Ok(())
}
+59
View File
@@ -0,0 +1,59 @@
//! Enabling a schedule in a fork warns about every ancestor sharing the path,
//! not only the direct parent: the row was cloned down the whole chain, so the
//! cron is shared with whichever ancestors still hold a copy.
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
async fn set_enabled(
base: &str,
path: &str,
enabled: bool,
force: bool,
) -> anyhow::Result<(u16, String)> {
let resp = reqwest::Client::new()
.post(format!("{base}/api/w/sfc-leaf/schedules/setenabled/{path}"))
.header("Authorization", "Bearer SFC_ADMIN_TOKEN")
.json(&json!({ "enabled": enabled, "force": force }))
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
}
#[sqlx::test(fixtures("schedule_fork_conflict"))]
async fn enabling_in_a_fork_names_the_nearest_ancestor_sharing_the_path(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}", server.addr.port());
let (status, body) = set_enabled(&base, "f/shared/nightly", true, false).await?;
assert_eq!(status, 400, "{body}");
assert!(
body.contains("fork-conflict:schedule:sfc-root"),
"the middle fork deleted its copy, so the root is the one still sharing the cron: {body}"
);
let (status, body) = set_enabled(&base, "f/shared/own", true, false).await?;
assert_eq!(
status, 200,
"a path nothing upstream has enables freely: {body}"
);
sqlx::query(
"INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, enabled, script_path, args, is_flow, email, timezone, extra_perms, permissioned_as)
VALUES ('sfc-mid', 'f/shared/nightly', 'sfc-admin', NOW(), '0 0 0 * * *', false, 'f/shared/job', '{}', false, 'sfc-admin@windmill.dev', 'UTC', '{}', 'u/sfc-admin')",
)
.execute(&db)
.await?;
let (status, body) = set_enabled(&base, "f/shared/nightly", true, false).await?;
assert_eq!(status, 400, "{body}");
assert!(
body.contains("fork-conflict:schedule:sfc-mid"),
"with the parent holding a copy again, it is the nearest and gets named: {body}"
);
Ok(())
}
+17 -29
View File
@@ -1129,35 +1129,23 @@ pub async fn set_enabled(
check_scopes(&authed, || format!("schedules:write:{}", path))?;
reject_reserved_schedule_path(path)?;
// Block enabling a schedule in a fork when the parent has the same path
// (regardless of parent's enabled flag), unless force=true. Two enabled
// crons fire in lockstep; even when the parent is currently disabled the
// user is likely to re-enable it later, at which point both fire — better
// to surface that risk at every fork-side enable. There's no namespacing
// fix for schedules (Phase 3 doesn't help cron); the user has to confirm
// or point the script at fork-only side effects.
// Block enabling a schedule in a fork when an ancestor has the same path
// (regardless of its enabled flag), unless force=true. Two enabled crons
// fire in lockstep; even when the ancestor is currently disabled the user
// is likely to re-enable it later, at which point both fire — better to
// surface that risk at every fork-side enable. There's no namespacing fix
// for schedules (Phase 3 doesn't help cron); the user has to confirm or
// point the script at fork-only side effects.
if payload.enabled && !payload.force {
let parent_id: Option<String> = sqlx::query_scalar!(
"SELECT parent_workspace_id FROM workspace WHERE id = $1",
&w_id
if let Some(ancestor_id) = windmill_common::workspaces::nearest_fork_ancestor_having(
&mut *tx, "schedule", &w_id, path,
)
.fetch_optional(&mut *tx)
.await?
.flatten();
if let Some(parent_id) = parent_id {
let exists: Option<bool> = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM schedule WHERE workspace_id = $1 AND path = $2)",
&parent_id,
path,
)
.fetch_one(&mut *tx)
.await?;
if exists == Some(true) {
return Err(Error::BadRequest(format!(
"fork-conflict:schedule:{}",
parent_id
)));
}
{
return Err(Error::BadRequest(format!(
"fork-conflict:schedule:{}",
ancestor_id
)));
}
}
let before = trigger_history::snapshot_row(&mut *tx, "schedule", &w_id, path).await?;
@@ -1699,9 +1687,9 @@ pub use windmill_queue::schedule::clear_schedule;
#[derive(Deserialize)]
pub struct SetEnabled {
pub enabled: bool,
/// Bypass the parent-state warning when enabling a schedule in a fork
/// whose parent has the same path enabled. The frontend sets this after
/// the user confirms the duplicate-firing dialog.
/// Bypass the fork-conflict warning when enabling a schedule in a fork
/// while an ancestor workspace has the same path. The frontend sets this
/// after the user confirms the duplicate-firing dialog.
#[serde(default)]
pub force: bool,
}
+42
View File
@@ -1788,6 +1788,48 @@ pub async fn workspace_with_fork_ancestors(db: &crate::DB, w_id: &str) -> Result
Ok(chain)
}
/// The nearest fork ancestor of `w_id` holding a row at `path` in `table`, or `None` when no
/// ancestor does (or `w_id` is not a fork). Fork creation clones trigger and schedule rows down
/// the whole chain, so a row shares its upstream identifier (Kafka group, PG slot, cron) with
/// every ancestor that still has one, not just the direct parent, which may have deleted its
/// copy since.
///
/// Runs on the caller's connection so it sees the caller's transaction, and uncached: the
/// answer depends on the target table, not only on lineage.
///
/// `table` is interpolated into SQL, hence `'static`: a trigger's `TABLE_NAME` or a literal,
/// never caller input. Reads lineage for any `w_id` with no authorization check, like
/// [`fork_ancestor_chain`], so the caller must already be authorized for `w_id`.
pub async fn nearest_fork_ancestor_having(
conn: &mut sqlx::PgConnection,
table: &'static str,
w_id: &str,
path: &str,
) -> Result<Option<String>> {
sqlx::query_scalar(&format!(
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 chain.id FROM chain
JOIN {table} t ON t.workspace_id = chain.id AND t.path = $2
WHERE chain.depth > 0
ORDER BY chain.depth LIMIT 1
"#
))
.bind(w_id)
.bind(path)
.fetch_optional(&mut *conn)
.await
.map_err(|e| Error::internal_err(format!("resolving fork ancestors of {w_id}: {e:#}")))
}
lazy_static::lazy_static! {
/// workspace id -> (root workspace id, expiry ts). Read once per job start, so correctness
/// rests on the invalidation rather than on the TTL: every mutation that can change the answer
+21 -55
View File
@@ -97,14 +97,14 @@ pub trait TriggerCrud: Send + Sync + 'static {
const DEPLOYMENT_NAME: &'static str;
const ADDITIONAL_SELECT_FIELDS: &[&'static str] = &[];
const IS_ALLOWED_ON_CLOUD: bool;
/// Whether enabling this trigger in a fork while the parent has the same
/// path enabled is a real conflict (shared upstream resource). True for
/// Whether enabling this trigger in a fork while an ancestor workspace has
/// the same path is a real conflict (shared upstream resource). True for
/// listener-based kinds where two consumers compete (Kafka group, PG slot,
/// SQS queue, etc.) and for Websocket where both subscribers fire on every
/// broadcast. False for kinds whose upstream identifier is implicitly
/// workspace-scoped at runtime (HTTP routes, Email local_part — clones for
/// the non-workspaced sub-case are filtered out, so any cloned row is
/// already collision-free vs. the parent).
/// already collision-free vs. its ancestors).
const FORK_CONFLICT_ON_ENABLE: bool = true;
fn get_deployed_object(path: String, parent_path: Option<String>) -> DeployedObject;
@@ -1095,54 +1095,14 @@ async fn exists_trigger<T: TriggerCrud>(
#[derive(serde::Deserialize)]
struct SetTriggerModePayload {
mode: TriggerMode,
/// When true, bypass the parent-state warning that would otherwise reject
/// enabling a trigger that's already enabled in the parent workspace.
/// The frontend sets this after the user confirms the duplicate-execution
/// dialog. See windmill-trigger/src/handler.rs::set_trigger_mode for the
/// full check.
/// When true, bypass the fork-conflict warning that would otherwise reject
/// enabling a trigger an ancestor workspace also has at this path. The
/// frontend sets this after the user confirms the duplicate-execution
/// dialog. See `set_trigger_mode` for the full check.
#[serde(default)]
force: bool,
}
/// Returns the parent workspace id when this workspace is a fork *and* the
/// parent has a row at the same trigger path. Used to gate enabling a trigger
/// in a fork behind an explicit `force=true` confirmation: the fork's row was
/// cloned from the parent, so its upstream identifier (Kafka group, PG slot,
/// SQS queue URL, etc.) is shared by construction. The risk is independent of
/// the parent's current `mode`: if the parent is enabled, the two listeners
/// compete; if it's disabled, the fork can destructively take over shared
/// state (e.g. advance the PG WAL, claim an MQTT client_id) before the parent
/// re-enables. Either way, the user should be asked to confirm.
async fn parent_has_trigger(
tx: &mut PgConnection,
table_name: &str,
workspace_id: &str,
path: &str,
) -> Result<Option<String>> {
let parent: Option<String> =
sqlx::query_scalar("SELECT parent_workspace_id FROM workspace WHERE id = $1")
.bind(workspace_id)
.fetch_optional(&mut *tx)
.await?
.flatten();
let Some(parent_id) = parent else {
return Ok(None);
};
let exists: Option<bool> = sqlx::query_scalar(&format!(
"SELECT EXISTS(SELECT 1 FROM {} WHERE workspace_id = $1 AND path = $2)",
table_name
))
.bind(&parent_id)
.bind(path)
.fetch_one(&mut *tx)
.await?;
Ok(if exists == Some(true) {
Some(parent_id)
} else {
None
})
}
async fn set_trigger_mode<T: TriggerCrud>(
Extension(handler): Extension<Arc<T>>,
authed: ApiAuthed,
@@ -1157,22 +1117,28 @@ async fn set_trigger_mode<T: TriggerCrud>(
let mut tx = user_db.begin(&authed).await?;
// Block transitioning a trigger in a fork to any mode that attaches a
// listener (Enabled or Suspended) when the parent has the same path,
// listener (Enabled or Suspended) when an ancestor has the same path,
// unless the caller passes force=true. Suspended still keeps the
// listener attached — it just stops auto-running queued jobs — so a
// suspended fork would still split Kafka events / share a PG slot
// with the parent. The cloned upstream identifier is shared by
// construction; the risk is independent of the parent's current mode.
// Skipped for kinds where the upstream identifier is already
// workspace-scoped at runtime (HTTP, Email).
// with the ancestor. The risk is independent of the ancestor's current
// mode: enabled, the two listeners compete; disabled, the fork can
// destructively take over shared state (advance the PG WAL, claim an
// MQTT client_id) before it re-enables. Skipped for kinds where the
// upstream identifier is already workspace-scoped at runtime (HTTP, Email).
if T::FORK_CONFLICT_ON_ENABLE && payload.mode != TriggerMode::Disabled && !payload.force {
if let Some(parent_id) =
parent_has_trigger(&mut *tx, T::TABLE_NAME, &workspace_id, path).await?
if let Some(ancestor_id) = windmill_common::workspaces::nearest_fork_ancestor_having(
&mut *tx,
T::TABLE_NAME,
&workspace_id,
path,
)
.await?
{
return Err(Error::BadRequest(format!(
"fork-conflict:{}:{}",
T::TRIGGER_TYPE,
parent_id
ancestor_id
)));
}
}
@@ -32,29 +32,29 @@
<ConfirmationModal
open={!!state}
title="Enable in fork conflicts with parent"
title="Enable in fork conflicts with upstream workspace"
confirmationText="Enable anyway"
onConfirmed={() => close(true)}
onCanceled={() => close(false)}
>
{#if state}
<p>
The parent workspace (<span class="font-mono">{state.parentWorkspaceId}</span>) has the same
{state.kindLabel} configured at this path. Because this fork's row was cloned from it, the upstream
identifier is shared.
The upstream workspace (<span class="font-mono">{state.upstreamWorkspaceId}</span>) this fork
descends from has the same {state.kindLabel} configured at this path. Because this fork's row was
cloned from it, the upstream identifier is shared.
</p>
<p class="mt-2">
{#if family === 'split'}
If both are enabled, the two listeners will compete on the same upstream and each side will
receive only a fraction of its events.
{:else if family === 'duplicate'}
If both are enabled, every event will fire the script twice — once in the fork and once in
the parent.
If both are enabled, every event will fire the script twice: once in the fork and once in
the upstream workspace.
{:else if family === 'slot'}
The cloned <span class="font-mono">replication_slot_name</span> points at the same Postgres slot,
which only allows one consumer at a time. Enabling here will either fail with "slot already active"
if the parent is enabled, or hijack the slot's WAL position if it isn't — causing the parent
to lose events when re-enabled.
if the upstream workspace is enabled, or hijack the slot's WAL position if it isn't, causing
it to lose events when re-enabled.
{:else}
Enabling it here may compete for the same upstream events or duplicate side effects.
{/if}
+1 -1
View File
@@ -242,7 +242,7 @@ export type GlobalForkModalState = {
export type ForkConflictModalState = {
kind: string
kindLabel: string
parentWorkspaceId: string
upstreamWorkspaceId: string
resolve: (proceed: boolean) => void
}
+8 -7
View File
@@ -2,14 +2,15 @@ import { forkConflictModal } from '$lib/stores'
/**
* The backend rejects "enable" requests on triggers/schedules in a fork when
* the parent workspace has the same path enabled. The error body is shaped as
* `fork-conflict:<kind>:<parent_workspace_id>`
* an upstream workspace (the parent, or an ancestor further up) has the same
* path. The error body is shaped as
* `fork-conflict:<kind>:<upstream_workspace_id>`
* so the UI can show a tailored confirm-to-proceed dialog and re-issue the
* call with `force: true` if the user agrees.
*/
export interface ForkConflict {
kind: string
parentWorkspaceId: string
upstreamWorkspaceId: string
}
export function detectForkConflict(e: unknown): ForkConflict | null {
@@ -20,7 +21,7 @@ export function detectForkConflict(e: unknown): ForkConflict | null {
: ((body as any)?.error?.message ?? (body as any)?.message ?? (e as any)?.message ?? '')
const m = String(raw).match(/fork-conflict:([^:]+):(.+)/)
if (!m) return null
return { kind: m[1], parentWorkspaceId: m[2].trim() }
return { kind: m[1], upstreamWorkspaceId: m[2].trim() }
}
/**
@@ -30,11 +31,11 @@ export function detectForkConflict(e: unknown): ForkConflict | null {
* on two rows in quick succession), resolve the older promise to false so
* the prior caller doesn't hang.
*/
function askForkConflictConfirm(kind: string, kindLabel: string, parentWorkspaceId: string) {
function askForkConflictConfirm(kind: string, kindLabel: string, upstreamWorkspaceId: string) {
return new Promise<boolean>((resolve) => {
const previous = forkConflictModal.val
previous?.resolve(false)
forkConflictModal.val = { kind, kindLabel, parentWorkspaceId, resolve }
forkConflictModal.val = { kind, kindLabel, upstreamWorkspaceId, resolve }
})
}
@@ -64,7 +65,7 @@ export async function withForkConflictRetry(
const proceed = await askForkConflictConfirm(
conflict.kind,
kindLabel,
conflict.parentWorkspaceId
conflict.upstreamWorkspaceId
)
// User explicitly dismissed the modal — treat as a silent no-op so the
// caller's catch block doesn't pop a redundant error toast.