Files
windmill/frontend/src/lib/components/CompareWorkspaces.svelte
T
Ruben Fiszel fadbe079e4 fix: refresh the dev-workspace pairing after attach and detach (#10491)
* fix: refresh the dev-workspace pairing after attach and detach

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

* fix: report enforced protections, not only unconditional ones, on the paired view

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 00:55:42 +02:00

1894 lines
70 KiB
Svelte

<script lang="ts">
import {
AlertTriangle,
ArrowDownRight,
ArrowRight,
ArrowUpRight,
Building,
CircleCheck,
CircleX,
DiffIcon,
FileJson,
FlaskConical,
GitFork,
ChevronDown,
Loader2,
RefreshCw,
UserPlus
} from 'lucide-svelte'
import { untrack } from 'svelte'
import type { CiTestResult } from '$lib/gen'
import { Alert, Badge } from './common'
import {
AppService,
FlowService,
FolderService,
ResourceService,
ScriptService,
UserService,
VariableService,
WorkspaceService,
type WorkspaceComparison,
type WorkspaceItemDiff
} from '$lib/gen'
import Button from './common/button/Button.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import DiffDrawer from './DiffDrawer.svelte'
import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import type { Kind } from '$lib/utils_deployable'
import {
checkDeployPermission,
deployItem,
deleteItemInWorkspace,
diffActionableInDirection,
diffCreatesInTarget,
diffRemovesInTarget,
getItemValue,
getOnBehalfOf,
type DeployPermission,
type DeployResult
} from '$lib/utils_workspace_deploy'
import { asSyncDeploy } from '$lib/deployOrigin'
import { isTriggerOrScheduleKind } from 'windmill-utils-internal'
import Tooltip from './Tooltip.svelte'
import OnBehalfOfSelector, {
needsOnBehalfOfSelection,
type OnBehalfOfChoice,
type OnBehalfOfDetails
} from './OnBehalfOfSelector.svelte'
import { sendUserToast } from '$lib/toast'
import { deepEqual } from 'fast-equals'
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte'
import { userStore } from '$lib/stores'
import { base } from '$lib/base'
import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte'
import CompareTargetPicker from './CompareTargetPicker.svelte'
import { displayDate } from '$lib/utils'
import { editUrlFor } from './sessions/forkEditUrl'
import { diffInMask } from './sessions/modifiedItemsMask'
import DatatableSchemaDiff from './DatatableSchemaDiff.svelte'
interface Props {
currentWorkspaceId: string
/** The workspace being merged into. Normally the lineage parent; with
* `isArbitraryTarget` it is any workspace the user administers. */
parentWorkspaceId: string
/** The lineage parent, when this workspace has one. Only used to offer the
* way back from an arbitrary target. */
lineageParentId?: string
/** The target is outside the fork lineage: nothing tallies the pair, so the
* comparison is one-way (current → target) and rests on an explicit full
* scan rather than on the continuous tally. */
isArbitraryTarget?: boolean
/** When the arbitrary pair's candidate set was last enumerated. Undefined
* means never — an empty comparison then says nothing about the two
* workspaces and the deploy list must not be shown as authoritative. */
fullScanAt?: string
scanning?: boolean
onScan?: () => void
/** Re-run the comparison without re-seeding — the only way out of a transient
* failure, which otherwise hides both the scan and recompute actions. */
onRetry?: () => void
onSelectTarget?: (target: string) => void
comparison: WorkspaceComparison | undefined
/** Set when the comparison request failed (e.g. not an admin of the target). */
comparisonError?: string
/** Initial merge direction; lets the page restore the chosen direction when
* switching back from draft mode (deploy_to → true, update → false). */
initialMergeIntoParent?: boolean
/** Per-direction counts for the merged toggle badges (the page owns them). */
deployCount?: number
updateCount?: number
/** Draft count for the merged toggle's badge (the page owns it). */
draftCount?: number
/** Keys (`kind:path`) of fork items that are deployed *and* have a pending
* draft (has_draft). Such rows get a "+Draft" warning badge and are left
* out of the default selection — deploying/updating moves the deployed
* version, not the draft. The page derives this from the fork drafts. */
draftKeys?: Set<string>
/** When set (reached via a session's Review button), preselect only the
* diffs this chat caused (matched via diffInMask). The deploy_to default
* narrows to these; the update direction (parent→fork) preselects nothing,
* since it's never chat-caused. All rows are still shown. */
chatMask?: Set<string>
/** False while the (async) chatMask is still loading. The select-all default
* waits for this so it doesn't race the mask. Defaults to true. */
chatMaskReady?: boolean
/** Selecting `draft` asks the page to swap us out for CompareDrafts;
* deploy_to/update are handled internally but reported so the page can
* remember the direction. */
onModeSelected?: (v: CompareMode) => void
/** Fired after a deploy/update so the page re-fetches the comparison and
* draft count, keeping the toggle badges in sync with the new state. */
onChanged?: () => void
}
let {
currentWorkspaceId,
parentWorkspaceId,
lineageParentId,
isArbitraryTarget = false,
fullScanAt,
scanning = false,
onScan,
onRetry,
onSelectTarget,
comparison,
comparisonError,
initialMergeIntoParent = true,
deployCount = 0,
updateCount = 0,
draftCount = 0,
draftKeys = new Set<string>(),
chatMask,
chatMaskReady = true,
onModeSelected,
onChanged
}: Props = $props()
// Workspace-specific ("pinned") resources/variables. They keep their own
// value per environment and are suppressed from the diff, so the page fetches
// them separately to keep them visible and un-pinnable.
// `onCurrent`/`onParent` track which side carries the ws_specific marker — which, because marks
// are existence-guarded, also tells us which side the item exists on. An item present on only one
// side can be seeded onto the other via "Create in <other>".
type PinnedItem = { item_kind: string; path: string; onCurrent: boolean; onParent: boolean }
let pinnedItems = $state<PinnedItem[]>([])
let pinBusy = $state<Record<string, boolean>>({})
let createConfirm = $state<PinnedItem | undefined>(undefined)
// Whether this item is already workspace-specific (marked on either side); used to avoid offering
// to pin it again from a diff row.
const isPinned = (kind: string, path: string) =>
pinnedItems.some((it) => it.item_kind === kind && it.path === path)
// Bumped on each load so a response that resolves after the compared pair
// changed can't overwrite the newer pair's items.
let pinnedReqSeq = 0
async function loadPinned() {
const seq = ++pinnedReqSeq
try {
const [cur, par] = await Promise.all([
WorkspaceService.listWsSpecific({ workspace: currentWorkspaceId }),
WorkspaceService.listWsSpecific({ workspace: parentWorkspaceId })
])
if (seq !== pinnedReqSeq) return
const map = new Map<string, PinnedItem>()
const mark = (it: { item_kind: string; path: string }, side: 'cur' | 'par') => {
const k = `${it.item_kind}:${it.path}`
const e = map.get(k) ?? {
item_kind: it.item_kind,
path: it.path,
onCurrent: false,
onParent: false
}
if (side === 'cur') e.onCurrent = true
else e.onParent = true
map.set(k, e)
}
for (const it of cur) mark(it, 'cur')
for (const it of par) mark(it, 'par')
pinnedItems = [...map.values()].sort((a, b) =>
`${a.item_kind}:${a.path}`.localeCompare(`${b.item_kind}:${b.path}`)
)
} catch (e) {
console.error('Failed to load workspace-specific items', e)
}
}
$effect(() => {
// Re-fetch whenever the compared pair changes.
currentWorkspaceId
parentWorkspaceId
loadPinned()
})
// Flag both environments so each side marks the item workspace-specific, not
// only the workspace the compare is viewed from. `value=false` makes it
// shared again.
async function setPinned(kind: 'resource' | 'variable', path: string, value: boolean) {
const k = `${kind}:${path}`
pinBusy[k] = true
try {
const targets = [currentWorkspaceId, parentWorkspaceId]
const results = await Promise.allSettled(
targets.map((ws) =>
WorkspaceService.setWsSpecific({
workspace: ws,
requestBody: { item_kind: kind, path, value }
})
)
)
const verb = value ? 'workspace specific' : 'shared'
const failed = results.filter((r) => r.status === 'rejected').length
if (failed === targets.length) {
sendUserToast(`Failed to update workspace-specific for ${path}`, true)
} else if (failed > 0) {
sendUserToast(`Made ${path} ${verb} in one environment only (no access to the other)`)
} else {
sendUserToast(`Made ${path} ${verb}`)
}
await loadPinned()
onChanged?.()
} finally {
pinBusy[k] = false
}
}
// Collect `$var:` variable references from a resource value (mirrors the backend `collect_var_refs`).
function collectVarRefs(value: unknown, out: string[]) {
if (typeof value === 'string') {
if (value.startsWith('$var:')) out.push(value.slice('$var:'.length))
} else if (Array.isArray(value)) {
for (const v of value) collectVarRefs(v, out)
} else if (value && typeof value === 'object') {
for (const v of Object.values(value)) collectVarRefs(v, out)
}
}
// Create the item on the target from the source value, strictly create-only: the backend create
// endpoints reject an existing path (`check_path_conflict`), so unlike `deployItem` — which updates
// on conflict — this never overwrites a target created concurrently. Returns 'created', 'conflict'
// (already present on the target), or throws for a real error.
async function createItemOnly(
kind: 'resource' | 'variable',
path: string,
from: string,
to: string
): Promise<'created' | 'conflict'> {
try {
if (kind === 'resource') {
const r = await ResourceService.getResource({ workspace: from, path })
await ResourceService.createResource({
workspace: to,
requestBody: {
path,
value: r.value ?? '',
description: r.description ?? '',
resource_type: r.resource_type
}
})
} else {
const v = await VariableService.getVariable({ workspace: from, path, decryptSecret: true })
await VariableService.createVariable({
workspace: to,
requestBody: {
path,
value: v.value ?? '',
is_secret: v.is_secret ?? false,
description: v.description ?? ''
}
})
}
return 'created'
} catch (e) {
// Don't parse the error message: re-check existence. If the target now exists, the create
// lost a race (the backend create is create-only and rejected it), so report a conflict and
// leave it untouched rather than overwriting.
const existsNow =
kind === 'resource'
? await ResourceService.existsResource({ workspace: to, path })
: await VariableService.existsVariable({ workspace: to, path })
if (existsNow) return 'conflict'
throw e
}
}
// Seed a resource's linked `$var:` variables into the target environment, copying each one's value
// (incl. secrets) only where the target lacks it (create-only). Mirrors the backend's
// `mark_linked_variables_ws_specific` cascade so a seeded resource resolves its references and its
// secrets stay per-environment. Returns the paths that failed to seed.
async function seedMissingLinkedVars(
resourcePath: string,
from: string,
to: string
): Promise<string[]> {
const resource = await ResourceService.getResource({ workspace: from, path: resourcePath })
const refs: string[] = []
collectVarRefs(resource.value, refs)
const failed: string[] = []
for (const varPath of Array.from(new Set(refs))) {
if (await VariableService.existsVariable({ workspace: to, path: varPath })) continue
try {
// 'created' and 'conflict' (now present, created concurrently) are both the desired end
// state; only a real error counts as a failure to seed.
await createItemOnly('variable', varPath, from, to)
} catch (e) {
failed.push(varPath)
}
}
return failed
}
// Seed a workspace-specific item onto a side that lacks it, copying its current value (incl.
// secret values). Strictly create-only: markers can be one-sided (a pin can fail on a locked
// side), so a missing marker doesn't prove the item is missing — re-check actual existence and,
// if the target already has it, mark it workspace-specific there instead of overwriting its value.
async function createOnRemote(it: PinnedItem) {
const from = it.onCurrent ? currentWorkspaceId : parentWorkspaceId
const to = it.onCurrent ? parentWorkspaceId : currentWorkspaceId
const k = `${it.item_kind}:${it.path}`
const kindCast = it.item_kind as 'resource' | 'variable'
pinBusy[k] = true
try {
const existsOnTarget =
kindCast === 'resource'
? await ResourceService.existsResource({ workspace: to, path: it.path })
: await VariableService.existsVariable({ workspace: to, path: it.path })
if (existsOnTarget) {
// Don't overwrite — just mark the existing target item workspace-specific.
await WorkspaceService.setWsSpecific({
workspace: to,
requestBody: { item_kind: kindCast, path: it.path, value: true }
})
sendUserToast(
`${it.path} already exists in ${to} — marked it workspace-specific instead of overwriting`
)
await loadPinned()
onChanged?.()
return
}
// A resource owns its `$var:` secrets, so seed any linked variables the target lacks before
// creating the resource — otherwise it would reference variables that don't exist there. If
// any linked variable fails to seed, abort rather than create a resource with dangling refs.
const linkedFailed =
kindCast === 'resource' ? await seedMissingLinkedVars(it.path, from, to) : []
if (linkedFailed.length > 0) {
sendUserToast(
`Did not create ${it.path} in ${to}: failed to seed linked variable(s) ${linkedFailed.join(', ')}`,
true
)
return
}
// Create-only: a 'conflict' means the target appeared between the existence check above and
// the create, so it was left untouched rather than overwritten.
const result = await createItemOnly(kindCast, it.path, from, to)
// Marking cascades to mark a resource's now-present linked variables workspace-specific.
await WorkspaceService.setWsSpecific({
workspace: to,
requestBody: { item_kind: kindCast, path: it.path, value: true }
})
sendUserToast(
result === 'conflict'
? `${it.path} already exists in ${to}, marked it workspace-specific instead of overwriting`
: `Created ${it.path} in ${to}`
)
await loadPinned()
onChanged?.()
} catch (e) {
sendUserToast(`Failed to create ${it.path}: ${e}`, true)
} finally {
pinBusy[k] = false
}
}
// A fork row has a pending draft when its key is in the page-provided set.
function hasDraft(diff: WorkspaceItemDiff): boolean {
return draftKeys.has(getItemKey(diff))
}
let currentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == currentWorkspaceId))
let parentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == parentWorkspaceId))
// An arbitrary target is one-way: the pair has no tally, so nothing distinguishes
// a change made here from one made there, and the "update current" direction
// would list every difference as an incoming change.
let mergeIntoParent = $state(isArbitraryTarget || initialMergeIntoParent)
// An arbitrary pair with no scan yet has an empty comparison for want of a
// candidate set, not because the workspaces agree — say so instead of letting
// the empty deploy list read as "up to date".
let awaitingScan = $derived(isArbitraryTarget && !fullScanAt)
let deploying = $state(false)
let hasAutoSelected = $state(false)
let canDeployToParent = $state(true)
let canPreserveInParent = $state(false)
let canPreserveInCurrent = $state(false)
let canPreserveOnBehalfOf = $derived(mergeIntoParent ? canPreserveInParent : canPreserveInCurrent)
let selectableDiffs = $derived(
comparison?.diffs.filter((diff) =>
diffActionableInDirection(diff, mergeIntoParent, isArbitraryTarget)
) ?? []
)
let selectedItems = $state<string[]>([])
// Selected items that carry a pending draft. They're opt-in (excluded from the
// default selection), so a non-empty list means the user explicitly picked an
// item whose draft won't be included — confirm before deploying.
let selectedDraftKeys = $derived(selectedItems.filter((k) => draftKeys.has(k)))
let draftConfirmOpen = $state(false)
function requestDeploy() {
if (selectedDraftKeys.length > 0) {
draftConfirmOpen = true
} else {
deployChanges()
}
}
// Nothing actionable in the current direction (no items ahead to deploy, or
// none behind to update). When so we show a message instead of a table of
// greyed, non-actionable rows.
let nothingToAct = $derived(selectableDiffs.length === 0)
let emptyDeployMessage = $derived(
awaitingScan
? `No diff computed yet between this workspace and ${parentWorkspaceId}.`
: (comparison?.diffs.length ?? 0) === 0
? `No changes between this workspace and ${parentWorkspaceId}.`
: mergeIntoParent
? `Nothing to deploy — ${parentWorkspaceId} already has every change from this workspace.`
: `Nothing to update — this fork is up to date with ${parentWorkspaceId}.`
)
let conflictingDiffs = $derived(
comparison?.diffs.filter((diff) => diff.ahead > 0 && diff.behind > 0) ?? []
)
let itemsWithBehindChanges = $derived(
comparison?.diffs.filter((diff) => {
const status = deploymentStatus[getItemKey(diff)]?.status
return diff && diff.behind > 0 && diff.ahead == 0 && !(status && status == 'deployed')
}) ?? []
)
// Gates the "update before deploying" alert, so it counts what a merge would
// actually carry — not every row with an `ahead` counter, which includes the
// parent-only ones the merge direction leaves out.
let itemsWithAheadChanges = $derived(
comparison?.diffs.filter((diff) => {
const status = deploymentStatus[getItemKey(diff)]?.status
return (
diff &&
diffActionableInDirection(diff, true, isArbitraryTarget) &&
!(status && status == 'deployed')
)
}) ?? []
)
let hasBehindChanges = $derived(mergeIntoParent && itemsWithBehindChanges.length > 0)
let hasAheadChanges = $derived(mergeIntoParent && itemsWithAheadChanges.length > 0)
// Summary cache: stores summaries from both workspaces
type SummaryCache = Record<string, { current?: string; parent?: string; loading?: boolean }>
let summaryCache = $state<SummaryCache>({})
// On-behalf-of tracking for flows and scripts
// Source workspace on_behalf_of emails (keyed by workspace/kind:path)
let onBehalfOfInfo = $state<Record<string, string | undefined>>({})
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
let customOnBehalfOf = $state<Record<string, OnBehalfOfDetails>>({})
let deployTargetWorkspace = $derived(mergeIntoParent ? parentWorkspaceId : currentWorkspaceId)
let deploySourceWorkspace = $derived(mergeIntoParent ? currentWorkspaceId : parentWorkspaceId)
function getItemKey(diff: WorkspaceItemDiff): string {
return `${diff.kind}:${diff.path}`
}
async function fetchSummary(
kind: string,
path: string,
workspace: string
): Promise<string | undefined> {
try {
if (kind === 'script') {
const script = await ScriptService.getScriptByPath({ workspace, path })
return script.summary
} else if (kind === 'flow') {
const flow = await FlowService.getFlowByPath({ workspace, path })
return flow.summary
} else if (kind === 'app' || kind === 'raw_app') {
const app = await AppService.getAppByPath({ workspace, path })
return app.summary
} else if (kind === 'folder') {
const folder = await FolderService.getFolder({ workspace, name: path.replace(/^f\//, '') })
return folder.summary
}
} catch (error) {
console.error(`Failed to fetch summary for ${kind}:${path}`, error)
}
return undefined
}
async function fetchSummaries(diffs: WorkspaceItemDiff[]) {
// Only fetch summaries for scripts, flows, and apps
const itemsToFetch = diffs.filter((diff) =>
['script', 'flow', 'app', 'raw_app', 'folder'].includes(diff.kind)
)
for (const diff of itemsToFetch) {
const key = getItemKey(diff)
// Skip if already cached or loading
if (summaryCache[key]) continue
// Mark as loading
summaryCache[key] = { loading: true }
// Fetch from both workspaces in parallel
const [currentSummary, parentSummary] = await Promise.all([
fetchSummary(diff.kind, diff.path, currentWorkspaceId),
fetchSummary(diff.kind, diff.path, parentWorkspaceId)
])
summaryCache[key] = {
current: currentSummary,
parent: parentSummary,
loading: false
}
}
}
async function fetchOnBehalfOfInfo(diffs: WorkspaceItemDiff[]) {
// Runnables carry an email; triggers/schedules carry `permissioned_as`.
// `getOnBehalfOf` handles the dispatch — we just need to skip kinds that
// have no on_behalf_of concept (resource, variable, folder, resource_type).
const itemsWithOnBehalfOf = diffs.filter(
(d) =>
['flow', 'script', 'app', 'raw_app'].includes(d.kind) || isTriggerOrScheduleKind(d.kind)
)
for (const diff of itemsWithOnBehalfOf) {
for (const workspace of [currentWorkspaceId, parentWorkspaceId]) {
const workspacedKey = getWorkspacedKey(workspace, getItemKey(diff))
if (onBehalfOfInfo[workspacedKey] !== undefined) continue
try {
onBehalfOfInfo[workspacedKey] = await getOnBehalfOf(
diff.kind as Kind,
diff.path,
workspace
)
} catch {
onBehalfOfInfo[workspacedKey] = undefined
}
}
}
}
// Get source workspace on_behalf_of value for an item (email for runnables, permissioned_as for triggers)
function getSourceOnBehalfOf(itemKey: string): string | undefined {
const sourceWorkspace = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId
return onBehalfOfInfo[getWorkspacedKey(sourceWorkspace, itemKey)]
}
// Get target workspace on_behalf_of value for an item (existing item in destination)
function getTargetOnBehalfOf(itemKey: string): string | undefined {
const targetWorkspace = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId
return onBehalfOfInfo[getWorkspacedKey(targetWorkspace, itemKey)]
}
// Check if an item needs on_behalf_of selection
function itemNeedsOnBehalfOfSelection(itemKey: string, kind: string): boolean {
return needsOnBehalfOfSelection(kind, getSourceOnBehalfOf(itemKey))
}
// Check if all required on_behalf_of selections are made
let hasUnselectedOnBehalfOf = $derived(
selectedItems.some((itemKey) => {
const diff = selectableDiffs.find((d) => getItemKey(d) === itemKey)
if (!diff) return false
return (
itemNeedsOnBehalfOfSelection(itemKey, diff.kind) && onBehalfOfChoice[itemKey] === undefined
)
})
)
/**
* Get the on_behalf_of value for deployment based on user's choice.
* Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules.
*/
function getOnBehalfOfForDeploy(itemKey: string, kind: Kind): string | undefined {
const choice = onBehalfOfChoice[itemKey]
if (choice === 'target') return getTargetOnBehalfOf(itemKey)
if (choice === 'custom') {
const details = customOnBehalfOf[itemKey]
const wantsPermissionedAs = kind === 'trigger' || isTriggerOrScheduleKind(kind)
return wantsPermissionedAs ? details?.permissionedAs : details?.email
}
// 'me' or undefined = don't pass, backend will use deploying user's identity
return undefined
}
/**
* Authorization half of the on_behalf_of pair for flows/scripts. Only a custom pick
* names one; for every other choice the backend derives it from the email, so
* returning undefined clears the source item's value rather than keeping it.
*/
function getOnBehalfOfPermissionedAsForDeploy(itemKey: string, kind: Kind): string | undefined {
if (kind === 'trigger' || isTriggerOrScheduleKind(kind)) return undefined
if (onBehalfOfChoice[itemKey] !== 'custom') return undefined
return customOnBehalfOf[itemKey]?.permissionedAs
}
let diffDrawer: DiffDrawer | undefined = $state(undefined)
let isFlow = $state(true)
async function showDiff(kind: Kind, path: string) {
const workspaceTo = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId
const workspaceFrom = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId
if (diffDrawer) {
isFlow = kind == 'flow'
diffDrawer?.openDrawer()
let values = await Promise.all([
getItemValue(kind, path, workspaceTo),
getItemValue(kind, path, workspaceFrom)
])
diffDrawer?.setDiff({
mode: 'simple',
original: values?.[0] as any,
current: values?.[1] as any,
title: `${workspaceFrom} <> ${workspaceTo}`
})
}
}
// All *diff* items selected. Trigger items are opt-in and don't count
// toward "all selected" — see item merge below in deployableItems.
// A row whose deploy deletes in the target takes an explicit tick (see
// `diffRemovesInTarget`); no bulk action may sweep one in.
function removesInTarget(diff: WorkspaceItemDiff): boolean {
return diffRemovesInTarget(diff, mergeIntoParent)
}
let bulkSelectableDiffs = $derived(selectableDiffs.filter((d) => !removesInTarget(d)))
let allSelected = $derived(
bulkSelectableDiffs.length > 0 &&
bulkSelectableDiffs.every((d) => selectedItems.includes(getItemKey(d)))
)
async function selectAll() {
const bulk = bulkSelectableDiffs
.map((d) => getItemKey(d))
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
// Adds to the selection rather than replacing it, so a removal row the user
// opted into individually survives — the group checkbox, which only toggles
// its own rows, already behaves that way. Restricted to rows still in the
// list: a key pruned by a recompute would otherwise be counted by the deploy
// button and then reported as undeployable.
const listed = new Set(selectableDiffs.map((d) => getItemKey(d)))
const keptOptIns = selectedItems.filter((k) => !bulk.includes(k) && listed.has(k))
selectedItems = [...bulk, ...keptOptIns]
}
function deselectAll() {
selectedItems = []
}
const deploymentStatus: Record<
string,
{ status: 'loading' | 'deployed' | 'failed'; error?: string }
> = $state({})
function getWorkspacedKey(workspace: string, key: string): string {
return `${workspace}/${key}`
}
async function deploy(
kind: Kind,
path: string,
workspaceToDeployTo: string,
workspaceFrom: string,
statusKey: string
) {
deploymentStatus[statusKey] = { status: 'loading' }
// The workspace this deploy reads from doesn't have the item: archive/delete
// it in the target instead of copying. Same predicate as the row's badge and
// its exclusion from bulk selection, so what the row says is what it does.
const diff = comparison?.diffs.find((d) => getItemKey(d) === statusKey)
const removes = diff ? removesInTarget(diff) : false
let result: DeployResult
if (removes) {
result = await deleteItemInWorkspace(kind, path, workspaceToDeployTo)
} else {
result = await deployItem({
kind,
path,
workspaceFrom,
workspaceTo: workspaceToDeployTo,
onBehalfOf: getOnBehalfOfForDeploy(statusKey, kind),
onBehalfOfPrincipal: getOnBehalfOfPermissionedAsForDeploy(statusKey, kind)
})
}
if (result.success) {
deploymentStatus[statusKey] = { status: 'deployed' }
} else {
deploymentStatus[statusKey] = { status: 'failed', error: result.error }
sendUserToast(`Failed to deploy ${statusKey}: ${result.error}`, 'error')
}
}
let deploymentErrorMessage = $state('')
let allowBehindChangesOverride = $state(false)
async function isComparisonUpToDate(): Promise<boolean> {
if (!comparison) {
return false
}
try {
const result = await WorkspaceService.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: currentWorkspaceId
})
const nonDeployedChanges = comparison.diffs.filter(
(e) => !(deploymentStatus[getItemKey(e)]?.status == 'deployed')
)
if (!deepEqual(nonDeployedChanges, result.diffs)) {
deploymentErrorMessage = `New changes detected. Please reload the page and review the new changes before ${mergeIntoParent ? 'deploying' : 'updating'}`
return false
}
} catch (e) {
deploymentErrorMessage = `Failed to check for new changes before deployment: ${e}`
console.error('Failed to compare workspaces:', e)
return false
}
return true
}
async function deployChanges() {
deploying = true
if (!(await isComparisonUpToDate())) {
deploying = false
return
}
const parent = parentWorkspaceId
const current = currentWorkspaceId
const sortedItems = [...selectedItems].sort((a, b) => {
const aIsFolder = a.startsWith('folder:')
const bIsFolder = b.startsWith('folder:')
if (aIsFolder && !bIsFolder) return -1
if (!aIsFolder && bIsFolder) return 1
return 0
})
const to = mergeIntoParent ? parent : current
let anyFailed = false
// Datatables whose migrations deployed cleanly — candidates for a run prompt.
const deployedMigrationDatatables = new Set<string>()
const applyAll = async () => {
for (const itemKey of sortedItems) {
const deployable = deployableItems.find((d) => d.key === itemKey)
if (!deployable) {
sendUserToast(`Undeployable item: ${itemKey}`, true)
continue
}
const from = mergeIntoParent ? current : parent
await deploy(deployable.kind, deployable.path, to, from, itemKey)
if (deploymentStatus[itemKey]?.status === 'failed') {
anyFailed = true
} else if (deployable.kind === 'datatable_migration') {
deployedMigrationDatatables.add(deployable.path.split('/')[0])
}
}
}
// Updating the fork copies the parent's state in, so the tally must not read
// the result as the fork authoring anything. Merging the other way is the
// opposite: someone chose those changes for the target, and a fork chain
// needs that to stay authored so it can be merged on again.
await (mergeIntoParent ? applyAll() : asSyncDeploy(applyAll))
deploying = false
deselectAll()
await maybePromptRunMigrations(deployedMigrationDatatables, to)
// If every selected item deployed cleanly and the direction was
// merge-into-parent, resolve any open deployment request for this fork.
// A deploy to an arbitrary target says nothing about a request that tracks
// the lineage parent, so it must not close one.
if (!anyFailed && mergeIntoParent && !isArbitraryTarget) {
try {
const open = await WorkspaceService.getOpenDeploymentRequest({
workspace: currentWorkspaceId
})
if (open) {
if (comparison?.all_ahead_items_visible) {
await WorkspaceService.closeDeploymentRequestMerged({
workspace: currentWorkspaceId,
id: open.id
})
deploymentRequestPanel?.refresh()
} else {
// Hidden ahead changes remain: those items are excluded from the
// list and stay undeployed, so this deploy is only partial. Closing
// the request as "merged" (which marks its comments obsolete and
// notifies requester/assignees of a merge) would be a lie — leave it
// open so someone with full access can finish it.
sendUserToast(
'Deployed the changes visible to you. The deployment request stays open because some ahead changes are hidden from you and were not deployed.'
)
}
}
} catch (e) {
console.error('Failed to resolve open deployment request after merge', e)
}
}
// Deployed items are now in sync and should drop off the comparison; ask
// the page to re-fetch so the list and toggle badges reflect the new state.
onChanged?.()
}
/**
* After a deploy, offer to run the migrations of every cloned datatable that
* received one. `forked_from` is set on the fork's datatable config only when
* the datatable was cloned into a separate database — shared-DB datatables
* have already had the schema change applied and must not be re-run.
*/
async function maybePromptRunMigrations(
deployedMigrationDatatables: Set<string>,
runTargetWorkspace: string
) {
if (deployedMigrationDatatables.size === 0) return
try {
const forkSettings = await WorkspaceService.getPublicSettings({
workspace: currentWorkspaceId
})
const datatables = forkSettings.datatable?.datatables ?? {}
const cloned = [...deployedMigrationDatatables].filter(
(dt) => datatables[dt]?.forked_from != null
)
if (cloned.length === 0) return
runMigrationsDatatables = cloned.sort()
runMigrationsTargetWorkspace = runTargetWorkspace
runMigrationsModalOpen = true
} catch (e) {
console.error('Failed to determine cloned datatables for migration run prompt', e)
}
}
async function runDeployedMigrations() {
runMigrationsModalOpen = false
for (const dt of runMigrationsDatatables) {
try {
const res = await WorkspaceService.runDatatableMigrations({
workspace: runMigrationsTargetWorkspace,
datatableName: dt
})
sendUserToast(
`Ran ${res.applied.length} migration${res.applied.length !== 1 ? 's' : ''} on ${dt}`
)
} catch (e: any) {
sendUserToast(`Failed to run migrations on ${dt}: ${e.body ?? e.message ?? e}`, true)
}
}
}
function toggleKey(key: string) {
if (selectedItems.includes(key)) {
selectedItems = selectedItems.filter((i) => i !== key)
} else {
selectedItems.push(key)
}
}
function selectDefault() {
// Triggers and schedules are opt-in: they often share runtime state with the
// parent (kafka group_id, postgres replication slot, schedule firing time)
// and pushing them by default would surprise users running a routine "Deploy
// to parent" flow. The user picks them à la carte by clicking the row.
// Items with a pending draft are also left out by default: the deployed
// version (not the draft) is what moves, so we make the user opt in.
// The update direction (parent→fork) is never something the chat caused, so
// when scoped to a chat's items (chatMask set) preselect nothing there.
if (chatMask && !mergeIntoParent) {
selectedItems = []
return
}
const filtered = bulkSelectableDiffs.filter(
(d) => !isTriggerOrScheduleKind(d.kind) && !hasDraft(d)
)
// The update direction leaves out two ambiguous shapes, both still one click
// away through "Select all": a conflict, and a parent-only row the fork has
// deploy events for — the fork may have dropped it on purpose, and a routine
// update must not silently bring it back.
const conflictSafe = mergeIntoParent
? filtered
: filtered.filter(
(d) => !(d.ahead > 0 && d.behind > 0) && !(d.ahead > 0 && diffCreatesInTarget(d, false))
)
// When reached from a session's Review, narrow the default to this chat's items.
const scoped = chatMask ? conflictSafe.filter((d) => diffInMask(d, chatMask)) : conflictSafe
selectedItems = scoped
.map((d) => getItemKey(d))
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
}
function toggleDeploymentDirection(v: string) {
deselectAll()
mergeIntoParent = v == 'deploy_to'
selectDefault()
}
// Merged toggle: deploy_to/update flip the direction in place; draft asks the
// page to swap us out for CompareDrafts. Either way report it up so the page
// remembers the chosen direction across mode switches.
function onToggleMode(v: CompareMode) {
onModeSelected?.(v)
if (v === 'draft') return
toggleDeploymentDirection(v)
}
// Fetch user permissions for both workspaces
$effect(() => {
;[currentWorkspaceId, parentWorkspaceId]
async function fetchPermissions() {
try {
const parentUser = await UserService.whoami({ workspace: parentWorkspaceId })
canPreserveInParent =
parentUser.is_admin || parentUser.groups?.includes('wm_deployers') || false
} catch {
canPreserveInParent = false
}
try {
const currentUser = await UserService.whoami({ workspace: currentWorkspaceId })
canPreserveInCurrent =
currentUser.is_admin || currentUser.groups?.includes('wm_deployers') || false
} catch {
canPreserveInCurrent = false
}
}
fetchPermissions()
})
// Can the user actually deploy into the target workspace? Fills the frontend
// gap for the `RestrictDeployToDeployers` rule (+ operator), shared with the
// session review drawer via the same checkDeployPermission util. Cached per
// workspace; `deployPerm` tracks whichever side the current direction targets.
let deployPerms = $state<Record<string, DeployPermission>>({})
const deployPermFetched = new Set<string>()
$effect(() => {
for (const ws of [currentWorkspaceId, parentWorkspaceId]) {
if (!ws || deployPermFetched.has(ws)) continue
deployPermFetched.add(ws)
void checkDeployPermission(ws).then((p) => (deployPerms = { ...deployPerms, [ws]: p }))
}
})
let deployPerm = $derived(deployPerms[deployTargetWorkspace] ?? { ok: true })
// Fetch summaries and on_behalf_of_email when comparison data loads
$effect(() => {
if (comparison?.diffs) {
fetchSummaries(comparison.diffs)
fetchOnBehalfOfInfo(comparison.diffs)
}
})
// Auto-select items on initial load
$effect(() => {
if (comparison?.diffs && !hasAutoSelected && chatMaskReady && selectableDiffs.length > 0) {
selectDefault()
hasAutoSelected = true
}
})
// A row can change meaning between two comparisons: an item selected while it
// merely differed, then deleted here, comes back as "deploying this removes it
// there". A tick that predates the flip never chose that, so it is revoked —
// but only for a row that just flipped. Clearing every destructive row instead
// would also revoke a deliberate opt-in on any refresh, and refreshes happen
// without a recompute (`onChanged` fires from the per-row workspace-specific
// button), silently dropping the one choice this view asks the user to make.
let removalKeys = new Set<string>()
$effect(() => {
const diffs = comparison?.diffs
// Removals are direction-dependent, so a flip must refresh the set: keeping the
// other direction's would make every row of the new one look freshly flipped
// and revoke the opt-in this guard protects.
;[mergeIntoParent]
if (!diffs) return
untrack(() => {
const current = new Set(diffs.filter(removesInTarget).map((d) => getItemKey(d)))
const flipped = [...current].filter((k) => !removalKeys.has(k))
removalKeys = current
if (flipped.some((k) => selectedItems.includes(k))) {
selectedItems = selectedItems.filter((k) => !flipped.includes(k))
}
})
})
// Reset override when selection or direction changes
$effect(() => {
;[selectedItems, mergeIntoParent]
allowBehindChangesOverride = false
})
// Trigger and schedule rows now flow through `comparison.diffs` like every
// other deployable kind — the backend's `compareWorkspaces` populates them
// from `workspace_diff`, with runtime fields ignored by `compare_two_*`.
// Rows are limited to the active direction (conflicts are ahead AND behind,
// so they show in both): an opposite-direction-only row would render as an
// unexplained disabled line. The other direction stays visible through the
// toggle badge counts and the behind/hidden alerts.
let deployableItems = $derived(
selectableDiffs.map((diff) => ({
key: getItemKey(diff),
path: diff.path,
kind: diff.kind as Kind,
diff
}))
)
let ciTestResults = $state<Record<string, CiTestResult[]>>({})
async function fetchCiTests() {
if (!comparison?.diffs || !currentWorkspaceId) return
const items = comparison.diffs
.filter((d) => d.kind === 'script' || d.kind === 'flow' || d.kind === 'resource')
.map((d) => ({ path: d.path, kind: d.kind as 'script' | 'flow' | 'resource' }))
if (items.length === 0) return
try {
ciTestResults = await ScriptService.getCiTestResultsBatch({
workspace: currentWorkspaceId,
requestBody: { items }
})
} catch (e) {
console.error('Failed to fetch CI test results:', e)
}
}
$effect(() => {
if (comparison && comparison.diffs.length > 0) {
fetchCiTests()
}
})
function getCiTestStatus(diff: WorkspaceItemDiff): 'pass' | 'fail' | 'running' | null {
const key = `${diff.kind}:${diff.path}`
const results = ciTestResults[key]
if (!results || results.length === 0) return null
if (results.some((r) => r.status === 'failure' || r.status === 'canceled')) return 'fail'
if (results.some((r) => r.status === 'running' || (r.job_id && !r.status))) return 'running'
if (results.every((r) => r.status === 'success')) return 'pass'
return null
}
// Deduplicated list of all CI tests across all items
let allCiTests = $derived.by(() => {
const seen = new Map<string, CiTestResult>()
for (const results of Object.values(ciTestResults)) {
for (const r of results) {
const existing = seen.get(r.test_script_path)
if (
!existing ||
(r.started_at && (!existing.started_at || r.started_at > existing.started_at))
) {
seen.set(r.test_script_path, r)
}
}
}
return [...seen.values()]
})
let deploymentRequestPanel: DeploymentRequestPanel | undefined = $state(undefined)
let hasOpenDeploymentRequest = $state(false)
// After deploying datatable migrations to a cloned (separate-DB) datatable, we
// offer to run them in the target workspace. Shared-DB datatables are skipped:
// the schema change is already physically applied, so re-running is redundant.
let runMigrationsModalOpen = $state(false)
let runMigrationsDatatables = $state<string[]>([])
let runMigrationsTargetWorkspace = $state('')
let runMigrationsTargetWorkspaceName = $derived(
$userWorkspaces.find((w) => w.id == runMigrationsTargetWorkspace)?.name ??
runMigrationsTargetWorkspace
)
/** Display labels for trigger/schedule kinds in the merge UI. */
const KIND_DISPLAY_NAMES: Record<string, string> = {
schedule: 'Schedule',
http_trigger: 'HTTP route',
websocket_trigger: 'Websocket trigger',
kafka_trigger: 'Kafka trigger',
nats_trigger: 'NATS trigger',
postgres_trigger: 'Postgres trigger',
mqtt_trigger: 'MQTT trigger',
amqp_trigger: 'AMQP trigger',
sqs_trigger: 'SQS trigger',
gcp_trigger: 'GCP trigger',
azure_trigger: 'Azure trigger',
email_trigger: 'Email trigger',
datatable_migration: 'Data table migration'
}
// Human label for a diff kind, lowercased for inline use in the hidden-items
// summary ("2 scripts, 1 http route").
function hiddenKindLabel(kind: string): string {
const base: Record<string, string> = {
script: 'script',
flow: 'flow',
app: 'app',
raw_app: 'app',
resource: 'resource',
variable: 'variable',
resource_type: 'resource type',
folder: 'folder'
}
return base[kind] ?? KIND_DISPLAY_NAMES[kind]?.toLowerCase() ?? kind
}
function formatHiddenByKind(byKind: Record<string, number>): string {
return Object.entries(byKind)
.map(([kind, n]) => `${n} ${hiddenKindLabel(kind)}${n !== 1 ? 's' : ''}`)
.join(', ')
}
</script>
{#if $workspaceStore != currentWorkspaceId}
<Alert title="Targetting different workspace" type="info" class="my-2">
<span>
You are currently seeing the deployement page of workspace <b
>{currentWorkspaceInfo?.name} ({currentWorkspaceInfo?.id})</b
> which is not your currently selected workspace.
</span>
<a href="/">Click here to go home ({$workspaceStore})</a>
</Alert>
{/if}
{#if comparisonError && !comparison}
<!-- Nothing to compare, so the merge card can't render — but the picker has to
stay reachable: a target the caller can't compare is chosen from the picker
itself, and without a way back the page would be a dead end. -->
<div class="flex flex-col gap-3 items-start">
<Alert title="Could not compare with {parentWorkspaceId}" type="error" class="my-2 w-full">
{comparisonError}
</Alert>
<div class="flex flex-wrap gap-2 items-center">
{#if onRetry}
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: RefreshCw }}
disabled={scanning}
onClick={() => onRetry?.()}
>
Retry
</Button>
{/if}
{#if onSelectTarget}
<CompareTargetPicker
{currentWorkspaceId}
targetWorkspaceId={parentWorkspaceId}
parentWorkspaceId={lineageParentId}
onSelected={onSelectTarget}
/>
{/if}
</div>
</div>
{:else if comparison}
{@const selectedConflicts = conflictingDiffs.filter((e) =>
selectedItems.includes(getItemKey(e))
).length}
<div class="flex flex-col gap-4">
<div class="bg-surface-tertiary p-4 rounded-md border">
<WorkspaceDeployLayout
items={deployableItems}
{selectedItems}
{deploymentStatus}
{allSelected}
bulkExcluded={(item) => removesInTarget(item.diff as WorkspaceItemDiff)}
onToggleItem={(item) => toggleKey(item.key)}
onSelectAll={selectAll}
onDeselectAll={deselectAll}
emptyMessage={emptyDeployMessage}
>
{#snippet header()}
<div class="flex items-center justify-between bg-surface-tertiary">
<div class="flex flex-col gap-2 w-full pb-4">
<div class="flex flex-wrap gap-1 items-center">
<CompareModeToggle
selected={mergeIntoParent ? 'deploy_to' : 'update'}
isFork={true}
oneWay={isArbitraryTarget}
{parentWorkspaceId}
{deployCount}
{updateCount}
{draftCount}
disabled={deploying}
onSelected={onToggleMode}
/>
{#if currentWorkspaceInfo && parentWorkspaceInfo}
<div class="flex-1 flex gap-1 items-center">
<Badge
color="transparent"
class="ml-5 font-semibold"
title={mergeIntoParent ? currentWorkspaceInfo.name : parentWorkspaceInfo.name}
>
<span class="text-secondary">merge:</span>
{#if mergeIntoParent}
<GitFork size={14} />
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
{:else}
<Building size={14} />
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
{/if}
</Badge>
<ArrowRight size={16} />
<!-- The destination doubles as the way to change it: retargeting is
rare enough not to earn a control of its own, and the badge already
names the thing being picked. Only in the deploy direction — the
"into" of an update is this workspace, which isn't a choice. -->
{#if mergeIntoParent && onSelectTarget}
<CompareTargetPicker
{currentWorkspaceId}
targetWorkspaceId={parentWorkspaceId}
parentWorkspaceId={lineageParentId}
disabled={deploying || scanning}
onSelected={onSelectTarget}
>
{#snippet triggerContent()}
<!-- The same Badge as the `merge:` side, so the pair reads as one
statement; `clickable` is the only difference, and the chevron
says the destination is the part that can change. -->
<Badge
color="transparent"
clickable
class="font-semibold"
title="Deploying into {parentWorkspaceInfo.name} — click to compare against another workspace"
>
<span class="text-secondary">into:</span>
<Building size={14} />
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
<ChevronDown size={12} class="text-tertiary" />
</Badge>
{/snippet}
</CompareTargetPicker>
{:else}
<Badge
color="transparent"
class="font-semibold"
title={!mergeIntoParent
? currentWorkspaceInfo.name
: parentWorkspaceInfo.name}
>
<span class="text-secondary">into:</span>
{#if !mergeIntoParent}
<GitFork size={14} />
<span class="text-emphasis">{currentWorkspaceInfo.id}</span>
{:else}
<Building size={14} />
<span class="text-emphasis">{parentWorkspaceInfo.id}</span>
{/if}
</Badge>
{/if}
</div>
{/if}
<div class="flex items-center gap-2 text-sm">
<Badge color="transparent">
{selectableDiffs.length}
{mergeIntoParent ? 'deployable' : 'updateable'}
</Badge>
{#if conflictingDiffs.length > 0}
<Badge color="orange">
<AlertTriangle class="w-3 h-3 inline mr-1" />
{conflictingDiffs.length} conflicts
</Badge>
{/if}
</div>
</div>
<!-- Retargeting lives on the destination badge above. Only the scan
freshness an arbitrary target depends on is surfaced here; the
lineage comparison has a continuous tally and needs neither. -->
{#if isArbitraryTarget && fullScanAt}
<div class="flex flex-wrap gap-2 items-center">
<span class="text-2xs text-tertiary">
Diff computed {displayDate(fullScanAt)}
</span>
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: RefreshCw }}
disabled={deploying || scanning}
title="Re-enumerate every item on both sides. Needed to pick up anything created or changed since the last scan."
onClick={() => onScan?.()}
>
{scanning ? 'Computing…' : 'Recompute'}
</Button>
</div>
{/if}
</div>
</div>
{/snippet}
{#snippet groupActions(groupItems)}
<!-- Conflicts stay visible at the folder level so a group can be
(de)selected without scanning every row for orange badges. -->
{@const groupConflicts = groupItems.filter((i) => {
const d = i.diff as WorkspaceItemDiff
return d.ahead > 0 && d.behind > 0
}).length}
{#if groupConflicts > 0}
<Badge color="orange" size="xs">
<AlertTriangle class="w-3 h-3 inline mr-0.5" />
{groupConflicts} conflict{groupConflicts !== 1 ? 's' : ''}
</Badge>
{/if}
{/snippet}
{#if allCiTests.length > 0}
<div class="flex flex-col gap-1.5 mt-3 p-3 border bg-surface-secondary rounded text-xs">
<div class="flex items-center gap-1.5 font-semibold text-secondary">
<FlaskConical size={14} />
CI tests
</div>
<div class="flex flex-col gap-1">
{#each allCiTests as test (test.test_script_path)}
<div class="flex items-center gap-2">
{#if test.status === 'success'}
<CircleCheck size={14} class="text-green-600" />
{:else if test.status === 'failure' || test.status === 'canceled'}
<CircleX size={14} class="text-red-600" />
{:else if test.status === 'running'}
<Loader2 size={14} class="text-yellow-600 animate-spin" />
{:else}
<div class="w-3.5 h-3.5 rounded-full border border-tertiary"></div>
{/if}
<span class="text-secondary">{test.test_script_path}</span>
{#if test.job_id}
<a
href="{base}/run/{test.job_id}?workspace={currentWorkspaceId}"
class="text-tertiary hover:text-secondary text-2xs"
>
view run
</a>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{#snippet alerts()}
{#if comparisonError}
<!-- A refresh failed over a comparison that did load: the list below is
still on screen, so flag it as possibly out of date. This branch also
covers a comparison that failed right after a scan, where it shadows
the scan prompt below — hence Retry, which is the only action left
(the candidates are already seeded; only the comparison must re-run). -->
<Alert
title="Could not refresh the comparison with {parentWorkspaceId}"
type="error"
class="my-2"
>
<div class="flex flex-col gap-2 items-start">
<span>{comparisonError}</span>
{#if onRetry}
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: RefreshCw }}
disabled={scanning}
onClick={() => onRetry?.()}
>
Retry
</Button>
{/if}
</div>
</Alert>
{:else if awaitingScan}
<Alert title="Compare with {parentWorkspaceId}" type="info" class="my-2">
<div class="flex flex-col gap-2 items-start">
<span>
{parentWorkspaceId} is not in this workspace's lineage, so their differences aren't
tracked as you deploy. Computing the diff compares every item on both sides — it can
take a while on large workspaces, and the result is kept for later visits.
</span>
<Button
variant="accent"
unifiedSize="xs"
disabled={scanning}
startIcon={{
icon: scanning ? Loader2 : DiffIcon,
classes: scanning ? 'animate-spin' : undefined
}}
onClick={() => onScan?.()}
>
{scanning ? 'Computing diff…' : 'Compute diff'}
</Button>
</div>
</Alert>
{/if}
{#if isArbitraryTarget && !awaitingScan}
<Alert title="One-way deploy outside the lineage" type="warning" size="xs" class="my-2">
Items are compared as of the last scan, and can only be sent from this workspace into
{parentWorkspaceId}. Anything created or changed on either side since is missing from
the list until you recompute.
</Alert>
{/if}
{#if draftCount > 0}
<Alert title="Undeployed drafts" type="warning" size="xs" class="my-2">
<div class="flex items-center gap-2 flex-wrap">
<span>
{#if mergeIntoParent}
This workspace has {draftCount} undeployed draft{draftCount !== 1 ? 's' : ''}.
Only deployed versions in this fork can be sent to {parentWorkspaceId} — deploy
{draftCount !== 1 ? 'them' : 'it'} first, otherwise those changes won't be included.
{:else}
This workspace has {draftCount} undeployed draft{draftCount !== 1 ? 's' : ''}.
{/if}
</span>
<Button variant="subtle" unifiedSize="xs" onclick={() => onModeSelected?.('draft')}>
See drafts
</Button>
</div>
</Alert>
{/if}
{#if mergeIntoParent}
<ParentWorkspaceProtectionAlert
{parentWorkspaceId}
onUpdateCanDeploy={(canDeploy) => {
canDeployToParent = canDeploy
}}
/>
{/if}
{#if conflictingDiffs.length > 0}
<Alert title="Conflicting changes detected" type="warning" class="mt-2">
<span>
{conflictingDiffs.length} item{conflictingDiffs.length !== 1 ? 's have' : ' has'} conflicting
changes, it was modified on the original workspace while changes were made on this fork.
Make sure to resolve these before merging.
</span>
</Alert>
{/if}
{#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)}
<Alert
title="This fork is behind {parentWorkspaceId} and needs to be up to date before deploying"
type="warning"
class="my-2"
>
You have items behind '{parentWorkspaceId}'. You need to update and test your changes
before being able to deploy.
<span class="font-medium flex flex-row gap-1 text-red-500">
<input
type="checkbox"
bind:checked={allowBehindChangesOverride}
class="rounded max-w-4"
/>
Override: Deploy despite {itemsWithBehindChanges.length} outdated item{itemsWithBehindChanges.length !==
1
? 's'
: ''}
</span>
</Alert>
{/if}
{@const hiddenDir = mergeIntoParent ? comparison.hidden_ahead : comparison.hidden_behind}
{#if hiddenDir.items.length > 0}
<!-- Caller is an admin of this side, so the dropped items are provably
stale/phantom diff rows (they can see every real item). List them with
paths — useful for debugging the "why is this fork ahead" question. -->
<Alert title="Hidden diff rows (visible to you as admin)" type="info" class="my-2">
{hiddenDir.items.length}
{mergeIntoParent ? 'ahead' : 'behind'} item{hiddenDir.items.length !== 1 ? 's' : ''}
{hiddenDir.items.length !== 1 ? 'are' : 'is'} excluded from the list below — they are not
resolvable as live items and are most likely stale/phantom diff rows:
<ul class="mt-1 font-mono text-2xs">
{#each hiddenDir.items as it}
<li>{hiddenKindLabel(it.kind)} · {it.path}</li>
{/each}
</ul>
</Alert>
{:else if mergeIntoParent ? !comparison.all_ahead_items_visible : !comparison.all_behind_items_visible}
<Alert title="Some changes are hidden from you" type="warning" class="my-2">
{hiddenDir.total}
{mergeIntoParent ? 'ahead' : 'behind'} item{hiddenDir.total !== 1 ? 's' : ''}
({formatHiddenByKind(hiddenDir.by_kind)})
{hiddenDir.total !== 1 ? 'are' : 'is'} not visible to your user and
{hiddenDir.total !== 1 ? 'are' : 'is'} excluded from the list below. You can still
{mergeIntoParent ? 'deploy' : 'update'} the items you can see — share this page with someone
who has full access to include the rest.
</Alert>
{/if}
{/snippet}
{#snippet itemSummary(item)}
{@const diff = item.diff as WorkspaceItemDiff}
{@const key = item.key}
<!-- Point the edit link at the workspace the item actually lives in:
a parent-only row (deleted/absent in the fork) would 404 if linked
into the fork, so link it into the parent instead. -->
{@const editUrl = editUrlFor(
diff,
diff.exists_in_fork ? currentWorkspaceId : parentWorkspaceId
)}
{#if isTriggerOrScheduleKind(diff.kind)}
<span class="text-emphasis">
{KIND_DISPLAY_NAMES[diff.kind as string] ?? diff.kind}
</span>
<span class="text-tertiary mx-1">&rarr;</span>
<span class="text-secondary">{diff.path}</span>
{:else}
{@const oldSummary = mergeIntoParent
? summaryCache[key]?.parent
: summaryCache[key]?.current}
{@const newSummary = mergeIntoParent
? summaryCache[key]?.current
: summaryCache[key]?.parent}
{@const existsInBothWorkspaces = !(
(diff.exists_in_fork && !diff.exists_in_source) ||
(!diff.exists_in_fork && diff.exists_in_source)
)}
<WorkspaceDeployItemSummary
path={diff.path}
{editUrl}
{oldSummary}
{newSummary}
renamed={oldSummary != newSummary && existsInBothWorkspaces}
/>
{/if}
{/snippet}
{#snippet itemActions(item)}
{@const diff = item.diff as WorkspaceItemDiff}
{@const key = item.key}
{@const targetOnBehalfOf = getTargetOnBehalfOf(key)}
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
{@const existsInBothWorkspaces = !(
(diff.exists_in_fork && !diff.exists_in_source) ||
(!diff.exists_in_fork && diff.exists_in_source)
)}
<!-- On-behalf-of selector -->
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
<OnBehalfOfSelector
targetWorkspace={deployTargetWorkspace}
targetValue={targetOnBehalfOf}
selected={onBehalfOfChoice[key]}
onSelect={(choice, details) => {
onBehalfOfChoice[key] = choice
if (details) customOnBehalfOf[key] = details
}}
kind={diff.kind}
canPreserve={canPreserveOnBehalfOf}
customValue={customOnBehalfOf[key]?.permissionedAs}
/>
{/if}
{#if diff.kind === 'raw_app'}
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
{#if hasDraft(diff)}
<!-- This deployed fork item also has a pending draft. Deploying/updating
moves the deployed version, not the draft — so we warn (yellow, ahead
of the New/status badges) and leave it out of the default selection
(see selectDefault). -->
<Badge
title={mergeIntoParent
? 'This item has a draft — deploying sends the deployed version, not the draft.'
: 'This item has a draft — updating replaces the deployed version your draft is based on.'}
color="yellow"
size="xs"
>
<AlertTriangle class="w-3 h-3 inline mr-0.5" />+Draft
</Badge>
{/if}
<!-- Status badges. An item on one side only states the effect of
deploying it; the row is offered at all only when the missing side
recorded dropping it (see `diffForkDroppedItem`). -->
{#if diffCreatesInTarget(diff, mergeIntoParent)}
<Badge
title="This item exists in '{deploySourceWorkspace}' but not in '{deployTargetWorkspace}' — deploying it creates it there"
color="indigo"
size="xs">New</Badge
>
{:else if removesInTarget(diff)}
{@const renamedAway = mergeIntoParent && diff.fork_last_event_kind === 'rename_from'}
<Badge
title={renamedAway
? `'${deploySourceWorkspace}' renamed this item to another path — deploying it removes the old path in '${deployTargetWorkspace}'`
: `This item exists in '${deployTargetWorkspace}' but not in '${deploySourceWorkspace}' — deploying it removes it there`}
color="red"
size="xs">Removes in {deployTargetWorkspace}</Badge
>
{/if}
{@const ciStatus = getCiTestStatus(diff)}
{#if ciStatus === 'pass'}
<Badge color="green" size="xs"><CircleCheck size={10} class="mr-0.5" />CI pass</Badge>
{:else if ciStatus === 'fail'}
<Badge color="red" size="xs"><CircleX size={10} class="mr-0.5" />CI fail</Badge>
{:else if ciStatus === 'running'}
<Badge color="yellow" size="xs"
><Loader2 size={10} class="mr-0.5 animate-spin" />CI</Badge
>
{/if}
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
<div class="flex items-center gap-2">
{#if isConflict || existsInBothWorkspaces}
{#if diff.ahead > 0}
<Badge color="green" size="xs">
<ArrowUpRight class="w-3 h-3 inline" />
{diff.ahead} ahead
</Badge>
{/if}
{#if diff.behind > 0}
<Badge color="blue" size="xs">
<ArrowDownRight class="w-3 h-3 inline" />
{diff.behind} behind
</Badge>
{/if}
{#if isConflict}
<Badge color="orange" size="xs">
<AlertTriangle class="w-3 h-3 inline" />
Conflict
</Badge>
{/if}
{/if}
</div>
<div class:invisible={!existsInBothWorkspaces}>
<Button
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: DiffIcon }}
onClick={() => showDiff(diff.kind as Kind, diff.path)}
>
Show diff
</Button>
</div>
{#if diff.kind === 'resource' || diff.kind === 'variable'}
{#if isPinned(diff.kind, diff.path)}
<Badge
color="gray"
size="xs"
title="Kept per environment, so it's excluded from the diff. Seed a missing copy or revert it in the Workspace-specific items list below."
>
workspace-specific
</Badge>
{:else}
<Button
unifiedSize="xs"
variant="subtle"
disabled={pinBusy[key]}
title="Keep this item's value per environment (excluded from the diff). Seed a missing copy from the Workspace-specific items list."
onClick={() => setPinned(diff.kind as 'resource' | 'variable', diff.path, true)}
>
Make workspace specific
</Button>
{/if}
{/if}
{/if}
{/snippet}
{#snippet footer()}
{#if !nothingToAct}
<div class="flex items-center justify-between">
<div></div>
<div class="flex flex-col items-end gap-2">
<!-- Always show the deploy footer: only the items visible to the user are
listed and selectable, so a partial-visibility user deploys the subset they
can see. The hidden items are surfaced by the non-blocking banner above,
not by removing the action. -->
<div class="flex items-center gap-2">
{#if mergeIntoParent && !isArbitraryTarget && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
<Button
variant="default"
startIcon={{ icon: UserPlus }}
on:click={() => deploymentRequestPanel?.openRequestDialog()}
>
Request deployment
</Button>
{/if}
<Button
variant="accent"
disabled={selectedItems.length === 0 ||
deploying ||
(hasBehindChanges && !allowBehindChangesOverride) ||
(mergeIntoParent && !canDeployToParent) ||
!deployPerm.ok ||
hasUnselectedOnBehalfOf}
title={!deployPerm.ok ? deployPerm.reason : undefined}
loading={deploying}
on:click={requestDeploy}
>
{mergeIntoParent ? 'Deploy' : 'Update'}
{selectedItems.length} Item{selectedItems.length !== 1 ? 's' : ''}
{#if selectedConflicts != 0}
({selectedConflicts} conflicts)
{/if}
</Button>
</div>
{#if !deployPerm.ok}
<span class="text-xs text-yellow-600">{deployPerm.reason}</span>
{/if}
{#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf}
<span class="text-xs text-yellow-600">
You must set the "on behalf of" user for all items before deploying
<Tooltip class="text-yellow-600">
The "run on behalf of" field defines which user's permissions will be applied
during execution. Make sure this is set to an appropriate user before
deploying.
</Tooltip>
</span>
{/if}
{#if deploymentErrorMessage != ''}
<Alert
title="Cannot {mergeIntoParent ? 'deploy these changes' : 'update these items'}"
type="error"
class="my-2 max-w-80"
>
<span>
{deploymentErrorMessage}
</span>
</Alert>
{/if}
</div>
</div>
{/if}
{/snippet}
</WorkspaceDeployLayout>
<!-- A deployment request is anchored on the fork's lineage: it tracks
changes headed for the parent. It has no meaning against an arbitrary
target, whose diff isn't tracked at all. -->
{#if !isArbitraryTarget}
<DeploymentRequestPanel
bind:this={deploymentRequestPanel}
forkWorkspaceId={currentWorkspaceId}
{parentWorkspaceId}
currentUsername={$userStore?.username ?? ''}
isAdmin={$userStore?.is_admin ?? false}
onStateChange={(open) => {
hasOpenDeploymentRequest = open
}}
/>
{/if}
</div>
<!-- Scoped to datatables cloned on fork: their `forked_from` baseline is the
parent's schema, so diffing them against anything else is meaningless. -->
{#if !isArbitraryTarget}
<DatatableSchemaDiff {currentWorkspaceId} {parentWorkspaceId} />
{/if}
{#if pinnedItems.length > 0}
<div class="bg-surface-tertiary p-4 rounded-md border flex flex-col gap-2">
<div class="flex items-center gap-1.5 font-semibold text-secondary text-sm">
Workspace-specific items
</div>
<p class="text-xs text-tertiary">
These resources and variables keep their own value in each environment and are excluded
from the diff. An item that exists on only one side can be seeded onto the other with
"Create in …" (copies the current value, including secrets); it will never overwrite an
existing value.
</p>
<div class="flex flex-col gap-1">
{#each pinnedItems as it (`${it.item_kind}:${it.path}`)}
{@const missingSide =
it.onCurrent && !it.onParent
? parentWorkspaceId
: !it.onCurrent && it.onParent
? currentWorkspaceId
: undefined}
<div class="flex items-center gap-2 text-sm">
<Badge color="transparent" small>{it.item_kind}</Badge>
<span class="text-secondary font-mono text-xs flex-1 truncate">{it.path}</span>
{#if missingSide}
<Button
unifiedSize="xs"
variant="subtle"
disabled={pinBusy[`${it.item_kind}:${it.path}`]}
title="Copy this item's current value (including secrets) into {missingSide}"
onClick={() => (createConfirm = it)}
>
Create in {missingSide}
</Button>
{/if}
<Button
unifiedSize="xs"
variant="subtle"
disabled={pinBusy[`${it.item_kind}:${it.path}`]}
onClick={() => setPinned(it.item_kind as 'resource' | 'variable', it.path, false)}
>
Make shared
</Button>
</div>
{/each}
</div>
</div>
{/if}
</div>
<DiffDrawer bind:this={diffDrawer} {isFlow} />
<ConfirmationModal
open={draftConfirmOpen}
title={mergeIntoParent ? 'Deploy items with a draft?' : 'Update items with a draft?'}
confirmationText={mergeIntoParent ? 'Deploy anyway' : 'Update anyway'}
onConfirmed={() => {
draftConfirmOpen = false
deployChanges()
}}
onCanceled={() => (draftConfirmOpen = false)}
>
<div class="flex flex-col gap-2">
<p>
{selectedDraftKeys.length} selected item{selectedDraftKeys.length !== 1 ? 's' : ''}
{selectedDraftKeys.length !== 1 ? 'have' : 'has'} an undeployed draft.
{#if mergeIntoParent}
Deploying sends the deployed version, not the draft — those draft changes won't be
included.
{:else}
Updating replaces the deployed version your draft is based on.
{/if}
</p>
<ul class="list-disc pl-5 text-sm font-mono text-secondary">
{#each selectedDraftKeys as k (k)}
<li>{k.split(':').slice(1).join(':')}</li>
{/each}
</ul>
</div>
</ConfirmationModal>
<ConfirmationModal
open={runMigrationsModalOpen}
title="Run datatable migrations?"
confirmationText="Run migrations"
onConfirmed={runDeployedMigrations}
onCanceled={() => (runMigrationsModalOpen = false)}
>
<div class="flex flex-col gap-2">
<p>
Run the deployed migrations in <b>{runMigrationsTargetWorkspaceName}</b> now? These data tables
use a separate database, so the schema changes won't apply until the migrations are run.
</p>
<ul class="list-disc pl-5 text-sm font-mono text-secondary">
{#each runMigrationsDatatables as dt (dt)}
<li>{dt}</li>
{/each}
</ul>
</div>
</ConfirmationModal>
<ConfirmationModal
open={!!createConfirm}
title="Create in {createConfirm?.onCurrent ? parentWorkspaceId : currentWorkspaceId}?"
confirmationText="Create"
onConfirmed={() => {
const it = createConfirm
createConfirm = undefined
if (it) createOnRemote(it)
}}
onCanceled={() => (createConfirm = undefined)}
>
<p class="text-sm">
This copies the current value of <span class="font-mono">{createConfirm?.path}</span>
(including any secret value) from
<b>{createConfirm?.onCurrent ? currentWorkspaceId : parentWorkspaceId}</b>
into <b>{createConfirm?.onCurrent ? parentWorkspaceId : currentWorkspaceId}</b>. It stays
workspace-specific afterward, so later promotes won't overwrite it. If it already exists
there, it's left untouched and just marked workspace-specific.
</p>
</ConfirmationModal>
{:else}
<div class="flex items-center justify-center h-full">
<div class="text-gray-500">No comparison data available</div>
</div>
{/if}