Files
windmill/frontend/src/lib/components/CompareWorkspaces.svelte
T
e47aedac0a feat: add SQL migrations for data tables (#9693)
* feat: add datatable_migrations table

* feat: add route to run datatable migrations

* feat: sync datatable migrations as .up.sql/.down.sql files

* feat: add datatable migrate up/down commands and post-push run prompt

* feat: add datatable migrate new command to scaffold migrations

* feat: add datatable migrations management UI

* feat: prompt to create migration on DDL in datatable SQL editors

* feat: support running a single specific datatable migration

* feat: view migration content, run single migration, fix stacked modal

* feat: per-row revert button with out-of-order warning

* fix: avoid migrations list flicker on refresh after an action

* feat: generate initial datatable migration via pg_dump

* fix: surface datatable migration API error details in toasts

* fix: revert created migration if create-and-run fails to run

* fix: include postgres error detail in migration run/rollback failures

* feat: sync datatable migrations as files via the workspace export

* refactor: move datatable migrations to migrations/datatable/ path

* fix: drop redundant datatable_migration label in sync output

* fix: exclude datatable migration sql files from script metadata generation

* feat: run datatable migrations as user-permissioned labeled jobs

* feat: reject invalid datatable migrations on sync push

* feat: datatable migrate up/down default to all datatables, --datatable to target one

* fix: surface postgres error detail when datatable migrations fail to run

* chore: regenerate CLI docs for datatable migrate commands

* feat: default new datatable migration to a BEGIN/END transaction template

* fix: validate datatable migration name and datatable at the API boundary

* fix: ensure detected DDL ends with semicolon when wrapped in transaction

* fix: re-prompt instead of stripping DDL when new-migration modal is cancelled

* feat: refresh datatable schema after running a migration from the SQL REPL

* feat: record db manager DDL on data tables as migrations

* feat: make datatable migrations opt-in per data table

* fix: make migration view editor read-only so its code can scroll

* fix: don't re-prompt DDL guard when creating a migration without running

* feat: generate down migrations for db manager DDL (postgres)

* fix: correct down migration for db manager alters (no double-wrap, serial)

* feat: explain migrations purpose with a tooltip in the migrations modal

* compare paeg

* feat: add datatable_migration kind to workspace diff pipeline

* chore: point ee-repo-ref at datatable_migration git-sync companion

* fix: harden datatable migration version allocation and initial-migration bookkeeping, add tests

* feat: deploy and run datatable migrations on workspace merge

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

* Refactor + handle datatable setting delete/rename

* refactor: move datatable migration rename/delete cascade into module

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

* chore(windmill-utils-internal): bump to 1.7.1 for datatable migration deploy provider methods

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

* feat(db-manager): add Migrations button to top bar, make Refresh icon-only

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

* BEGIN/END placeholder in down migration

* feat: autofocus migration name input and flag it red when empty

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

* feat(datatable-migrations): allow non-admins to create/run/revert migrations, gate only opt in/out

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

* border nits

* refresh db manager schema on migrations

* BEGIN/END scaffold in CLI

* feat(cli): push local datatable migrations before running on migrate up

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

* feat: flag invalid migration name with red border, not just empty

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

* refactor: drop random slug from auto-generated migration names

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

* feat: offer revert-and-delete when deleting an installed migration

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

* feat: record fork merge as a migration when target datatable opts in

* nit

* clone migrations on fork

* windmill-utils-internal

* fix(datatable-migrations): serialize run/rollback with a per-db advisory lock

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

* fix(db-manager): fail closed when migrations-status check errors on DDL apply

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

* docs: fix generate_initial migration ordering comment to match code

* chore(datatable-migrations): remove unused update_datatable_migrations endpoint

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

* fix: run DDL migration guard on the script editor Test button

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

* split

* ee-repo-ref

* chore(frontend): sync package-lock with package.json (@emnapi deps)

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

* fix(datatable-migrations): never resolve instance credentials into migration job args

datatable_database_arg eagerly resolved instance data-table credentials
(including the shared instance-wide Postgres password) and passed them as the
migration job's plaintext `database` arg, landing in v2_job.args. Since the
run route has no admin gate, a non-admin could run a migration and read
args.database to recover the password, granting cross-workspace psql access to
all instance data-table DBs.

Pass a `datatable://<name>` reference for both resource-backed and instance
data tables instead; the pg executor already resolves it to real credentials
server-side at run time, so nothing sensitive is ever stored in the job args.

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

* nit

* fix: handle dollar-quoting and comments when splitting SQL statements

* feat: deploy datatable migrations on merge with explicit opt-in error

* fix(frontend): sync package-lock with npm 11 peer-dep resolution

npm ci failed with 'Missing: @emnapi/core@1.11.2 / @emnapi/runtime@1.11.2 from
lock file'. @napi-rs/wasm-runtime declares @emnapi/core|runtime ^1.7.1 as
peerDependencies while @rolldown/binding-wasm32-wasi pins them to exactly
1.10.0. Newer npm (bundled with node 24 in CI) installs the peer deps at the
highest match (1.11.2) alongside rolldown's nested 1.10.0, so the ideal tree
needs both versions; the committed lock only had 1.10.0.

Regenerate the lock with npm 11.18 so it carries both 1.11.2 (top-level, for
the peer deps) and 1.10.0 (nested, for rolldown's pin). Verified npm ci passes.

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

* nit npm publish

* fix: fail closed on migrations-status error in fork schema merge

* nit CI emnapi/core version

* prevent initial_datatable_migration if migrations already exist

* fix(datatable-migrations): validate persisted data table names as path segments

edit_datatable_config only validated rename segments, not the actual
settings.datatables keys, so a data table could be saved directly under a name
like '..' or one containing '/'. Since new tables default to
migrations_enabled = true, generate_initial_datatable_migration would then
insert a migration row and the sync export would build
migrations/datatable/<name>/... paths from that name, producing malformed or
directory-escaping export paths.

Validate every persisted data table name in edit_datatable_config (alongside
the existing rename checks) and add validate_datatable_path_segment to
generate_initial_datatable_migration for defense in depth.

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

* fix: scope datatable _wm_migrations by data table and cascade renames/deletes

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

* fix(system_prompts): resolve nested local command groups in CLI docs generator

The CLI docs generator anchored on the first `new Command()` in a file and
never resolved locally-defined command groups passed as
`.command("name", localCmd)`. For datatable this flattened the nested
`migrate` group: it emitted `datatable new/up/down` plus a bare
`datatable migrate`, and mislabeled the datatable command with the migrate
group's description. jobs was broken the same way (its description was pull's,
and pull/push rendered empty).

Anchor block extraction on the `export default`ed command, recurse into
locally-defined `const x = new Command()` groups mounted as subcommands, and
render nested sub-subcommands. Regenerated docs now show
`datatable migrate new/up/down` and `jobs pull/push` with their real
options.

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

* refactor: drop unreleased _wm_migrations legacy-upgrade handling

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

* fix: return datatable migration SQL from getItemValue for the diff drawer

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

* chore(frontend): use windmill-utils-internal 1.8.2 for migration diff drawer

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

* nit

* nit

* fix: handle datatable migration renames on push and dedupe timestamps

* fix: reject rewriting an already-applied datatable migration on upsert

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

* fix(frontend): add missing @emnapi/core and @emnapi/runtime lockfile entries

Resolves npm ci EUSAGE failure: the optional cpu:wasm32 @rolldown/binding-wasm32-wasi
declares deps on @emnapi/core@1.11.2 and @emnapi/runtime@1.11.2 that had no resolved
lockfile entries.

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

* fix(cli): datatable migrate up/down default to main datatable, not all

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

* fix: fail closed when applied status unreadable on datatable migration rewrite

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

* fix: surface full error detail in Database Manager DDL/query errors

* "See migration" button in the toast

* feat: add Enter shortcut to Create-a-migration in the DDL guard

* fix(frontend): warn before running a newly-created datatable migration out of order

The row-level Run action warns when earlier migrations are still pending, but
the create-and-run paths ran a just-created migration with `only` directly,
applying it ahead of older pending migrations without that confirmation.

Reuse the same "Run migration out of order" confirmation across all
create-and-run paths via a shared helper (datatableMigrationUtils):
- NewDataTableMigrationModal "Create and run" (and the DDL guard path)
- DatatableSchemaDiff fork→parent merge
- dbOps schema ops (DB manager create/alter/drop) — the pure factory throws a
  MigrationRunCancelled sentinel on decline, which DBTableEditor treats as a
  silent cancel

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

* fix: keep renamed datatable migrations visible in compare view

* fix: record per-migration deployment on datatable migrations disable

* fix(cli): run deployed datatable migrations after workspace merge

The merge command upserted datatable_migration definitions into the target
workspace and reported the item as successfully deployed, but never ran the
migrations. For forked datatables backed by separate databases, this left the
target schema unchanged until someone manually ran `wmill datatable migrate up`,
while the CLI reported a successful merge.

Collect the datatable migrations deployed (not deleted) into the target and,
after the deploy loop, offer to run them via the existing offerToRunNewMigrations
helper — the same post-deploy run prompt the push/sync path uses (interactive
only; `--yes`/non-TTY skip the mutating run, matching push behavior). Export
parseDatatableMigrationDeployPath so the merge path can parse the deployed items.

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

* fix(backend): serialize datatable migration edits/deletes with the run lock

A migration run snapshots a migration's code_up from datatable_migrations and
only records its version in the data table's _wm_migrations after the job
succeeds. upsert_datatable_migration checked _wm_migrations before allowing an
edit but took no lock, so a concurrent edit could read "not applied yet",
rewrite code_up/code_down, and then the in-flight run would record the version
for the old SQL — leaving _wm_migrations pointing at SQL that was never applied
(migrate up then skips it; rollback runs a down that doesn't match).

Serialize definition rewrites and deletes with the same per-database advisory
lock the run/rollback paths use:
- Factor the connect+advisory-lock into lock_datatable_migration_runs and the
  applied-versions read into read_applied_versions_on_client.
- run_datatable_migrations now snapshots the definitions AFTER taking the lock,
  so code_up can't change between snapshot and version-record.
- upsert (when changing an existing def) and delete take the lock across the
  applied-check and the write; delete now rejects deleting an already-applied
  migration (would orphan its _wm_migrations record), symmetric with upsert.
  Both fail closed if the data table database is unreachable.

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

* fix(frontend): stack the out-of-order migration confirm above the DB editor preview

Creating a table on a migrations-enabled data table opened the DB table editor's
"Confirm running the following" preview modal, whose confirm triggers applyDdl,
which then asks for out-of-order confirmation. Both are ConfirmationModals with a
hardcoded z-[9999]; the out-of-order one lives in DBManagerContent (mounted before
the editor), so it rendered behind the still-open preview modal.

Add an optional zIndexClass prop to ConfirmationModal (default z-[9999],
backward-compatible) and give the DB-manager out-of-order confirm z-[10000] so it
stacks on top.

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

* chore: update ee-repo-ref to 27672e37df5d9dfde94f19963d5ffcdf8dd5448c

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

Previous ee-repo-ref: 6c287041cd7edd4a77a4bc07ad0e156cec32cce4

New ee-repo-ref: 27672e37df5d9dfde94f19963d5ffcdf8dd5448c

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-07 08:25:16 +00:00

1645 lines
58 KiB
Svelte

<script lang="ts">
import {
AlertTriangle,
ArrowDownRight,
ArrowRight,
ArrowUpRight,
Building,
CircleCheck,
CircleX,
DiffIcon,
FileJson,
FlaskConical,
GitFork,
Loader2,
UserPlus
} from 'lucide-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,
getItemValue,
getOnBehalfOf,
type DeployPermission,
type DeployResult
} from '$lib/utils_workspace_deploy'
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 { editUrlFor } from './sessions/forkEditUrl'
import { diffInMask } from './sessions/modifiedItemsMask'
import DatatableSchemaDiff from './DatatableSchemaDiff.svelte'
interface Props {
currentWorkspaceId: string
parentWorkspaceId: string
comparison: WorkspaceComparison | undefined
/** 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,
comparison,
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))
let mergeIntoParent = $state(initialMergeIntoParent)
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) => {
if (mergeIntoParent) {
return diff.ahead > 0
} else {
return diff.behind > 0
}
}) ?? []
)
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(
(comparison?.diffs.length ?? 0) === 0
? 'No changes between this fork and its parent.'
: mergeIntoParent
? `Nothing to deploy — ${parentWorkspaceId} already has every change from this fork.`
: `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')
}) ?? []
)
let itemsWithAheadChanges = $derived(
comparison?.diffs.filter((diff) => {
const status = deploymentStatus[getItemKey(diff)]?.status
return diff && diff.ahead > 0 && !(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)
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
}
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.
let allSelected = $derived(
selectableDiffs.length > 0 &&
selectableDiffs.every((d) => selectedItems.includes(getItemKey(d)))
)
async function selectAll() {
selectedItems = selectableDiffs
.map((d) => getItemKey(d))
.filter((k) => !(deploymentStatus[k]?.status == 'deployed'))
}
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' }
// Check if the item was deleted in the source workspace.
// If so, archive/delete it in the target workspace instead of copying.
const diff = comparison?.diffs.find((d) => getItemKey(d) === statusKey)
const itemDeletedInSource = diff
? mergeIntoParent
? diff.exists_in_fork === false
: diff.exists_in_source === false
: false
let result: DeployResult
if (itemDeletedInSource) {
result = await deleteItemInWorkspace(kind, path, workspaceToDeployTo)
} else {
result = await deployItem({
kind,
path,
workspaceFrom,
workspaceTo: workspaceToDeployTo,
onBehalfOf: getOnBehalfOfForDeploy(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>()
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])
}
}
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.
if (!anyFailed && mergeIntoParent) {
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 = selectableDiffs.filter((d) => !isTriggerOrScheduleKind(d.kind) && !hasDraft(d))
const conflictSafe = mergeIntoParent
? filtered
: filtered.filter((d) => !(d.ahead > 0 && d.behind > 0))
// 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
}
})
// 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_*`.
let deployableItems = $derived.by(() => {
return (comparison?.diffs ?? [])
.filter((diff) => {
const key = getItemKey(diff)
const isSelectable = selectableDiffs.includes(diff)
const isDeployedAndIrrelevant =
deploymentStatus[key]?.status === 'deployed' && !isSelectable
return !isDeployedAndIrrelevant
})
.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',
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 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={nothingToAct ? [] : deployableItems}
{selectedItems}
{deploymentStatus}
selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)}
{allSelected}
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}
{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} />
<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>
</div>
{/if}
<div class="flex items-center gap-2 text-sm">
<Badge color="transparent">
{comparison.summary.total_diffs} total items
</Badge>
<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>
</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 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 isSelectable = selectableDiffs.includes(diff)}
{@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 && isSelectable && 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 -->
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
<Badge
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
color="indigo"
size="xs">New</Badge
>
{/if}
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
<Badge title="This item was deleted in '{currentWorkspaceId}'" color="red" size="xs"
>Deleted</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
<Badge
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
color="red"
size="xs">Deleted</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
<Badge
title="This item was newly created in '{currentWorkspaceId}'"
color="indigo"
size="xs">New</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 && !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>
<DeploymentRequestPanel
bind:this={deploymentRequestPanel}
forkWorkspaceId={currentWorkspaceId}
{parentWorkspaceId}
currentUsername={$userStore?.username ?? ''}
isAdmin={$userStore?.is_admin ?? false}
onStateChange={(open) => {
hasOpenDeploymentRequest = open
}}
/>
</div>
<DatatableSchemaDiff {currentWorkspaceId} {parentWorkspaceId} />
{#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}