Files
windmill/frontend/src/lib/utils/forkConflict.ts
T
73dc892f9c 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>
2026-09-16 10:45:34 +02:00

77 lines
2.6 KiB
TypeScript

import { forkConflictModal } from '$lib/stores'
/**
* The backend rejects "enable" requests on triggers/schedules in a fork when
* 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
upstreamWorkspaceId: string
}
export function detectForkConflict(e: unknown): ForkConflict | null {
const body = (e as any)?.body
const raw =
typeof body === 'string'
? body
: ((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], upstreamWorkspaceId: m[2].trim() }
}
/**
* Opens the global ForkConflictModal and awaits the user's choice. Resolves
* to true when the user clicks "Enable anyway", false when they cancel or
* dismiss. If a previous modal is still pending (e.g. user clicked toggles
* 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, upstreamWorkspaceId: string) {
return new Promise<boolean>((resolve) => {
const previous = forkConflictModal.val
previous?.resolve(false)
forkConflictModal.val = { kind, kindLabel, upstreamWorkspaceId, resolve }
})
}
/**
* Catches a fork-conflict error from `fn(false)`, shows the confirmation
* dialog, and retries with `fn(true)` when the user accepts. Re-throws every
* other error.
*
* Returns `true` when the call committed (no conflict, or user confirmed and
* retry succeeded) and `false` when the user dismissed the modal. Callers
* should bail on `false` to skip success toasts and revert any optimistic UI
* state.
*
* `kindLabel` is shown to the user — pass a friendly name like "kafka trigger"
* or "schedule" so the dialog reads naturally.
*/
export async function withForkConflictRetry(
fn: (force: boolean) => Promise<unknown>,
kindLabel: string
): Promise<boolean> {
try {
await fn(false)
return true
} catch (e) {
const conflict = detectForkConflict(e)
if (!conflict) throw e
const proceed = await askForkConflictConfirm(
conflict.kind,
kindLabel,
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.
if (!proceed) return false
await fn(true)
return true
}
}