feat: Add section to deploy projects to hub (#9332)

* feat: add Deploy to Hub workspace settings tab

* Init record logic

* Fix wordings

* Add publish-app drawer with per-app rate limit mock

- Publish drawer on raw_apps/apps exposes public URL, copy-iframe, unpublish
- Inline per-app rate limit config (req/min, burst, per-IP toggle)
- Rename workspace settings "Default app" tab header to "Apps" to cover both default app and public rate limiting

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

* Simplify publish drawer to show workspace-wide rate limit only

Drop per-app rate limit fields (req/min, burst, per-IP) — none of these
are supported by the backend. The drawer now shows the existing
workspace-level rate limit read-only with a link to edit it in
Workspace settings → Apps.

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

* Rename publish-app drawer wording to 'Share as iframe'

'Publish publicly' was ambiguous (publish to Hub vs make public URL).
Use 'Share as iframe' for the button and drawer title, and 'Generate
iframe' for the confirm action. Intro text now explicitly mentions
iframe embedding use cases (Hub, docs page, own site).

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

* Wire DeployToHub to real workspace data

- Fetch apps, raw_apps, flows, scripts, resources via their services
- Fetch workspace rate limit via WorkspaceService.getSettings
- Share-as-iframe flips app policy.execution_mode to 'anonymous' via
  AppService.updateApp and resolves the real public URL via
  getPublicSecretOfApp + computeSecretUrl
- Detect already-public apps from listApps execution_mode field
- Filter out app_theme resources (noise, present in every workspace)
- Hub bundle/version push and recording remain mocked (no backend yet)

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

* Wire recordings to real jobs with run-preview UX

- Recording flow now fetches the real schema, runs the job, and polls
  getCompletedJobResultMaybe to surface success/failure before saving.
- Drawer shows a sticky status box (loader / success / failure) with a
  result preview, a job link, and an in-context Save CTA.
- Only successful runs can be saved as a recording. Failures show the
  error and offer re-run.
- Filter cache/state/app_theme internal resource types (mirrors
  workspaces_export.rs filter).
- Added "What is a recording?" explainer banner above the items list.

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

* Add draft/review state machine and submission gating

- Phases: predeploy → draft → under_review → live, with workflow
  step indicator and contextual footer actions per phase
- Bundle drawer collects name + readme before pushing the draft
- draftItems snapshot frozen at deploy time; workspaceItems keep
  refreshing without affecting the draft
- Folder MultiSelect lets users scope the bundle to one or more
  folders; empty = whole workspace
- Submit-for-review disabled until every script and flow in the
  draft has a recording (progress bar + counter)
- Recordings now run the real job and poll for success/failure;
  only successful runs can be saved
- under_review phase locks editing, sharing, and recording
- Dark mode variants on every coloured banner
- Steps card shows the full 3-step process always, highlighting the
  current step

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

* Make recordings optional, encourage them for discoverability

- Submit for review no longer gated on full recordings
- Footer hint now frames recordings as boosting approval speed and
  public Hub featuring, not as a hard requirement
- Progress card label switched from 'Recordings needed' to
  'Recordings recommended'
- Items without a recording display a yellow 'No recording' badge in
  every phase so the gap stays visible after submission

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

* Allow per-item selection inside the bundle scope

- Items in predeploy now have checkboxes (all selected by default)
- Select all / Deselect all act on the current folder filter
- manualDeselected resets when the folder filter changes
- Bundle button uses the selected count, disabled when zero
- Draft snapshot keeps only the selected items
- Checkboxes hidden in draft / under_review / live phases

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

* Add diff button once approved by admins

* Small fix

* Nits

* fix(deploy-to-hub): paginate workspace list and cancel stale record polls

- loadWorkspace fetches all pages instead of capping at 100 items per kind
- pollJobUntilComplete now bails when recordRunSeq advances (new record
  target, re-run, or drawer close), preventing late completion of a
  previous run from overwriting current state

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

* perf(deploy-to-hub): parallelize public-app URL resolution

resolvePublicUrl now runs once per anonymous app via Promise.all instead
of serially inside the items loop, removing N round-trips from initial
tab load.

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

* fix(indexer): tell admins when ingress routes search to wrong pod (#9274)

* [ee] fix(indexer): tell admins when ingress routes search to wrong pod

When the IndexReader is absent on the pod handling a search request but
another pod is actively holding the indexer lock, the EE handler now
returns a tailored error pointing at the ingress/load-balancer
configuration instead of the generic "indexer not running" message.

The indexer status endpoint reads the DB lock so it reports "running"
from any pod, but search endpoints need the in-memory IndexReader that
only exists on the lock holder. In multi-replica deployments this looks
like the indexer is healthy but every search 404s.

Companion: windmill-labs/windmill-ee-private#TBD

Fixes WIN-1968.

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

* chore: update ee-repo-ref to eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817

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

Previous ee-repo-ref: 7dd43d1850813071cc18ba49ba090583e7321f4b

New ee-repo-ref: eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* feat(cli): add `wmill init prompts` and custom override slot (#9266)

* feat(cli): add `wmill init prompts` and custom override slot

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

* refactor(cli): replace init prompts with refresh prompts + AGENTS.md/AGENTS.cli.md split

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

* feat(cli): dedupe claude skills via @-includes and add prompts freshness check

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

* refactor(cli): drop migration-choice flags from `refresh prompts`

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

* docs(cli): add 'Running and previewing local changes' section to AGENTS.cli.md

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

* fix(cli): write full skill content to .claude/, drop @-include wrapper

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

* feat(cli): reconcile CLAUDE.md the same way as AGENTS.md

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

* fix(cli): address PR review nits — argv parsing, lazy import, comment detection, error propagation

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add yolo mode for ai chat tools (#9258)

* feat: add yolo mode for ai chat tools

* nit

* fix: align chat footer controls

* feat: add ai chat autonomy modes

* feat: add autonomy mode dropdown

* fix: highlight yolo autonomy icon

* fix: auto accept flow edits

* fix: hide unsupported autonomy modes

* fix: handle auto-accept flow editor races

* fix(debugger): add non-root user support to Dockerfile (#9277)

Mirrors the main Windmill Dockerfile pattern: creates a windmill user
(UID/GID 1000) and makes cache/work directories world-writable so the
image runs cleanly under Kubernetes securityContext.runAsNonRoot or
runAsUser: 1000 without permission errors on Bun, pip, or windmill
cache writes.

Fixes WIN-1969

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path (#9276)

* fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path

The AI proxy handler accepts an X-Resource-Path header to override the
configured workspace AI provider. When supplied, the handler loaded the
resource value from the resource table using the root DB pool with no
resources:read scope check, so any authenticated workspace user could
point X-Resource-Path at a restricted AI resource (e.g. one in a folder
they cannot read) and the proxy would use that resource's provider
credentials for the outbound AI request.

For user-supplied resource paths, now require resources:read:{path}
scope and fetch the resource through user_db.begin(&authed) so RLS
enforces the same folder/group boundary as the resource API. The RLS-
scoped $var: resolution stays in place as defense in depth. The
admin-configured workspace/instance ai_config path is unchanged.

Fixes WIN-1971

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

* test(ai): regression test for X-Resource-Path RLS enforcement

Cover all four cases:
- non-admin pointing X-Resource-Path at a restricted resource is rejected
- non-admin pointing it at a resource they own still works
- admin can point it at any resource
- workspace-configured proxy flow (no X-Resource-Path) is unchanged

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add userdraft listing primitives (#9268)

* feat: add userdraft listing primitives

* fix: cancel stale userdraft discard writes

* docs: remove global ai userdraft plan

* feat(nsjail): optional disk-backed /tmp via instance setting (#9272)

* feat(nsjail): optional disk-backed /tmp via instance setting

* test(nsjail): unit-test tmp mount resolver and narrow visibility

* refactor(nsjail): switch tmp backing to select + conditional UI

* ui(nsjail): make tmpfs the visible default in /tmp backing select

* fix(nsjail): refuse preexisting jail_tmp to block symlink escape

* fix(nsjail): allow jail_tmp reuse on sequential nsjail calls

Codex flagged that python/ruby/rust executors invoke nsjail twice per
job_dir (install then run). The previous resolver treated any preexisting
jail_tmp as hostile and silently fell back to tmpfs on the second call,
so disk-backed mode never reached the main script run for those langs.

Use symlink_metadata().is_dir() to distinguish a real directory left by
an earlier call in the same job_dir (safe to reuse) from a symlink or
other entity (still refused, as the codebase-tar escape requires).

Also loosen the frontend visibility predicate: only hide nsjail settings
when job_isolation is explicitly 'none' or 'unshare', so deployments
that enable nsjail via DISABLE_NSJAIL=false with no DB setting can
still see the controls.

* chore(main): release 1.706.0 (#9270)

* chore(main): release 1.706.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* fix(nsjail): gate unix-symlink test behind cfg(unix) for Windows build (#9280)

The disk_backed_refuses_preexisting_symlink_at_jail_tmp test calls
std::os::unix::fs::symlink directly, which doesn't exist on Windows
targets. Without a cfg gate, `cargo check --tests` fails on Windows
with E0433. Other symlink call sites in this crate (php_executor,
bun_executor, rust_executor, etc.) already follow this pattern.

Fixes WIN-1972

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Reduce slim image vulnerability surface (#9279)

* Reduce slim image vulnerability surface

* chore(docker): drop apt-get upgrade -y from slim images

apt-get upgrade hurts build reproducibility (same Dockerfile + same
commit at different times produces divergent images) and trips hadolint
DL3005. The freshness it buys is dominated by simply rebuilding against
the periodically-refreshed debian:bookworm-slim base image.

The --no-install-recommends and apt-list cleanup wins are kept.

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>

* fix(git-sync): bump to hub/28234 with stateless gpg.program wrapper (WIN-1974) (#9282)

* fix(git-sync): revert LATEST_GIT_SYNC_SCRIPT_PATH to hub/28230 to restore GPG-signed deploys (WIN-1974)

hub/28231 (PR #9230) is the "thin" script that hands the actual `git commit`
to the CLI's hidden `sync git-deploy`. The hub script still does the GPG
setup (import key into a fresh GNUPGHOME, dummy `gpg -bsau` to warm the
agent passphrase cache, then `git config user.signingkey` + `commit.gpgsign`
locally), but the commit no longer runs in the same `git_push` flow — it
runs minutes later inside the CLI after workspace API resolution, zip pull,
file extraction, and lockfile autofill. By the time the spawned `git commit`
asks gpg-agent for the cached passphrase, the cache state is no longer
reliable (or the spawned `gpg` ends up talking to a fresh agent), so signing
fails non-interactively with `gpg failed to sign the data`.

hub/28230 is hub/28217's in-script logic rebuilt with windmill-cli@1.703.3:
the GPG setup and the in-script `sh_run("git commit ...")` happen back-to-back
in `git_push`, so the cache is always fresh. It preserves wm_deploy / fork
branch behavior, the EE deployment-callback `main()` signature is unchanged,
and the only min-version check in EE (`is_script_meets_min_version(28103)`)
is comfortably below 28230 — so this revert is safe.

Forward fix (separate PR): publish a new thin script that, alongside the
existing GPG setup, writes a `gpg.program` wrapper using `--pinentry-mode
loopback --passphrase-file` so signing is independent of the agent's cache
state. Re-bump past 28231 then.

Fixes WIN-1974

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

* chore(git-sync): check in source-of-truth for the next hub script (gpg.program wrapper)

This is the script that will be published to hub.windmill.dev once verified
on a customer GPG-signed deploy. It replaces hub/28231's agent-cache
pre-warm (`gpg -bsau` with --passphrase) with a stateless gpg.program
wrapper + chmod-600 passphrase file. Every git-invoked gpg call goes
through the wrapper, which always uses --pinentry-mode loopback (and
--passphrase-file when a passphrase exists). Signing no longer depends on
gpg-agent having a cached passphrase by the time the CLI's `git commit`
runs — which closes WIN-1974.

Not wired in yet: LATEST_GIT_SYNC_SCRIPT_PATH stays on hub/28230 until this
script is uploaded and the new hub id is known. This file is checked in so
the diff is reviewable, future bumps have a source of truth, and a CLI
regression test can `cat` it for fixture parity.

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

* fix(frontend): skip format/pattern validation for $var/$res/$jsonvar references in ArgInput

A resource field with a `pattern` constraint (e.g. the gpg_key.private_key
field, whose pattern enforces a `-----BEGIN PGP PRIVATE KEY BLOCK-----`
prefix) rejects values like `$var:u/me/gpg-private-key` with an "invalid
format" error in the resource editor — even though `$var:`/`$res:`/`$jsonvar:`
are placeholders the backend resolves at runtime, not the actual string
that needs to match the regex.

Bail out of all format/pattern checks (email, ipv4, ipv6, uuid, custom
pattern) when the value is one of these references. Required/numeric
bounds/array checks still apply since they're shape-level, not regex.

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

* feat(git-sync): bump LATEST_GIT_SYNC_SCRIPT_PATH to hub/28234 (gpg.program-wrapper fix)

hub/28234 is the forward fix for WIN-1974: replaces hub/28231's agent-cache
pre-warm (which became stale by the time the CLI's `git commit` ran) with
a stateless `gpg.program` wrapper that uses `--pinentry-mode loopback`
(and `--passphrase-file` when a passphrase exists) on every gpg invocation.
Bundled CLI is windmill-cli@1.705.0.

Verified via reproducer at /tmp/git-sync-diff/test-gpg-fix.sh: deliberately
killing gpg-agent between GPG setup and `git commit` reproduces the
customer's `gpg failed to sign the data` error verbatim under the old
flow, and the wrapper signs through it. Holds for passphrase-protected
keys, split-subkey [C]+[S] layouts, and unprotected keys.

Drops the local source-of-truth copy (`hub-scripts/`) — hub is canonical
now that 28234 is published.

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

* chore(git-sync): drop verbose comment above LATEST_GIT_SYNC_SCRIPT_PATH

The git history (this PR) carries the why; the constant name + value carry
the what.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cli): wmill sync git-deploy stops committing; caller owns commit+push (#9284)

Single contract for the deployment-callback path: the CLI does branch
checkout + pull, the caller (hub script in production, test in test)
does git add + commit + push. This restores the WIN-1974 invariant —
GPG setup and `git commit` run back-to-back in the same process, so
the agent's pre-warmed passphrase cache is still warm at sign time —
without needing a `--skip-commit` flag for the hub case and a default
"also-commit" for everything else. Same behavior in every call site.

Changes:
  - sync.ts: drop the gitSyncDeployPush call from pull()'s deploy path
    (both the onlyCreateBranch fast-return and the post-pull commit).
    `gitSyncDeployPush` stays exported for any caller that wants the
    same commit/push semantics — just not invoked by the CLI subcommand.
  - gitsync_promotion.test.ts: e2e test now does its own git add +
    commit + push after `wmill sync git-deploy`, mirroring what the
    hub script does in production. Same regression coverage
    (wm_deploy branch created in Case A, main untouched; main updated
    in Case B, no new wm_deploy).

CLI typecheck unchanged (two pre-existing TarAsZip errors at lines
2578/3307, present before this PR). All 743 unit tests still pass.

The accompanying hub script (option-C — CLI for branch+pull, script
for commit+push) lives at /tmp/git-sync-diff/sync-script-to-git-repo-windmill.option-C.ts.
Once published, a follow-up bumps LATEST_GIT_SYNC_SCRIPT_PATH to its id.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* bump git sync to 28236

* fix: fork compare visibility for non-admins and stale-token superadmins (#9283)

* fix: use fork-scoped authed for fork visibility in compare_workspaces

* test: add EE end-to-end repro for fork rename visibility

* chore: restore concurrency_locks sqlx cache lost in cleanup

* test: add regression for stale-superadmin-token fork visibility bug

* chore: update sqlx cache for new test queries

* chore(main): release 1.706.1 (#9281)

* chore(main): release 1.706.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* feat: add wmill job rerun subcommand (#9275)

* feat: add wmill job rerun subcommand

* feat: add wmill job restart subcommand for flow restart-at-step

* chore(system_prompts): point plugin skills sync at plugins/windmill/ (#9287)

* chore(system_prompts): point plugin skills sync at plugins/windmill/

The plugin checkout's plugin folder is being renamed from
`plugins/windmill-code-plugin/` to `plugins/windmill/` to shorten the
slash-command namespace and align with the matching Cursor plugin
layout.

Paired with windmill-labs/windmill-claude-plugin#8. That PR must merge
first so the next sync run finds the new folder.

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

* docs(system_prompts): update plugin-dir example to plugins/windmill

Co-authored-by: centdix <centdix@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: centdix <centdix@users.noreply.github.com>

* fix(cli): wmill sync pull updates wmill-lock.yaml for raw apps (#9289)

* fix: flow recording teardown crash + rename package to @windmill-labs/components (#9288)

* fix: guard against null recording during FlowRecordingReplay teardown

Navigating away from a flow recording inside a workspace file-tree view
threw `TypeError: Cannot read properties of null (reading 'flow')` from
FlowGraphViewer once during the teardown tick.

Svelte 5 compiles child component props as live getters that close over
`$$props.recording.flow`. When `recording` flips to null on the parent's
navigation, an outer `{#if !recording?.flow}` doesn't stop those getters
from firing one more time as derived effects re-evaluate before the
unmount lands — so the getter dereferences null and throws.

Fix at the two layers where the deref actually happens:

- FlowRecordingReplay: use `recording?.flow` at the binding sites
  (FlowViewer + graph-snippet FlowGraphViewer) so the compiler emits an
  optional-chained getter, and guard the snippet branch with
  `{:else if recording?.flow}` so it doesn't mount when there's nothing
  to show.
- FlowGraphViewer: finish the optional chaining the rest of the file
  already used everywhere else (`flow?.value?.skip_expr`,
  `flow?.value?.cache_ttl`, `flow?.schema`). When the upstream
  binding returns undefined during teardown, the graph degrades to an
  empty frame instead of crashing.

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

* chore: rename package to @windmill-labs/components

- frontend/package.json: rename `windmill-components` → `@windmill-labs/components`
- frontend/publish.sh: drop the in-place sed rename dance; the checked-in name now matches what's published, so `npm run package && npm publish` is enough
- frontend/package-lock.json, system_prompts/auto-generated/prompts.d.ts: regenerated by `npm run package` under the new name

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(flows): restore Variables and Resources in flow editor prop picker (#9290)

The design system overhaul in 888837431c accidentally dropped the
fallback condition that displayed the Variables and Resources sections
in the prop picker by default. After that commit, these sections only
appeared when the user typed `variable.` or `resource.` in their
expression, which meant they effectively disappeared from the flow
editor's prop picker for most users.

Restore the previous behavior by showing the sections when no input
match is active (the equivalent of the old `!filterActive` clause).

Fixes WIN-1976

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): tighten token-owner fallback for unscoped tokens (WIN-1978) (#9293)

* fix(auth): reject unscoped tokens with cross-workspace forged owners (WIN-1978)

An unscoped token (workspace_id IS NULL) whose `owner` field references a
user, group, or unprefixed value that is not present in the target
workspace must not authenticate. The previous fallback in the
`u/<username>` branch granted `(is_admin=false, is_operator=true)` when
no `usr` row matched in the target workspace, letting a token holder
who could mutate the `token` table cross workspace boundaries with
operator privileges.

The `g/<groupname>` branch likewise silently accepted any group name as a
"group user", and the no-prefix branch granted operator state from
arbitrary owner strings. Both are now rejected unless the owner matches
a real user/group membership in the target workspace.

Adds an integration regression covering all three forged-owner shapes.

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

* chore: drop integration regression for auth fallback

The test added in the previous commit relies on a sqlx::query! that
requires offline-cache regeneration; removing per code-review preference
to keep this PR scoped to the auth-layer fix.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ResourceEditor): don't reset state when `selected` reverts to undefined (#9295)

The bootstrap effect tracked `selected` via its early-return check, so any
time `selected` flipped back to `undefined` it would re-run and reinitialize
`states[effectiveWorkspace]` to empty — wiping user input. This happens in
the React SDK consumer: reactify re-syncs all Svelte props on every React
render, and since `selected` isn't passed through, `$props()` reverts it.

Move the `selected !== undefined` check inside the existing `untrack` so
the effect only tracks `effectiveWorkspace`. Bootstrap still runs once on
mount; subsequent `selected` flips no longer retrigger it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(secret-backend): pass DB to Vault migrations + show failure details (#9292)

* [ee] fix(secret-backend): pass DB to Vault migrations + surface failure details

Companion to windmill-ee-private fix for WIN-1977. The HashiCorp Vault
migration always failed under JWT/OIDC auth because the migration
constructed VaultBackend without a DB, so every secret hit "Database
connection required for JWT authentication". Creating new secrets worked
because the runtime path passes the DB.

Frontend: when failed_count > 0, the toast and console now show the
per-secret failures (path + error, capped at 5 with "...and N more")
instead of just aggregate counts.

Fixes WIN-1977

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

* chore: update ee-repo-ref to 14315067c083d3361512de621b12e41dbe3b017d

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

Previous ee-repo-ref: 390ed6c851b1915f0b492897c663f8058477680f

New ee-repo-ref: 14315067c083d3361512de621b12e41dbe3b017d

Automated by sync-ee-ref workflow.

* fix(secret-backend): escape failure fields and use <br> in migration toast

Address CI review on PR #9292:

- P1 (cubic/codex): backend-supplied workspace_id/path/error are now
  HTML-escaped before being interpolated into the migration toast,
  which renders through {@html processMessage(...)} in Toast.svelte.
  This prevents stored XSS via secret paths or backend errors that
  contain markup. '/' is intentionally left intact so the toast's
  path-highlight regex still tags workspace paths.
- P2 (pi): swap '\n' for '<br>' so multi-line failure lists actually
  break in the toast instead of collapsing to a single run-on line.
- Extend the same per-secret failure surfacing (toast + console.error)
  to the Azure Key Vault and AWS Secrets Manager migration handlers
  via a shared reportMigrationFailures() helper so all six migration
  paths report identically.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* nit react-sdk resource editor

* sdk_resource

* make `selected` resilient + snapshot args for React (#9298)

* fix(ResourceEditor): make `selected` resilient + snapshot args for React

Two issues surfaced via the React SDK (reactify wrapper re-spreads Svelte
props on every host re-render):

1. The bindable `selected` prop transiently resets to undefined on each
   re-spread, flipping `current` through undefined and unmounting the
   form (input loses focus on every keystroke). Rename the prop to
   `selectedProp` and derive `selected = selectedProp ?? effectiveWorkspace`
   so the fallback insulates the component without effects.

2. The onChange dispatch passed `current.args` (a `$state` proxy) directly,
   so React consumers diffing by reference or JSON.stringify saw the same
   value forever, and the effect only tracked the args reference (not
   nested mutations). Wrap with `$state.snapshot` to deep-track and emit
   a plain object.

The bootstrap effect is also restructured: it no longer writes `selected`
(the derived handles defaulting) and now guards on `selected in initialStates`
so workspace flips remain idempotent.

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

* fix(ResourceEditor): declare effectiveWorkspace before use in selected

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* remove unused workflow

* feat(typescript-client): add deleteS3File + optional workspace arg on S3 helpers (#9300)

* feat(typescript-client): add deleteS3File + optional workspace arg on S3 helpers

Customer-requested ergonomics for the TypeScript SDK:

- New `deleteS3File(s3object, workspace?)` wrapper around the existing
  `HelpersService.deleteS3File` (backend endpoint is already there). Saves
  callers from having to either hand-roll `denoS3LightClientSettings()` +
  AWS SDK calls, or wire up `HelpersService` directly.
- `denoS3LightClientSettings`, `loadS3File`, `loadS3FileStream`, `writeS3File`,
  and the new `deleteS3File` all gain an optional trailing `workspace?: string`
  parameter that falls back to the `WM_WORKSPACE` env var via `getWorkspace()`.
  Mirrors the calling convention customers already expect from helpers like
  `getVariable` / `runScript`.

`build.sh` and `build.jsr.sh` are updated to export `deleteS3File` from both
the NPM and JSR entry points.

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

* chore: regenerate system_prompts auto-generated for new S3 helpers

`python system_prompts/generate.py` after adding deleteS3File and the
optional workspace param to the existing S3 helpers, so the agent-facing
docs (CLI skills, TS SDK prompt, script skills) reflect the new signatures.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(github-app): hide cloud-only UI on self-managed + admin assignment UI (#9299)

* feat(github-app): hide cloud-only UI on self-managed + admin assignment UI

Two related UX fixes for the GitHub App self-managed (GHES) integration:

1. On self-managed instances, the per-installation Export button and the
   "Import installation from other instance" section in the workspace UI both
   hide. Both round-trip a JWT carrying only {installation_id, account_id} with
   no github_base_url, so they would produce broken cloud-style installs on a
   self-managed instance. The previous Export attempt also failed with
   "No JWT token received from server" because self-managed installs store an
   empty JWT by design.

2. New "Workspace assignments" panel in instance settings (GhesAppSettings.svelte)
   that auto-discovers installations of the configured GHES App and lets the
   super-admin assign them to specific workspaces. Workspace users without
   GitHub permissions no longer need to install the App themselves — the admin
   provisions the link from instance settings. Admin-provisioned installs show a
   "Provisioned by admin" badge in the workspace UI and can only be removed by
   the super-admin from instance settings.

Backend support is in the EE companion PR
windmill-labs/windmill-ee-private#588.

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

* chore: update ee-repo-ref to da5189cf69a453de3855057f41be0d84e5910707

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

Previous ee-repo-ref: d959b83ce413ad531e9cc28e0f8199cdecb73a31

New ee-repo-ref: da5189cf69a453de3855057f41be0d84e5910707

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* chore(main): release 1.707.0 (#9285)

* chore(main): release 1.707.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* feat(queue): per-workspace fairness cap on the shared cloud worker pool (#9303)

* feat(queue): cloud-only per-workspace fairness cap on the shared worker pool

On `app.windmill.dev` the cluster runs a single default worker group, so a
single workspace flooding the queue can degrade quality of service for
everyone else. This adds an opt-in mechanism that caps any single workspace
at a configurable share of the shared worker pool when it has been
dominating cluster activity for more than a configurable window.

Detection signal counts both currently-running jobs and jobs completed in
the rolling window, so it catches workspaces hogging slots with long jobs
**and** workspaces spamming many tiny jobs (where no individual job's
started_at is old, but throughput share dominates).

Refresh is coordinated cluster-wide via a single UPDATE on
`background_task_state`: the `WHERE updated_at < now() - interval` predicate
combined with row-level locking means only one process per refresh cycle
actually runs the aggregation, regardless of fleet size. Every other
process gets the freshly written value in the same round trip via
`UNION ALL ... LIMIT 1`. Heavy aggregation rate stays at ~0.2-0.5 qps for
the whole cluster.

Pull queries are split: the existing query string and its bind shape stay
bit-identical to today, so the planner keeps using the same indexes when
fairness is off or no workspace is currently capped. A separate
`WORKER_PULL_QUERIES_FAIRNESS` adds `AND workspace_id <> ALL($2::text[])`
and is only materialized while the feature is enabled.

Hard-gated to `CLOUD_HOSTED=true` + BASE_URL host == app.windmill.dev at
three layers: frontend `cloudonly: true`, API setter rejection in
`set_global_setting_internal`, runtime check in `fairness_active`. Settings
are exposed under Jobs in the instance-settings UI; defaults are off so
the change is a no-op for self-hosted.

Two-pass pull guarantees no worker idling: if every queued job belongs to
a capped workspace, the second pass uses the unmodified pull queries.
Cap re-asserts on the next refresh.

Fixes WIN-1982

* fix(queue): address CI review findings on workspace fairness

Six fixes from the four-reviewer cross-check on #9303:

1. **Aggregation evaluation (Codex P1).** The previous `INSERT ... ON CONFLICT
   DO UPDATE WHERE updated_at < ...` had the heavy `v2_job_queue ∪
   v2_job_completed` aggregation inlined into `VALUES`, which Postgres
   evaluates for every contender to build the proposed row — losing the
   "one heavy aggregation per cycle cluster-wide" property the design
   advertises. Split into three small statements: (a) cheap claim with
   constant `VALUES`, (b) winner-only `UPDATE ... SET value = jsonb_build_object('overloaded', <agg>)`
   (Postgres only evaluates `SET` per row matching `WHERE`, so losers never
   compute the aggregation), (c) read for everyone. Heavy query now truly
   runs ~0.2-0.5 qps cluster-wide regardless of fleet size.

2. **Numeric setting wraparound (cubic P1).** `u64 as u32` and downstream
   `u32 as i32` could silently flip sign and feed `make_interval(secs => -N)`,
   making `now() - interval` a future timestamp and disabling the
   completed-jobs half of the activity signal. Clamp `duration_secs` to
   [1, 86400] and `min_total_jobs` to [0, u32::MAX] before storing.

3. **`/instance_config` bypass (cubic/Claude/Codex P2).** Bulk config endpoint
   sidestepped `set_global_setting_internal`'s gate; a self-hosted superadmin
   could persist `workspace_fairness_*` rows via the bulk path. Mirror the
   per-key check in `set_instance_config` upsert flow.

4. **DB error coerced to false (Claude P2).** `load_workspace_fairness_enabled`
   collapsed `Err(_)` to `false` and unconditionally swapped the atomic — a
   transient DB blip during notify-event propagation toggled the feature off
   cluster-wide (and triggered a `store_pull_query` rebuild precisely when load
   is highest). Now propagates the error so the atomic stays at its prior value.

5. **Refresh failure cooldown (Claude P2).** Storing `0` removed the rate
   limit entirely; every subsequent pull spawned a new refresh task. Leave
   `LAST_REFRESH_MICROS` at `now_us` (already written by the CAS) so the
   natural interval acts as the cooldown.

6. **Visibility + duplication (Pi P2).** Mark `make_pull_query_fairness` as
   `pub(crate)`. Move the duplicated `BASE_URL host == app.windmill.dev`
   parser into `windmill-common::worker::is_cloud_production_host` and share
   it between the API setter and the runtime path.

Verified locally:
- `POST /api/settings/global/workspace_fairness_enabled` → 400 (per-key gate)
- `PUT /api/settings/instance_config` with fairness key → 400 (bulk gate)
- `cargo check --workspace --features=private,enterprise,quickjs` — clean

Refs WIN-1982.

* fix(queue): second round of CI review nits on workspace fairness

Three issues raised by the Codex/Claude re-review of commit 0b38ff2:

1. Non-cloud deletes were rejected (Codex P2). The cloud gate ran before
   the Null / empty-string deletion branches in both `set_global_setting_internal`
   and the bulk `set_instance_config`. A self-hosted instance that inherited
   stale `workspace_fairness_*` rows from a cloned cloud DB couldn't clear
   them through the API — the rows stayed in `global_settings` and continued
   to show up in the YAML export. Now the gate only blocks upserts; Null /
   empty-string deletes pass through on any host.

2. Deleted numeric knobs kept stale runtime values (Codex P2). When a
   cloud admin cleared `workspace_fairness_max_percent`, `..._duration_secs`,
   or `..._min_total_jobs`, the notify-event fired but the numeric loaders
   ignored `Ok(None)` and left the previous in-memory value pinned until
   process restart. Loaders now distinguish three outcomes:
     - `Err(_)`: transient — leave atomic alone (preserves the
       previous-round fix).
     - `Ok(None)` / `Ok(Some(invalid))`: reset to the documented default.
     - `Ok(Some(valid))`: clamp and store.
   Defaults are extracted to `WORKSPACE_FAIRNESS_*_DEFAULT` constants kept
   in sync with the `AtomicU32::new(...)` initialisers in
   `windmill-common/src/worker.rs`.

3. `fairness_active` was `pub` with no cross-crate caller (Claude nit).
   Tightened to module-private.

Verified locally on this non-cloud instance:
  POST .../workspace_fairness_enabled  body=null  → 200 (delete passes)
  POST .../workspace_fairness_enabled  body=true  → 400 (set blocked)
  PUT .../instance_config              {}         → 200 (no-op passes)
  PUT .../instance_config  with fairness key      → 400 (bulk set blocked)

Skipped the partial index on `v2_job_queue WHERE running = true` that
Claude flagged as a residual nit — queue stays under 50k rows per the
operator's measurement, so the seq-scan cost (~10 ms × 0.5 qps =
~0.5% of a DB core) is well below the noise floor and the index isn't
worth the maintenance cost on job transitions.

Refs WIN-1982.

* chore(main): release 1.708.0 (#9304)

* chore(main): release 1.708.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* feat: add copy button to Path component (#9311)

* feat: plug global chat drafts into userdraft (#9291)

* refactor: move global chat drafts to userdraft

* feat: share script and flow drafts with editors

* feat: share trigger drafts with editors

* feat: share raw app drafts with editor

* feat: share resource drafts with editors

* docs: rename global chat drafts copy

* feat: add global chat draft discard tool

* fix: resolve global chat editor draft paths

* fix: remove editor draft path resolver

* feat: track live editor drafts in userdraft

* fix: snapshot live userdraft reads

* chore: checkpoint pending global draft changes

* fix: address global draft review issues

* fix: defer raw app draft persistence

* docs: remove pr investigation docs

* fix: persist live global draft writes

* refactor: move bedrock proxy handling to windmill-ai (#9309)

* refactor: move bedrock proxy handling to windmill-ai

* docs: track ai refactor follow-ups

* fix(auth): filter resource/variable listings by token scope (WIN-1981) (#9302)

A token scoped to a single resource (e.g. `resources:read:u/alice/foo`)
could call `GET /api/w/{w}/resources/list_search` and receive `path` and
`value` for unrelated resources in the workspace. Route-level scope
checks only validate `domain:action`; per-resource handlers do a
`check_scopes` against the path, but the listing endpoints did not —
leaking integration credentials, API keys, and other secrets stored as
resource values to narrowly-scoped tokens.

Add `build_scope_path_predicate` to `windmill-api-auth` (mirrors
`check_scopes` semantics but parses the token's scopes once, suitable
for filtering many rows). Apply it to `list_search_resources`,
`list_resources`, `list_names` (resources) and `list_variables`
(non-secret value leak), so a scope-restricted token only ever sees the
paths it is authorized to read. Unscoped tokens and tokens whose only
scopes are `if_jobs:filter_tags:*` are unaffected.

Includes regression tests covering: unscoped, tag-filter-only,
single-resource, wildcard, wrong-domain, and write-implies-read.

Fixes WIN-1981

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* audit-log workspace-fairness cap transitions (#9306)

* feat(queue): audit-log workspace-fairness cap transitions

When the cloud per-workspace fairness mechanism adds a workspace to the
capped set or releases one, write `workspace_fairness.capped` /
`workspace_fairness.uncapped` audit-log entries to the affected workspace.
The cluster admin can review the full timeline from the `admins` workspace
audit view with `all_workspaces=true`; per-workspace owners see their own
events in their normal audit list.

Only the per-cycle refresh winner emits entries (matching where the heavy
aggregation runs), so a fleet of N workers does not produce N duplicates
per transition. The diff is computed against the value already in
`background_task_state` rather than the winner's in-memory cache, so a
freshly-restarted process winning the claim does not spuriously emit
"newly capped" entries for workspaces that were already capped before it
started.

Audit writes are best-effort: failures are logged via tracing and do not
abort the refresh cycle.

Fixes WIN-1984

* feat(queue): scope fairness audit to admins workspace + queue-metrics pane

- Write `workspace_fairness.capped` / `workspace_fairness.uncapped` to the
  `admins` workspace (was: per-affected-workspace) with the affected
  workspace_id moved to the `resource` field. Cluster admins now get the
  full timeline in one place without `all_workspaces=true`.
- Add `GET /workers/workspace_fairness_events` returning the last 100
  events. Cloud-gated (returns `[]` on non-cloud) and devops-only.
- Add a `WorkspaceFairnessEvents` Section to the Queue Metrics drawer,
  rendered only when `isCloudHosted()` is true. Shows time / event
  badge / workspace / parameters with a refresh button.

Fixes WIN-1984

* feat(ai-chat): expand chat question answers (#9310)

* feat(ai-chat): align footer bar + DropdownV2 mode/autonomy selectors (#9308)

* feat(ai-chat): align footer bar, use DropdownV2 for mode/autonomy selectors

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

* feat(dropdown): add `selected` item prop rendering a trailing check

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

* style(ai-chat): add small spacing between chat input and footer bar

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

* feat(ai-chat): always offer the 3 autonomy options in the auto-accept picker

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

* fix(ai-chat): default autonomy mode to auto-accept on

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

* refactor(ai-chat): use Button component for footer dropdown triggers

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

* style(ai-chat): use a hand icon for the auto-accept-off autonomy state

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

* style(ai-chat): use subtle Button variant for mode and model selectors

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

* style(ai-chat): tighten spacing between input and footer bar

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

* feat(ai-chat): reword autonomy levels as ask/auto-accept/bypass permissions

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

* feat(button): add 2xs unified size with tighter padding

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

* feat(ai-chat): compact footer bar — 2xs buttons, AtSign context icon, short Yolo label, discreet model

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

* style(ai-chat): widen the permission selector dropdown

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

* fix(dropdown): group shortcut + selected check to avoid ml-auto collision

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

* test(ai-chat): cover getPersistedAutonomyMode default; clarify default comment

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(raw_apps): tab-based editor surface with split-with-preview (#9273)

* feat(raw_apps): custom tab system for source / runnable / preview

Replaces the fixed split-pane layout with a tab bar inside the editor
area. Each frontend file is a tab, each selected runnable is a tab,
and the Preview is pinned to the right (non-closable). Tabs are an
alternative discoverability surface to the sidebar — both stay
functional, but tabs make navigation viable on small screens with
the sidebar collapsed.

A "Split with Preview" toggle in the tab bar's trailing slot pairs
the active tab with the preview side-by-side for wide-screen
multitasking. The toggle hides when Preview is already the active
tab.

The UI Builder, runnable editor, and preview iframe all stay mounted
across tab switches (toggled via `display`) — no bundler restarts, no
preview state loss, no editor remounts.

- New common/tabs/DraggableTabs.svelte: reusable tab strip with
  drag-reorder (@windmill-labs/svelte-dnd-action), pinned-left/right
  slots excluded from the drag zone, hover-revealed X close, middle-
  click close, keyboard navigation (arrows / Enter / Backspace),
  and a `trailing` snippet for inline toolbar add-ons.
- raw_apps/RawAppEditor.svelte:
  - Tab state (`tabs`, `activeTabId`, `splitWithPreview`) lives in
    Windmill. Persisted in localStorage keyed by workspace + app path.
  - Sidebar file clicks (`handleSelectFile`) and runnable selection
    (`selectedRunnable` via `bind:`) are mirrored into tabs via an
    effect — the sidebar interaction is otherwise untouched.
  - Listener augmented: `setActiveDocument` backfills tabs for files
    VS Code opens by itself; `setFiles` / `runnables` updates drop
    stale tabs.
  - Bundler / inspector / rebuild toolbar moves into the tab bar's
    trailing slot — always visible regardless of active tab.

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

* feat(raw_apps): modern tab styling + resizable split-with-preview

Two polish passes on the new tab system:

DraggableTabs styling:
- Remove the bottom border on the tab strip + the accent-coloured
  border-b-2 on the active tab. The active tab now shares the
  surface background with the content area below it, so the
  boundary visually "disappears" — modern IDE-style tabs.
- Inactive tabs sit on the darker surface-secondary tab strip and
  get a subtle right separator so they don't blur into each other.

Split-with-Preview is now a real resizable Splitpanes:
- The content area is rendered as a Splitpanes (always), with the
  source/runnable slot on the left and the preview iframe on the
  right. The user can drag the divider to adjust the ratio when
  the "Split with Preview" toggle is on.
- Iframes never remount across single↔split toggles — pane sizes
  are driven reactively from (activeTabKind, splitWithPreview),
  not by adding/removing the Splitpanes itself.
- The user's preferred split ratio is remembered while they're
  dragging and reapplied next time split is enabled.
- The inner splitter is CSS-hidden in single mode so the toggle
  button stays the single canonical way to flip layouts.

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

* refactor(raw_apps): split mode moves preview tab into the right pane

Cleaner mental model for split-with-preview. Instead of "split the
active tab + always keep the Preview tab around", the Split toggle
now physically moves the Preview tab out of the bar and into a
permanent right pane. When the user toggles split off, the Preview
tab reappears in the bar like any other tab.

- New `displayedTabs` derived: filters out the Preview tab when
  splitWithPreview is on, so the user sees only file/runnable tabs
  in the bar and a dedicated preview pane on the right.
- `toggleSplit` redirects the active tab to the most recent
  file/runnable when the user toggles split on with Preview active,
  so they don't end up staring at an empty left pane.
- Split toggle is now always visible — the user can flip both ways.
  The button label flips between "Pin preview to the right" and
  "Move preview back into a tab" to reflect what's about to happen.
- reorderTabs preserves the Preview tab in the underlying `tabs`
  array even though it's filtered out of the drag set in split mode.

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

* feat(raw_apps): VS Code-style "Preview" header on the right pane

In split mode, the right pane now shows a small "Preview" tab-styled
header anchored at its top-left — making the layout read like a real
VS Code editor split, where each group has its own tab bar.

- Header appears only when `splitWithPreview && activeTabKind !== 'preview'`
  (i.e. when the right pane is meaningfully separate from the left's
  content). In single mode with preview active, the right pane is the
  only thing visible and the main tab bar already labels it.
- The header uses the same styling as an active tab: `bg-surface`
  on a `bg-surface-secondary` strip, h-8, text-xs, no border.
- An X button next to the label toggles split off — equivalent to
  closing the editor in VS Code's split view (preview goes back to
  living as a tab in the main bar).

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

* refactor(raw_apps): VS Code-style symmetric tab bars per pane

Restructure the editor area so each pane is a self-contained "editor
group" with its own tab bar at the top. The Splitpanes is now the
topmost element — the divider runs floor-to-ceiling, splitting both
the tab bars and the content.

Layout (left pane = source / runnable, right pane = preview):
- Left pane top: DraggableTabs (file/runnable tabs, Preview tab when
  split is off) + Split-toggle in the trailing slot.
- Right pane top: a custom preview header — "Preview" label styled
  like an active tab on the left + the preview-affecting toolbar
  (bundler, inspector, rebuild) on the right.
- Each pane independently sized via Splitpanes; iframes + the
  runnable panel stay mounted and toggled via `display` so state
  survives every transition.

Trade-off: in single-mode with Preview active (paneA=0), the left
tab bar is hidden along with the left pane. To switch back to a
file tab the user uses the sidebar — which is exactly the
discoverability surface tabs were meant to complement, not replace.

Button placement by semantic ownership:
- Layout control (Split toggle) — left side, with the editor.
- Preview-affecting controls (bundler, inspector, rebuild) — right
  side, with the preview. No close-X on the right; the Split toggle
  on the left is the canonical way to flip layouts.

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

* fix(raw_apps): keep tab bar visible when Preview is active in single mode

The "VS Code-style" restructure put the tab bar inside the left
Pane. When activeTabKind became 'preview' in single mode, the left
pane collapsed to width 0 and the entire tab bar disappeared with
it — leaving the user with no way to switch back to a file tab
except via the sidebar.

Move the main tab bar back above the inner Splitpanes (full width,
always visible). The preview pseudo-header stays inside the right
pane, carrying the bundler / inspector / rebuild toolbar. The
splitter only goes through the content area below the tab bar,
which is acceptable given how much friction the disappearing-tabs
edge case caused.

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

* refactor(raw_apps): per-pane tab bars with mirrored single-mode lists

Replace the single tab bar above the inner Splitpanes with one
DraggableTabs per pane. Splitter now goes floor-to-ceiling through
tabs AND content in split mode.

In single mode both bars mirror the full tab list, so the visible
pane always carries every tab — fixes the bug where activating
Preview hid the tab strip. Clicking Preview while in split mode is
a no-op (Preview is permanently visible in the right pane).

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

* refactor(raw_apps): polish tab strip and sync editor font to text-xs

* feat(raw_apps): move logs overlay onto the preview pane

* refactor(splitpanes): extract pixel-aware minSize helper

* fix(raw_apps): tab hydration loads correct file; closeTab in split mode

* fix(raw_apps): lazy-mount UI Builder iframe + add dev:ui-builder script

* feat(raw_apps): default split view, blue preview tab, fix dnd ghosting

* fix(raw_apps): remove 1px splitter sliver beside preview in single view

* fix(raw_apps): tab scrollbar on hover, fix thumb height + resize staleness

* refactor(raw_apps): don't persist tab/split layout in localStorage

* refactor(raw_apps): derive pane sizes + binding setter instead of effects

* style(raw_apps): trim verbose comments

* feat(raw_apps): accept appendLogs delta from the UI Builder iframe

* fix(raw_apps): exit inspect mode on Escape

* fix(raw_apps): Escape clears lingering inspector selection after pick

* style(raw_apps): accent-selected styling for active tab, bg-surface strip

* fix(raw_apps): address PR review nits (drop debug log, timer/reorder/pane-setter, dev script restore)

* fix(raw_apps): clear inspector overlay on the preview iframe, not the source

* style(raw_apps): neutral tab look (surface-tertiary/text-emphasis selected, text-hint idle)

* chore(raw_apps): bump bundled ui_builder to 61b6fdd

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(raw_apps): bump bundled ui_builder to b4f6219 (#9314)

* skip workspaced-route duplicate checks on cloud (#9305)

* fix(settings): skip workspaced-route duplicate checks on cloud

The pre-write validation hooks for `app_workspaced_route` and
`http_route_workspaced_route` query the DB for cross-workspace duplicates
and fail the save when any are found. On cloud both `custom_path_exists`
(apps) and `route_path_key_exists` (HTTP triggers) already scope lookups
by `workspace_id` regardless of these settings, so duplicates across
workspaces are expected and the validation has no runtime meaning. The
result was that any cloud super-admin attempting to save instance
settings with these toggles set to false received
`Duplicate HTTP route paths detected` even though the setting has no
effect on cloud routing.

Fixes WIN-1983

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

* fix(error): render JsonErr as readable text and return 400

`Error::JsonErr` previously rendered through `#[error("Error: {0:#?}")]`,
leaking Rust's `Debug` output (`Object { "error": String(...), "details":
Array [...] }`) into the HTTP response body, and was bucketed into the
catch-all 500 branch in `IntoResponse`. The result was a 500 status with
a wall of Rust debug syntax in the toast — confusing and user-hostile.

- Bucket `JsonErr` into 400 (Bad Request): every current call site
  (workspaced-route duplicate checks, OAuth client errors, etc.) is a
  client/validation issue, not an internal server fault.
- Add `format_json_err_message` which surfaces the `error` field as the
  headline, summarises `details` (with a `- key=value` per entry), and
  pretty-prints the rest as JSON for unknown shapes. The frontend toast
  now reads e.g.

      Duplicate HTTP route paths detected
      - route_path=a, workspace_id=admins, http_method=post
      - route_path=a, workspace_id=starter, http_method=post

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

* fix(toast): preserve newlines and escape HTML in multi-line errors

The toast renders via `{@html processMessage(message)}`, so server-side
error bodies that span multiple lines (e.g. the duplicate-route response
from the settings endpoint) collapsed into a single line because HTML
treats consecutive whitespace (including `\n`) as a single space.

When the message contains a newline, escape HTML first (defends against
injected markup in server error bodies) and convert `\n` to `<br />` so
multi-line errors stay readable in the toast.

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

* fixup: address CI review feedback

- toast.ts: escape HTML unconditionally. The previous gate on `\n` left
  single-line server error bodies unsafe under {@html}, which cubic
  flagged as P0. The path regex below only inserts a `<span>` around a
  `u/...` or `f/...` capture that can't contain HTML metacharacters, so
  escaping the whole input is the simpler and correct fix.
- error.rs: add unit tests pinning the rendered shape of
  `format_json_err_message` (error+details, error-only, truncation cap,
  non-object fallback to pretty JSON).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(service-accounts): allow choosing role at creation time (#9307)

* [ee] feat(service-accounts): allow choosing role at creation time

Previously, service accounts were hardcoded to operator and could not be
used as the CLI sync user since they had no write access. They also only
counted as 0.5 seat each.

This change:
- Extends `NewServiceAccount` to accept optional `is_admin` / `operator`
  (defaults to `operator=true` for backward compatibility).
- Exposes a role picker in `AddUser.svelte` when creating a service
  account (Operator / Developer / Admin).
- Lets admins update a service account's role from the user list (it
  used to be locked to "Operator" with a tooltip).
- Updates the OpenAPI spec + regenerates the frontend client.

A developer/admin service account counts as 1 seat under the existing
seat-cap logic (operators stay at 0.5).

Companion PR on windmill-ee-private updates the `INSERT INTO usr` to
honour the chosen role.

Fixes WIN-1985

* [ee] feat(service-accounts): wm_deployers opt-in for Dev role

When creating a service account with role=Developer, surface a toggle
"Add to wm_deployers" (recommended). Members of wm_deployers can deploy
on behalf of other users — the typical setup when the service account is
used as the CLI sync / CI deploy identity.

- `NewServiceAccount` gains an optional `add_to_deployers` flag.
- Frontend defaults the toggle to on but only shows it under Developer
  (admins have it implicitly; operators can't deploy).
- Tooltip links to docs.windmill.dev "Run on behalf of".

Companion EE PR updates the handler to INSERT into usr_to_group for
wm_deployers when the flag is set.

Refs WIN-1985

* chore: update ee-repo-ref to 974ed42067d9f63acb42332b671b8c01ffd4b625

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

Previous ee-repo-ref: f7dbc3cc2ba21c396f4828881e3b9d9ab6f50c69

New ee-repo-ref: 974ed42067d9f63acb42332b671b8c01ffd4b625

Automated by sync-ee-ref workflow.

* [ee] fix(service-accounts): unhardcode role in superadmin user list

Two review issues from the merged #9307 / #589:

1. P1 — The global Users tab in #superadmin-settings still pinned every
   service account to "Operator". Now it shows the actual role
   (Admin / Operator / Developer), derived from the SA's usr row.

   - `list_users_as_super_admin`: replaced `true as operator_only` with
     the real `operator` value, and added `is_workspace_admin` from the
     row (NULL for password users since their admin status is
     per-workspace).
   - `global_whoami`: when the email belongs to a service account, look
     up its real `operator` / `is_admin` instead of pinning to operator.
   - `SuperadminSettingsInner.svelte`: drop the hardcoded "Operator"
     badge; render Admin / Operator / Developer using the new fields,
     matching the workspace-level view.

2. P2 — Regenerate the bundled `openapi-deref.{yaml,json}` so the
   `createServiceAccount` body (now exposing `is_admin`, `operator`,
   `add_to_deployers`) and the new `GlobalUserInfo.is_workspace_admin`
   field show up at runtime in `/api/openapi.{yaml,json}`.

Bumps `ee-repo-ref.txt` to the EE follow-up that adds the offline
seat-cap check on `create_service_account`.

Refs WIN-1985

* chore: update ee-repo-ref to b7a6068c1f3dc845e012959268b2426f0de4d697

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

Previous ee-repo-ref: 0b1307c21d1bfd6fb43a03c2ba39d2a8bf8e6470

New ee-repo-ref: b7a6068c1f3dc845e012959268b2426f0de4d697

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>

* fix(jobs): authorization bypass in only_result job updates (WIN-1980) (#9301)

* fix(jobs): enforce anonymous-only guard on `only_result` job updates

The `jobs_u/getupdate/{id}` and `jobs_u/getupdate_sse/{id}` endpoints
accept `only_result=true`. In that branch, `get_job_update_data` queried
the result solely by (workspace_id, job_id) and skipped the
`created_by == "anonymous"` check that the non-only_result path and
adjacent unauthenticated endpoints apply. An unauthenticated requester
who learned a private job UUID could therefore retrieve that job's
output.

Hoist the guard to the top of `get_job_update_data` so both branches are
covered.

Fixes WIN-1980

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

* refactor: fold `created_by` check into existing only_result queries

Avoids the extra `SELECT created_by` round-trip per call by joining
`v2_job` once in the two queries that handled the unauth path and
checking inline. Behavior is identical to the prior commit; the SSE
polling loop now does one query per poll instead of two for
unauthenticated callers.

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

* refactor: cache anonymous_verified across SSE polls

Replace the LEFT JOIN approach with an upfront `SELECT created_by`
guarded by a new `&mut bool anonymous_verified` parameter that mirrors
`early_return_suppressed`. The SSE polling loop now performs the auth
check exactly once per stream rather than per poll, and the data SQL
reverts to its original form so authenticated callers pay no extra
cost. `created_by` cannot change after job creation, so caching the
verification across polls is safe.

Cost matrix:
- Authed (any path): 0 extra queries
- Unauthed one-shot: 1 extra query (unavoidable)
- Unauthed SSE: 1 extra query at stream start, 0 per poll

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

* refactor: scope anonymous check to only_result branch

The non-only_result branch already enforces the `created_by` check via
its main query, so a top-level hoisted check duplicated work for
unauthenticated default-path callers. Move the check inside the
`if only_result.unwrap_or(false)` block — exactly where the bypass
lives — and leave the non-only_result path untouched.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(raw_apps): surface UI Builder build errors over the preview pane (#9316)

* feat(raw_apps): surface UI Builder build errors over the preview pane

Companion to the matching change in the UI Builder repo (see linked PR),
which stops rendering the build-error overlay over the VS Code editor
iframe and instead emits a `buildError` postMessage on every build
(message: undefined on success to clear).

Listen for that message on the existing window message handler (already
source-gated by the UI Builder iframe), store it in a `buildError`
$state, and surface it in two places:

* A red banner over the preview iframe, sibling to the existing logs
  overlay (`top-12 left-2 right-2 z-20` so it clears the tab bar) —
  failures appear right where the user looks for the rendered output.
* The Preview tab's icon and label tint red
  (`text-red-600 dark:text-red-400`, matching the existing error
  convention in raw_apps) — important in single-tab mode where the
  preview pane is collapsed to 0px and the banner would be hidden.
  Done by mapping `leftPaneTabs` / `rightPaneTabs` through a small
  `tintPreviewOnError` helper so the source-of-truth `tabs` array is
  untouched (DnD, ordering, fallback selection keep using the original
  previewTab object).

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

* refactor(raw_apps): use Alert component for the build-error banner

Replace the hand-rolled red div with the shared `Alert` component
(`type="error"`, `title="Build failed"`). The error text stays in a
`<pre>` child so multi-line bundler output keeps its formatting, with
`max-h-60` so a long error never takes over the whole preview pane.

The absolute-positioned wrapper (`top-12 left-2 right-2 z-20`) and the
`role="alert"` move to that wrapper so the Alert component itself stays
unstyled at the call site.

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

* fix(raw_apps): solid bg-surface backing behind build-error Alert

The Alert's error background is semi-transparent in dark mode
(`bg-red-900/40` in `common/alert/model.ts`), so the preview iframe
shows through when the banner is laid over it. Add a `::before`
pseudo on the Alert root with `bg-surface` (matched `rounded-md`,
`-z-10` so it sits behind the red bg) to give it a solid plate.

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

* refactor(raw_apps): isolate banner stacking context, DRY tab tint chain

Two small follow-ups from review:

* Add `isolate` to the build-error banner wrapper so the `before:-z-10`
  pseudo's stacking context is pinned locally — it works today because
  `position: absolute` + `z-20` creates one, but `isolate` makes the
  dependency self-documenting and survives a future refactor that
  removes the explicit `z-20`.
* Extract `tintTabs = (ts) => ts.map(tintPreviewOnError)` so the two
  `$derived` blocks for leftPaneTabs / rightPaneTabs read identically.

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

* chore(raw_apps): trim build-error overlay comments

Per review feedback. Keep only the load-bearing facts (bg-surface backs
the Alert's translucent red, isolate pins the pseudo stacking, the
`message: undefined` clear convention) and drop the prose context that
duplicated what the code already shows.

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

* chore(raw_apps): bump bundled ui_builder to 00c9834

Brings in the postMessage emission from
windmill-labs/windmill-code-ui-builder#9 (merged) so this PR's host
listener actually receives `buildError` events. SHA verified against
the R2 artifact.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(main): release 1.709.0 (#9312)

* chore(main): release 1.709.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* add cli-sync workspace snapshot/load scripts (#9322)

* feat(fixtures): add cli-sync workspace snapshot/load scripts

* fix(fixtures): address review nits (env var password, mktemp, dead refs)

* fix(fixtures): address CI review (SIGPIPE, JSON escaping, doc/code drift)

* feat(queue): stochastic admission + EE availability of workspace fairness algorithm (#9321)

* refactor: unify AI provider credentials (#9317)

* refactor: use provider credentials for worker builders

* refactor: resolve api proxy credentials directly

* fix: lazy load frontend eval modes

* fix(websocket-trigger): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (#9324)

* feat(websocket-trigger): honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY (WIN-1988)

`tokio_tungstenite::connect_async` opens a raw TCP socket and ignores
the standard outbound-proxy env vars, so deployments behind a forward
HTTP proxy can't reach the WebSocket endpoint and Test Connection
times out after 30s.

Add a small `proxy` module that resolves the right proxy URL for the
target host (HTTPS_PROXY for wss://, HTTP_PROXY for ws://, NO_PROXY
exclusions, ALL_PROXY fallback, lowercase variants), opens an HTTP
CONNECT tunnel when one applies, and hands the resulting TcpStream to
`client_async_tls_with_config` for the TLS + WS handshake. Direct
connect remains the default when no proxy env is set.

Unit tests cover NO_PROXY matching, proxy URL parsing (including IPv6
literals and basic-auth userinfo), and the CONNECT handshake itself
against an in-process fake proxy (success, basic-auth header, 407
rejection).

Fixes WIN-1988

* refactor(websocket-trigger): reduce blast radius and reuse existing logic

Follow-up to the proxy support change. Three things:

1. Skip the new code path entirely when no proxy is configured.
   `connect_async_with_proxy` now checks the env-var snapshots up front
   and delegates straight to `tokio_tungstenite::connect_async` if
   neither `HTTP_PROXY` nor `HTTPS_PROXY` is set. Same fall-through
   applies when proxy env is set but `NO_PROXY` excludes the host or
   the proxy URL doesn't parse. Non-proxied deployments now exercise
   exactly the previous code path.

2. Move the `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` env-var snapshots
   from `windmill-worker::worker` into `windmill-common`. The worker's
   `PROXY_ENVS` static now reads from there, and the websocket trigger
   reads from the same source — one place reads the env, one source
   of truth for both call sites.

3. Replace the hand-rolled proxy-URL parser with `url::Url::parse`
   (already a workspace dep, used across the codebase). Half the LoC
   and handles edge cases (userinfo percent-encoding, IPv6 literals,
   path/query stripping) via the well-tested crate instead of by hand.

All 13 proxy unit tests still pass. `cargo check` is clean.

* fix(websocket-trigger): unbreak EE build + trim proxy tests

- Re-export `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` from
  `windmill-worker::worker` (via `pub use windmill_common::...`) so the
  EE `otel_tracing_proxy_ee` module's `use crate::{HTTPS_PROXY, ...}`
  resolves like it did before. Fixes the `check_ee_full` / `cargo_test`
  CI failures from the previous commit.

- Trim the proxy tests to one un-ignored canary
  (`http_connect_tunnel_sends_well_formed_request_and_unwraps_stream`)
  that exercises the actual on-wire CONNECT handshake plus byte-perfect
  tunnel passthrough. The NO_PROXY-matching, URL-parsing, and edge-case
  tunnel tests are kept under `#[ignore]` for manual debugging
  (`cargo test -- --ignored`) since they're either delegated to
  `url::Url::parse` or trivial string matching — low ROI on every CI run.

* chore(main): release 1.710.0 (#9323)

* chore(main): release 1.710.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* fix: improve workspace fairness

* chore(main): release 1.710.1 (#9327)

* chore(main): release 1.710.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>

* prevent windows backend tests from running out of disk space (#9325)

* ignore flaky fairness regression tests in CI (#9328)

`fairness_ignores_zombie_running_rows` and
`fairness_ignores_concurrency_suspended_rows` panic intermittently in CI
(both Linux and Windows runs). Mark them `#[ignore]` until the
underlying flakiness is resolved.

* feat(cli): add object-storage commands and flow test-step (#9326)

* feat(cli): add object-storage commands and flow test-step

* docs(cli): clarify flow test-step doesn't recurse into aiagent tools

* fix(cli): correct failure step id in docs, handle bare flow.yaml path

* refactor(cli): fold flow test-step into flow preview --step (#9330)

* fix(queue): duration-weighted workspace fairness signal (#9329)

* fix(queue): bump EE ref to include worker_ping fairness signal

The current ee-repo-ref.txt pointed to 31cda7c (an unrelated merge
commit on the asset-graph-view-ee branch) instead of ddc9e80, which
contains the workspace-fairness fix that switches the active-share
signal from v2_job_queue.running=true to worker_ping. As a result
cloud was still computing overload off the legacy signal, so a
workspace with many in-flight/suspended flows (lancom01-prod, with
799 suspended flows × 3 v2_job_queue bookkeeping rows each = 2397
running-true rows) was flagged as 95% of cluster activity despite
consuming zero worker slots.

Bumping to ddc9e80 picks up the worker_ping-based signal, which
naturally excludes (a) suspended jobs (no worker pinging them),
(b) zombie running-rows from dead workers, and (c) flow/flownode
orchestration rows that never run on a worker in the first place.

* test(queue): seed v2_job rows + realistic durations for fairness helpers

The new duration-weighted fairness algorithm joins v2_job_queue and
v2_job_completed to v2_job for the `kind` filter (excluding flow
bookkeeping) and reads `duration_ms` for the completed contribution.
Update the test helpers to mirror that schema:

* `insert_completed` now inserts a matching v2_job row (kind=script)
  and writes `duration_ms = 1000` with a 1-second [started_at,
  completed_at] interval, so each completed row contributes ~1
  worker-second when fully inside the refresh window.
* `insert_queued` likewise pre-inserts v2_job, sets `started_at`
  to NOW() - 1s when running=true (so running rows contribute ~1
  worker-second by the time the refresh runs), and seeds
  v2_job_runtime.ping so the running side accrues real-time worker
  seconds (the algorithm bounds end-of-interval by ping).

The zombie/suspended insert helpers are intentionally left without
v2_job rows — the new algorithm's INNER JOIN excludes them, so they
still correctly contribute zero worker-seconds.

* chore(queue): bump EE ref to duration-weighted fairness algorithm

Companion to windmill-ee-private#<TBD>: switch the EE workspace
fairness aggregation from a count-based UNION (worker_ping snapshot
+ v2_job_completed count) to a worker-seconds aggregation sourced
directly from v2_job_queue and v2_job_completed, with kind/suspend
filters mirroring handle_zombie_jobs and per-row defenses against
zombie inflation on both halves.

* chore(queue): bump EE ref for fairness perf fix (inline window_start)

* chore(queue): bump EE ref for fairness perf rewrite (driver-side flip)

* update ee ref

* feat(hub-publish): add backend proxy routes for hub publishing

New workspaced router /api/w/:ws/hub/* forwarding to the Hub:
- POST /publish_draft → POST {HUB}/workspaces (slug/name/summary/readme)
- POST /scripts → POST {HUB}/scripts/add (workspace_slug + content)
- POST /flows | /apps | /raw_apps → corresponding hub endpoints
- POST /scripts/:ask_id/recording, /flows/:flow_id/recording → recording uploads

Auth uses HUB_DEV_TOKEN env var (dev shortcut). All bodies are
serde-typed; the helper forward_to_hub centralises the HTTP call.

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

* feat(deploy-to-hub): wire frontend to backend hub proxy

Replaces the mocked deploy flow with real backend calls:
- confirmBundle() POSTs /hub/publish_draft with sanitized slug,
  name, summary and readme.
- deployAll() pushes selectedItems one by one via pushItem(),
  fetching the live content (Script/Flow/AppService + raw_apps
  get_data) before forwarding to /hub/{scripts,flows,apps,raw_apps}.
- saveRecording() builds the replay-shaped payload expected by
  the Hub (initial_job + events with type: 'CompletedJob') and
  POSTs to /hub/{scripts,flows}/{hub_id}/recording.
- Adds bundleSummary state + TextInput in the drawer.

Hub item ids (ask_id / flow_id) returned by the create calls are
cached client-side to wire later recording uploads.

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

* feat(hub-publish): add /resources proxy route

Forward workspace resource stubs (path + type) to the hub's
/workspaces/{slug}/resources endpoint.

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

* feat(deploy-to-hub): auto-detect resource dependencies from selection

Derive resource dependencies from the $res:/res:// references in the selected
scripts/flows/apps instead of a manual resource list, sync them as empty stubs,
and show them read-only (chip per type, hover for path + which items use it).
Aborts item publish if dependency sync fails to avoid broken fork references.

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

* feat(deploy-to-hub): add project-bundle closure + path-rewrite logic

Pure, unit-tested module (projectBundle.ts) backing the "project = folder"
Hub bundle:
- extractScriptRefs / extractFlowRefs / extractAppRefs: structural detection
  of $res: references (code, static step inputs, script-by-path), hub refs
  classified separately.
- classifyPath / buildPathMap: relocate external u/.. and f/other/.. paths
  under f/<slug>/, with deterministic _2/_3 collision suffixes.
- rewriteContent / rewriteFlowValue / rewriteAppValue: rewrite every ref to
  its relocated path, leaving hub/.. untouched.
- buildProjectBundle: walk the transitive closure of a seed selection
  (scripts pulled in recursively, resources pulled as stubs), returning the
  rewritten items + resource stubs + unresolved list.

14 vitest cases cover classification, extraction, collision suffixing,
partial-match safety, deep-clone, and the closure orchestrator.

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

* feat(deploy-to-hub): publish as relocated project bundle + resource drawer

- deployAll now builds a self-contained project bundle (buildProjectBundle),
  pushing resource types, empty resource stubs at relocated f/<slug>/ paths,
  and the rewritten items — so a fork's references resolve inside the project.
- Resource-dependency detection is unified on the same bundle: the UI list
  (dependencyTypes) is derived from the bundle preview, guaranteeing what's
  shown matches what's pushed. Removes the duplicate in-component detection
  (extractResRefs/refsForItem/resolveResourceSet/typeForResource).
- Input-type deps (schema format: resource-<type>) are synced as types and
  conventional f/<slug>/<type> stubs alongside hardcoded ones.
- Replaces the hardcoded-path warning/fix/block machinery with a read-only
  "Resource dependencies" drawer: per-type usages tagged input vs hardcoded
  path, with an info popover explaining the portability tradeoff.

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

* fix(deploy-to-hub): gate hub publish endpoints + harden trigger detection

- Add ApiAuthed + require_admin to all hub publish handlers; previously any
  workspace-authenticated session could trigger Hub-side writes attributed
  to the URL workspace via the shared HUB_DEV_TOKEN.
- Track per-kind trigger fetch failures (triggerLoadErrors) so an EE-gated
  or transiently failing trigger service no longer silently maps to "0
  triggers"; UI surfaces an amber badge listing the missing kinds and a
  toast warns the operator before publish.
- Add workspaceLoadSeq cancellation so the parallel loadWorkspace +
  loadTriggers stop bleeding stale data when the workspace switches mid
  load.
- Drop the silent effectiveSlug fallback to sanitizeSlug(hubName) when
  the Hub response can't be parsed; abort the publish instead so items
  don't land under a slug the Hub never locked.

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

* feat(deploy-to-hub): thin /triggers proxy to forward trigger bulk-sync to Hub

Mirrors the existing /scripts, /flows, /apps thin proxies. Forwards
{ triggers, workspace_slug } to Hub's POST /workspaces/[slug]/triggers
bulk-replace endpoint, with the same require_admin + HUB_DEV_TOKEN
guardrails. Lets the frontend push trigger stubs in a single round-trip
after the items they reference have landed on the Hub.

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

* feat(deploy-to-hub): push trigger stubs as the final bundle step

After scripts/flows/apps land on the Hub, pushTriggers() builds a
relocation map for the trigger paths, strips operational metadata
(workspace_id, edited_by/at, enabled, last_*/captured_*, capture data,
error_handler_path/args, permissioned_as*) from each config, resolves
script_ask_id / flow_id via the hubItemIds map produced by step 3, and
POSTs the whole set to /api/w/:wsp/hub/triggers. Triggers whose runnable
didn't publish are skipped with a warning rather than emitted as broken
stubs.

Also drops the per-kind trigger-load error surfacing: feature-gated
services (Kafka, NATS, ...) 404 on instances that don't enable them, and
the banner was lighting up on every load for nothing. Errors are
swallowed silently again, matching the pre-review behaviour.

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

* refactor(hub_publish): rename Hub-facing fields and URLs from workspace to project

Matches the windmillhub rename: every body now carries `project_slug`
instead of `workspace_slug`, the draft creation forwards to `/projects`,
and the resource_types/resources/triggers proxies hit
`/projects/{slug}/...`. `HubWorkspaceBody` becomes `HubProjectBody`. The
instance-side `Path(workspace)` extractor and the `workspace` URL
parameter stay because that's still the source tenant's identifier.

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

* ui(deploy-to-hub): user-facing rename from "workspace" to "project"

The Hub-deploy surface now talks about *projects* (the bundle published
to the Hub) instead of *workspaces* (which still means the source
tenant). Tab is "Publish project", header copy mentions "project", the
Hub URL in the breadcrumb points to /projects/<slug>, payload field is
`project_slug`. Internal state names (`workspaceItems`, `workspaceStore`,
`WorkspaceService`, …) stay — they refer to the instance workspace the
items are read from, which has not been renamed.

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

* ui(deploy-to-hub): open-in-tab affordance on each dependency and trigger

Adds a small ExternalLink icon at the far right of every row in the
Resource dependencies drawer (script / flow / app / raw_app) and the
Triggers drawer (per trigger kind, opens the matching list page —
/routes, /schedules, /websocket_triggers, /kafka_triggers, …). Both
buttons open in a new tab scoped to the current $workspaceStore. Sized
to sit after the role badge so the dominant signal (input vs hardcoded
path, script vs flow) stays read first.

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

* feat(deploy-to-hub): proxy raw app embed to the Hub

Add POST /w/{workspace}/hub/raw_apps/{id}/embed forwarding to the Hub so a
shared (public) raw app's external_embed_url can be set/cleared. null is
forwarded (not skipped) so unpublish clears the embed.

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

* feat(deploy-to-hub): bundle raw apps, share live iframe, folder-scoped bundles

- Detect modern raw apps (app table, raw_app=true) and push them to the Hub
  as raw apps: fetch source files + runnables + the compiled bundle (via the
  latest-version bundle secret) and shape them into the raw payload RawAppView
  expects. Fail loudly when no compiled bundle exists.
- Capture the Hub id for raw apps and wire "Share as iframe"/"Unpublish" for
  them (post-bundle, like recordings); re-sync the embed on re-bundle for
  already-public apps. Factor the publish/unpublish flow into setAppShared +
  pushRawAppEmbed helpers.
- Scope bundles to a single required f/<folder>/ (Select instead of MultiSelect)
  so relocated paths stay predictable.

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

* feat(hub): send item path when publishing a project to the hub

Include each item's newPath in the script/flow/app/raw_app publish payloads so
the hub can store the relocated Windmill path.

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

* feat(hub): accept path on publish and proxy project export

Add an optional path field to the publish bodies and a GET
/projects/{slug}/export route that proxies the hub export (admin-only,
authenticated with HUB_DEV_TOKEN).

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

* feat(projects): add project install page

New /projects/install page pulls a hub project's export and re-creates it in
the selected workspace.

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

* feat(deploy-to-hub): let user pick target folder on project import

Add a FolderPicker to the project install page (defaulting to the
project slug, with create-new-folder support) and retarget every
`f/<slug>/` prefix in the bundle — item paths, $res:/script refs,
schedule runnable paths — to the chosen folder in one pass. Ensures
the target folder exists before creating items.

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

* Fix wording

* fix(hub-publish): bind Hub publish/export to the trusted workspace via source_id

Hub publish endpoints ignored the {workspace} path and addressed the Hub
project purely by client-supplied project_slug, forwarding with an
instance-wide HUB_DEV_TOKEN. Any workspace admin could mutate or export
another workspace's Hub project by passing its slug.

Stamp the server-trusted workspace from the path onto every forwarded
request as source_id (body for mutations, query param for export) so the
Hub can enforce that the targeted project belongs to the calling
workspace. Requires the matching Hub-side source_id ownership check.

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

* fix(deploy-to-hub): reset draft/publish state on workspace switch

The workspace-switch effect only reset load-derived state, so phase,
draftItems, recordings, hub/bundle metadata, hubVersion, deploymentStatus,
effectiveSlug and hubItemIds survived a switch — a draft built in one
workspace could publish its items/slug under the next workspace's auth.
Reset the full publish session on switch. Also drop explanatory comments.

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

* fix(deploy-to-hub): pull sub-flows referenced by type: flow steps into the bundle

extractFlowRefs only emitted refs for type: script steps, so a flow calling
an external sub-flow by path was never followed and the published project
was silently incomplete. Add a 'flow' RefKind, emit it for type: flow steps,
recurse on it in buildProjectBundle, and rewrite its path on relocation.

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

* fix(deploy-to-hub): fall back to slug when import folder is whitespace-only

(folderName || slug).trim() let a whitespace-only folder bypass the slug
fallback and trim to an empty target, producing invalid f//... paths and a
failed import. Trim first, then fall back.

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

* fix(hub-publish): validate project slug before interpolating into Hub path

slug/project_slug are caller-controlled and were interpolated straight into
the Hub request path; a crafted value (e.g. ../../admin) could reach an
unintended Hub endpoint after URL normalization. Validate against the
frontend charset (lowercase alphanumerics + hyphens, 3-50 chars) in the four
handlers that put the slug in the path.

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

* fix(deploy-to-hub): use ?tab query param to link to the Apps settings tab

The "Edit in Workspace settings → Apps" link set window.location.hash, but
the settings page derives the active tab from ?tab=..., so the link was a
dead affordance. Navigate with goto('?tab=default_app') instead.

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

* fix(deploy-to-hub): point Open in Hub link at the project slug, not the workspace

hubSlug was derived from $workspaceStore, so the Open in Hub link and badge
used the workspace id instead of the published project slug — navigating to
the wrong (or nonexistent) Hub project. Derive hubSlug from the actual
project slug (effectiveSlug, falling back to sanitizeSlug(hubName)).

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

* Sync hub to instance

* feat(deploy-to-hub): rehydrate project state, wire review flow, bundle trigger resources

- Rehydrate the publish panel from the Hub by source_id on load (phase, slug,
  metadata, items, hub ids, recordings) so refresh no longer loses the draft.
- Map Hub project status to the draft/under_review/live phase; submitForReview
  now persists to the Hub instead of a local stub; drop the unused v{n} version
  display (status is the source of truth).
- Send source_path (original workspace path) per item for recording round-trip.
- Detect resources referenced by triggers, add them to the bundle closure
  (extraResourcePaths) so they appear in dependencies, get stubbed/relocated,
  and rewrite the trigger config path via the full bundle pathMap (no leaked
  private path); show trigger usages in the dependency drawer.
- Review fixes: Array.isArray guards on trigger topic/subject lists; snapshot
  relevantTriggers in deployAll to avoid a mid-deploy folder-switch race;
  index-key the usage list.

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

* fix(deploy-to-hub): keep item summary on rehydrated draft, drop placeholder diff button

Rehydrated draft items now carry their summary (from the Hub) so step 2 shows
the summary like step 1 instead of falling back to the path. Remove the
"Diff vs submitted" button: it only toasted add/remove counts with no view,
which read as broken.

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

* fix(deploy-to-hub): New draft returns to the folder-picker step instead of erroring

In the live phase the folder picker is hidden, so startNewDraft's
selectedFolder guard always failed with "Pick a folder..." and the user had
no way to pick one. Now New draft goes back to step 1 (predeploy) with the
project's folder pre-selected (inferred from the item paths) so the user can
re-bundle.

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

* fix(hub_publish): return 500 not 400 when HUB_DEV_TOKEN missing

* fix(deploy-to-hub): keep internal subfolder paths identity-mapped when bundling

* fix(projects-install): never overwrite existing resources; isolate invalid raw app json

* fix(deploy-to-hub): route raw_app to apps_raw/get and guard openRecord schema race

* refactor(hub_publish): extract hub_token helper, drop duplicated env lookup

* refactor(projects-install): route raw-app and unsupported-trigger failures through record()

* fix(deploy-to-hub): refresh review status from Hub and use configured hub base url

* fix(hub_publish): return 400 not 500 when HUB_DEV_TOKEN is unset

Missing config is a client/config error, not a server fault. Restores the
BadRequest class lost when hub_token() was extracted.

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

* feat(hub_publish): scope Hub projects per folder via workspace:folder source key

* feat(deploy-to-hub): publish per-folder projects from the Folders page

* ui(deploy-to-hub): move phase CTA to the top-right header

* Fable review

* feat(hub_publish): forward the caller's token to the Hub instead of HUB_DEV_TOKEN

* style(windmill-api): cargo fmt fallout in build.rs and lib.rs

* Nit fixes

* Nit fix

* fix: structural project-ref rewrite and deploy-to-hub state fixes

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

* fix: deterministic draft phase fallback when post-deploy rehydrate fails

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

* fix(frontend): skip EE-only native trigger calls on CE to avoid console 404s

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

* feat(deploy-to-hub): fork all trigger kinds on project install

The project install (fork) flow recreated only schedule triggers and
rejected every other kind with "not supported yet". Recreate all trigger
kinds instead, imported disabled (enabled: false → mode disabled).

Kafka, NATS, SQS, GCP and Azure require an Enterprise license, so they are
gated behind enterpriseLicense and reported as "requires Enterprise" on CE
rather than firing backend calls that 404. http, websocket, postgres, mqtt
and email are recreated on CE. The kind-specific config (with retargeted
resource paths) is spread into the create body; explicit path/script_path/
is_flow/summary/enabled win over it. Also carry the schedule summary through.

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

* fix(deploy-to-hub): list CE trigger kinds without an Enterprise license

loadTriggers wrapped http, websocket, postgres, mqtt and email list calls
in eeList, so on CE (no enterpriseLicense) they resolved to [] and never
made it into deploy state — those triggers silently disappeared from the
Hub publish set. Only Kafka, NATS, SQS, GCP and Azure are EE; switch the CE
kinds back to safeList so they are always listed and published. Mirrors the
EE gating used on the project install (fork) side.

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

* fix(deploy-to-hub): reload triggers when EE license hydrates late

loadTriggers captures enterpriseLicense at call time and the main reload
$effect only depends on workspace/folder, guarded by lastLoadedKey. When the
license store hydrates asynchronously after loadTriggers already ran, the EE
trigger kinds (kafka/nats/sqs/gcp/azure) stay empty until the workspace or
folder changes. Add a dedicated $effect that re-fetches triggers on the
license false→true transition, mirroring the sidebar's license-race handling.
prevHadLicense is seeded from the current value so a license already present
at mount doesn't trigger a redundant reload.

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

* fix(deploy-to-hub): token loadTriggers so a late EE reload can't be clobbered

The license-late reload calls loadTriggers with the same workspaceLoadSeq as
the original license-less load, so the workspace guard alone lets both assign
workspaceTriggers. If the earlier (EE-empty) request resolves last, it
overwrites the newer license-aware result and the EE trigger kinds disappear
again. Add a per-invocation triggerLoadSeq token and only let the latest load
assign (and toggle triggersLoading), so a slow earlier request is discarded.

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

* fix(deploy-to-hub): use mode 'disabled' for forked non-schedule triggers

Non-schedule triggers expose `mode` (TriggerMode), not the deprecated
`enabled` flag, in their create body. `enabled: false` happens to still map
to disabled today via the backend's legacy BaseTriggerData field, but relying
on a deprecated path is fragile. Set `mode: 'disabled'` explicitly so imported
http/websocket/postgres/mqtt/native triggers stay disabled. Schedules keep
`enabled: false` (NewSchedule uses the enabled flag).

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

* fix(deploy-to-hub): snapshot workspace + key bundle by kind:path

Address three P1 review findings:

- Project install (fork) read the reactive `workspace` ($derived) across many
  sequential awaits, so a workspace switch mid-import could create the folder in
  one workspace and later items in another. Snapshot the target workspace once at
  the top of install().
- DeployToHub.deployAll re-read $workspaceStore after confirmBundle had already
  created the Hub draft bound to a specific workspace's source_id, so a switch
  during draft creation could publish items to a different workspace. Pass the
  workspace captured by confirmBundle into deployAll instead.
- buildProjectBundle keyed its fetched/queued maps by bare path, silently
  dropping one of two distinct-kind items at the same path (script vs flow). Key
  by `${kind}:${path}` and derive item paths from the fetched values, keeping
  path relocation separate.

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

* style(deploy-to-hub): condense comments

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

* fix(deploy-to-hub): surface backend error body on failed project import

record() only showed `e.message`, which for API errors is the generic status
text ("Bad Request"). Prefer the ApiError `.body` (plain-text reason for
Windmill 4xx) so a failed import reports the actual cause — e.g. a path or
route_path collision — instead of a bare "Bad Request".

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

* fix(deploy-to-hub): close mid-request workspace-switch races

Two follow-ups to the workspace snapshotting:

- confirmBundle captured `workspace` but read selectedItems / relevantTriggers
  / hubSlug only inside deployAll, after the publish_draft await. A workspace
  switch during that request resets those to the new workspace, so deployAll
  would push the new workspace's items into the old workspace's Hub draft.
  Capture workspaceLoadSeq before the request and abort (with a toast) if it
  changed before publishing.
- install() snapshotted `workspace` but still read the reactive `data` after
  the createFolder await; load() can replace `data` on a workspace switch, so
  retarget() could run against a different export than `folder` was derived
  from. Snapshot `data` up-front and use it throughout install().

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

* fix(deploy-to-hub): guard stale load response and mid-publish status writes

- install load() assigned `data`/`folderName` unconditionally, so a slow
  /export for an old ?hub= could overwrite a newer project after navigation.
  Add a load token + captured slug/workspace and only assign if still current.
- deployAll wrote deploymentStatus/hubItemIds incrementally and only checked
  the workspace at the very end. Bail at the top of the per-item loop when the
  active workspace changed, so a mid-publish switch can't keep writing the old
  workspace's item statuses and Hub IDs into the new workspace's live view.

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

* feat(hub-projects): generate and apply datatable migrations on project publish/install (#9977)

* 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>

* feat(hub-projects): generate and apply datatable migrations for projects

Detect datatable assets in a project's scripts/flows/raw apps when
publishing to the Hub, generate a best-effort CREATE TABLE migration per
data table from the source workspace's live schema, and let the publisher
edit/toggle them in the bundle drawer. On import, offer to run the shipped
migrations: recorded (datatable_migrations + _wm_migrations) when the
target data table opted into migrations, otherwise as a one-off preview
job. Missing target data tables are surfaced and skipped.

- backend: POST /hub/migrations proxy forwarding to the Hub
- frontend publish: projectMigrations.ts detection + generation, new
  "Data table migrations" section in DeployToHub
- frontend import: run/skip modal + missing-datatable confirmation
- extract pure SQL-gen from DatatableSchemaDiff.svelte into
  datatableSchemaSql.ts so plain .ts modules can import it

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

* feat(hub-projects): close datatable migration table set over foreign keys

Pull a referenced table's FK targets into the generated migration
transitively, so it creates every table it references (ordered by FK
dependency), and drop any FK whose target still isn't in the set so the
generated SQL always runs.

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

* feat(hub-projects): show Data table dependencies in the publish view

Detect data table usage off the predeploy bundle preview and surface it as
a "Data table dependencies" summary right after "Resource dependencies",
mirroring how resource types and triggers are shown. The editable
migration itself stays in the bundle drawer.

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

* feat(hub-projects): explain un-generated migrations with SQL comments

When a table can't be found in the schema, a data table is referenced as a
whole, or the schema can't be loaded, write a `--` comment describing the
problem into the migration instead of leaving it blank. Partial migrations
keep the CREATE TABLEs that did generate and comment the rest; comment-only
migrations stay disabled. The bundle drawer now always shows the SQL box so
those comments are visible and editable.

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

* feat(hub-projects): review/edit migrations on import + rollback down migration

Replace the plain "run migrations?" confirmation with a review drawer that
previews each runnable migration, lets the user edit the SQL and toggle
which to run, before the import proceeds. When recording an imported
migration, also record a down migration (DROP TABLE of the created tables,
in reverse order) derived from the up SQL, so it can be rolled back; the
derived rollback is previewed in both the publish bundle drawer and the
import review drawer.

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

* nit

* feat(hub-projects): editable Up/Down Monaco editor for migrations

Replace the plain textarea with a Monaco SQL editor split into Up/Down
tabs. The down migration is now generated once as best-effort (DROP TABLE
in reverse creation order) and is fully editable — no longer parsed back
out of the up SQL. The down is threaded through publish → Hub → import
(new project_migration.sql_down) and recorded as code_down when an imported
migration is applied.

- projectMigrations: GeneratedMigration.sql_down generated from the table set
- MigrationSqlEditor.svelte: shared Up/Down tabbed Monaco editor (re-keyed on
  regeneration since Monaco ignores external code changes)
- DeployToHub + install review drawer use it; sql_down pushed/applied
- backend: PublishMigrationBody carries sql_down

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

* fix(hub-projects): generate CREATE TABLE IF NOT EXISTS for project migrations

The FK closure pulls a referenced table's parents into the same transaction
(e.g. `orders` drags in `customers`); those shared parents often already
exist in the target, so a plain CREATE TABLE aborted the whole migration on
the first collision. Emit CREATE TABLE IF NOT EXISTS for project migrations
(via a new opt-in flag on generateMigrationSql, leaving the schema-diff
behavior unchanged) so a pre-existing parent is skipped instead of failing.

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

* fix(hub-projects): key FK ordering by schema-qualified table name

orderByFkDependency keyed its dependency graph by bare table name (and
resolved FK targets with .split('.').pop()), so two same-named tables in
different schemas collapsed and one was dropped from the ordered set and
never created. Key by schema.table like the rest of the pipeline, resolving
FK targets through resolveTable. Also let resolveTable fall back to the bare
table name when a schema-qualified ref's schema doesn't match.

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

* fix(hub-projects): comment out generated down-migration DROP statements

The generated down migration listed DROP TABLE for every table in the FK
closure, including shared parent tables that may have pre-existed in the
target — a rollback could drop a table the project never created (data
loss). Emit all DROP statements commented out with a note, so nothing is
dropped by default; the publisher uncomments the tables this migration
actually owns.

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

* fix(hub-projects): disable Import button during migration review

planMigrations awaits the review / missing-datatable modals before setting
installing = true, so the Import button stayed enabled during review and a
second click launched a concurrent install() (second review drawer,
duplicated item creation). Track a planningMigrations flag, disable the
button on it, and early-return install() if already installing or planning.

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

* fix(hub-projects): toast when migration generation fails

regenerateMigrations cleared the drafts on error, showing "No data table
usage detected" — indistinguishable from a genuine schema-load failure. Add
a toast on the catch so the publisher can tell the two apart.

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

* fix(hub-projects): honor cancel on the missing-data-table warning

planMigrations awaited missingDatatableModal.ask() but ignored its boolean,
so cancelling the "some data tables are missing" warning still proceeded
with the import — the cancel affordance did nothing. Show the warning first
and abort the whole import when the user cancels (planMigrations returns
null; install() early-returns), so they can create the data table(s) and
re-run. Confirming still imports without the missing migrations.

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

* feat(hub-projects): detect data tables from low-code app DB-table config

Low-code apps don't carry a persisted asset list, but the DB-table
component declares its data table and table explicitly: a `oneOf` `type`
config with `selected === 'datatable'` holding `datatable://<name>` and the
table. Walk the app value for those configs so an app that reads a data
table is picked up by the Data table dependencies detection.

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

* Revert "feat(hub-projects): detect data tables from low-code app DB-table config"

This reverts commit 9c43ebd512.

* fix(hub-projects): detect data tables from full-code apps' declaration

Full-code (raw) apps explicitly declare the data tables/tables they use in
value.data.tables (refs like main/customers or main/schema:table), which the
"Data table dependencies" detection missed — it only looked at inline-script
assets. Read the declaration via extractDataConfig/parseDataTableRef. The
bundler previously dropped value.data (kept only files + runnables); include
it so detection sees it and the imported app keeps its declaration, and pass
it through on import.

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

* fix(hub-projects): recompute app policy on project import

Apps imported from a Hub project were created with an empty triggerables_v2
policy, so running any inline component script failed at runtime with
"Path rawscript/<sha> forbidden by policy". The policy is computed client-side
on deploy and stored verbatim by the backend, and import skipped that step;
retargeting also rewrites inline-script content (changing its sha), so a copied
policy would not match either.

Recompute the policy from the retargeted value at import, mirroring the deploy
path: updatePolicy for grid apps, updateRawAppPolicy for raw apps, defaulting
execution_mode to publisher.

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

* nit fix

---------

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

* fix(hub-projects): retarget plain trigger resource paths on import

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

* fix(hub-projects): reset migration drafts on workspace/folder switch

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

* fix(hub-projects): bundle http auth resources, pin drafts during deploy

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

* fix(hub-projects): make generated data table migrations idempotent

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

* Reapply "feat(hub-projects): detect data tables from low-code app DB-table config"

This reverts commit 112844deea.

* fix(hub-projects): create all tables before FK constraints in migrations

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

* fix(hub-projects): reset install state when the hub slug or workspace changes

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

* Revert "Reapply "feat(hub-projects): detect data tables from low-code app DB-table config""

This reverts commit 14abefb4f6.

* fix: dedupe args state duplicated by main merge in AssetGraphDetailsPane

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

* refactor(deploy-to-hub): extract session class keyed by workspace+folder

All DeployToHub state and async operations move into DeployToHubSession
(deployToHubSession.svelte.ts), an immutable-(workspace, folder) state class.
A workspace/folder change replaces the instance and remounts the UI via
{#key} instead of manually resetting ~20 state vars, and in-flight async
work writes to the discarded object instead of racing the new scope. The
workspace-scoped seq counters (workspaceLoadSeq/triggerLoadSeq for
lifecycle, migrationsSeq) collapse into a dispose flag plus intra-session
tokens only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqqWYQR46tcunvPVRfidZS

* refactor(triggers): single shared module for all-kind workspace trigger listing

TRIGGER_KINDS (badge/route/note/resourceField/eeOnly + list call),
listAllWorkspaceTriggers, triggerResourcePath, stripTriggerConfig and
triggerDetails move to $lib/components/triggers/workspaceTriggersList.ts, so
EE-license gating per trigger kind is declared once instead of being re-decided
at each call site. DeployToHubSession consumes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqqWYQR46tcunvPVRfidZS

* refactor(hub-publish): route every endpoint through one validation choke point

HubPublishCtx (a FromRequestParts extractor) is now the only way a handler
reaches the Hub: it performs the admin check, resolves and validates the
workspace:folder source key, and carries the forwarded token — a new endpoint
cannot skip any of it. Project slugs become a ProjectSlug newtype whose only
constructor is validating deserialization (body field or path segment), so
every slug that reaches a Hub URL or payload is valid by construction; the
previously unvalidated slugs in publish_draft/scripts/flows/apps/raw_apps/
embed/recording bodies are now checked too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* refactor(hub-projects): shared bundle format module + per-kind project installer

The Hub export format (types + retargetProjectExport/buildRetargetMap) moves
into projectBundle.ts so publish and install share one definition, with unit
tests for retargeting. projectInstall.ts owns the import: one importer per
item kind with per-item error capture, and trigger creation goes through
createWorkspaceTriggerDisabled in the shared trigger module, which encodes
the per-kind disable semantics (schedules use enabled:false, everything else
mode:'disabled') and EE gating once. The install page shrinks to
orchestration and UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): AMQP kind, trigger handler bundling, import containment

Review-round fixes: register the AMQP trigger kind (CE) in the shared
registry so it lists/bundles/imports like every other kind; stop stripping
error_handler_path/args from trigger configs and bundle + relocate handler
runnables (including schedules' script|flow-prefixed on_* refs) with the
project; resolve full schedule rows on listing (listSchedules is slim) and
spread the exported config on import so cron_version, retry, handlers and
no_flow_overlap survive; refuse per-item any export path that escapes the
selected f/<folder>/ target; and gate the install page's results/done
writes on the load sequence so a stale import can't mark a newly loaded
project as imported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): schedule config hygiene and complete handler bundling

Strip email/is_draft/paused_until from exported trigger configs (the full
schedule row carries owner and runtime state that must not reach the Hub);
bundle and relocate dynamic_skip handler scripts (schedule creation refuses a
missing one, so an unrelocated path breaks the import); exclude and report a
schedule whose detail fetch fails instead of silently exporting the slim row
with default behavior; and seed migration detection with the same
handler-augmented item set as deployment so data tables used only by bundled
handlers get their migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(deploy-to-hub): single guarded publish path, gated on trigger load

publishBundle() owns draft creation + deployment under one synchronously-set
deploying flag, so a double-click can't start two interleaved publishes, and
it refuses to run while triggers are still loading — snapshotting an
incomplete relevantTriggers list would permanently omit triggers, their
handlers and handler-only migrations from the draft. The bundle CTAs disable
while trigger discovery is in flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): block publish on failed trigger discovery, strict schema-qualified table resolution

listAllWorkspaceTriggers now distinguishes a feature-gated 404 (kind not
compiled into the instance — legitimately empty) from a real listing or
detail-fetch failure: failures are surfaced, recorded per kind, and the
session blocks publishing with a visible retry until discovery completes
cleanly, so an incomplete trigger snapshot can't be bundled silently.
resolveTable no longer falls back to a same-named table in another schema
when a qualified ref misses — that generated a migration for an unrelated
table; the miss now produces the existing commented warning instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* docs(openapi): document the 15 hub publish proxy routes

All /w/{workspace}/hub endpoints (draft/items/recordings/resource
types/resources/triggers/migrations/export/submit/by-source) enter the
public API contract with their body schemas derived from the serde structs,
a shared HubProjectSlug schema encoding the slug validation, and passthrough
text responses matching the proxy behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): bundle $res refs nested in trigger configs, nullable trigger payload fields

Trigger dependency collection now scans the full stripped config for
$res:/res:// tokens (schedule args, on_*_extra_args, error_handler_args —
e.g. the built-in Slack handler's channel resource) in addition to the
kind-specific resource field, so those resources enter the bundle path map,
get relocated by rewriteTriggerConfig, export a typed stub, and show up in
the dependency pane. PublishTriggerBody's summary/description/
script_ask_id/flow_id become nullable in the OpenAPI contract, matching
what the publisher actually sends and the Rust Options accept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): flow preprocessor/env refs, no cloud provisioning on import, config containment

Flow extraction and rewriting now cover preprocessor_module (walked like any
other module) and flow_env $res: values, so those dependencies are bundled
and relocated instead of keeping source-workspace paths. GCP/Azure triggers
are refused at import with an actionable message — their create endpoints
manage cloud subscriptions before storing the trigger, even disabled, so
auto-creating them from an import could mutate external infrastructure. The
import containment guard now also validates everything a trigger config
binds to (kind resource field, handler runnables incl. hub/ refs, nested
$res: tokens), closing the path where a crafted export binds a trigger to
assets outside the chosen folder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* refactor(hub-projects): per-kind config allowlists from a full trigger-field audit

Every trigger kind's boundary-crossing config is now an explicit per-kind
allowlist (configFields in TRIGGER_KINDS), derived from a field-by-field
audit of every create type: portableTriggerConfig replaces the blocklist
and is applied on export AND import, so an upstream field addition is
dropped until consciously admitted (no more email-style leaks) and a
crafted export can't inject fields like permissioned_as into create calls.
The audit also surfaced unbundled websocket runnables — $script:/$flow:
URLs and initial-message runnable_result paths are now collected and
relocated — and drops GCP/Azure provisioned identities (subscription ids,
delivery_config with the source instance's endpoint) from exports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): bundle $res refs nested in JSON flow_env values

The worker resolves $res: references inside nested JSON flow_env values
(transform_json walks the full value), so extraction and rewriting now scan
the env's full serialization instead of only top-level strings. Also: the
install-page Enterprise note includes GCP/Azure, the trigger-discovery Retry
button binds to the loading state so clicks can't stack requests, and
extractTriggerConfigResourceRefs no longer splits rewriteTriggerConfig from
its doc comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): scope $script:/$flow: relocation to the websocket url field

The runnable-url form is only meaningful in that one field; remapping it on
every nested config string could corrupt a literal payload that happens to
look like one (e.g. a websocket initial raw_message).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): nested static-transform refs, shared flow walk for migrations, top-level-only url remap

Static input transforms accept arbitrary JSON and the worker resolves $res:
refs nested inside them — extraction and rewriting now scan the full
serialization, preserving the value's type. projectMigrations reuses
projectBundle's allFlowModules instead of carrying its own module walk, so
the preprocessor module (and any future module class) can't diverge between
bundling and migration detection. The websocket $script:/$flow: url remap
applies only at the config's top level, leaving nested url keys in args or
handler payloads untouched. Schedule tag stays excluded by design (a
source instance's worker-group name; a foreign tag queues jobs forever) —
now documented in the allowlist contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): remap prefixed runnable refs only in their known config fields

script/<path> and flow/<path> forms are now rewritten only in the top-level
schedule handler fields (on_failure/on_recovery/on_success), joining the url
field treatment — shape-based remapping on arbitrary strings could rewrite a
literal payload that merely looked like a handler ref. Bare-path exact
matches and $res: tokens remain position-independent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): abort stale-session imports after review, walk failure-module descendants

Confirming a migration review whose project/workspace was switched away from
now aborts with a toast before any write — previously the writes went to the
old workspace with all feedback suppressed by the session guard. And
allFlowModules puts the failure module in the root list so its nested
children (loops/branches inside a failure handler) are expanded like every
other module, for both bundling and migration detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): preserve app share state on Hub draft rehydration

`rehydrateFromHub()` rebuilt `draftItems` from the Hub project payload, which
carries only draft membership, so it dropped each app's `published`/`publicUrl`
and app-table origin. Outside `predeploy` the UI reads `draftItems` exclusively,
so reopening a draft showed a still-public app as unshared and removed its
Unpublish control. Merge the live workspace-item state onto matching drafts after
both `#loadWorkspace` and `rehydrateFromHub` (they race).

Also gate the Share-as-iframe action on `canShareAsIframe`: legacy raw apps live
only in the `raw_app` table, but that flow drives `AppService` (the `app` table)
and fails with "App not found" for them, so the action is now hidden for legacy
entries.

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

* fix(hub-projects): stale-identity import guard, live publish state on drafts, iframe action gating

The install session check now also compares the live slug and workspace to
the captured ones — loadSeq only advances when a new load starts, so
navigating away (workspace or ?hub becoming empty) previously left the
stale migration review able to import into the captured workspace. Draft
items are decorated with the live workspace item's shared-iframe fields
(published/publicUrl/appTable) so a public app still shows as public after
reopening a draft, settling reactively regardless of load order. The
share-as-iframe action is offered only for apps and app-table raw apps —
legacy raw_app entries have no AppService representation and the action
could only fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* refactor(deploy-to-hub): drive share-state merge from one reactive derived

The rebase left two parallel fixes for the same rehydration gap: an
imperative mergeShareState call after each racing load, and a read-time
derived. Keep the pure, tested mergeShareState as the single implementation
and invoke it from the derived — no load-completion call sites to maintain,
and the merge settles whichever load finishes last.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DoHaGJdACgE7RvknRDAb6

* fix(hub-projects): block publish on unresolved refs; contain imported item refs

Address two Codex findings:

- Publish continued after `buildProjectBundle` reported unresolved references
  (a selected root or transitive runnable that failed to fetch, or a resource
  with no resolvable type), shipping a project whose items silently vanished or
  still pointed at the publisher's private source-workspace path. `#deployAll`
  now aborts before any Hub write when the bundle doesn't close, and the bundle
  drawer surfaces the unresolved list and disables "Create bundle".

- `installProject` validated only each item's own path, so a crafted or
  incomplete export could place a script/flow/app inside the target folder while
  its `$res:`/script/flow reference stayed bound to an existing `u/...` or other
  `f/...` asset. Extract each item's live references and reject any that escape
  `f/<folder>/` (hub/ script refs allowed), mirroring the existing trigger-config
  containment check.

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

* fix(hub-projects): contain imported $var refs; dedupe unresolved list

Follow-up to the publish-blocker and import-containment fixes:

- `$var:` references (flow static inputs, flow_env, app values, and trigger
  config fields such as SQS queue_url) were not caught by the containment check,
  which only recognized `$res:`/runnable refs. Retargeting leaves them unchanged,
  so an export with `$var:u/admin/token` imported an item that resolves a
  variable outside the target folder under the runnable's permissions. Scan each
  imported flow/app/trigger for `$var:` tokens and reject out-of-folder ones.
  Scripts are skipped: `$var:` is resolved in job args, not script source.

- `buildProjectBundle` stored bare paths in `unresolved` while keying missing
  items by kind:path, so a script and flow sharing a missing path produced a
  duplicate string. The new keyed unresolved list in the bundle drawer then hit
  Svelte's duplicate-key runtime error instead of rendering the publish blocker.
  Dedupe `unresolved` at the source.

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

* fix(hub-projects): contain $var/$jsonvar imports; retryable partial publish; iframe rollback

Address four Codex findings:

- `$jsonvar:` (secret JSON args) was not contained on import, and scanning the
  serialized flow/app for `$var:` tokens falsely rejected inline-code literals.
  The worker only substitutes a variable when an argument value *is* the
  reference (whole value, walking nested JSON), never a token embedded in code.
  Replace the token scan with a structural whole-value walk (`$var:`/`$jsonvar:`)
  and reject out-of-folder refs in flows, apps, and trigger config. Scripts carry
  no variable args, so they are skipped.

- A partial publish (failed item/trigger/migration write) still transitioned to
  the submit-ready `draft` phase. Stay in the retryable `predeploy` state on any
  failure, keeping the failed items visible, so nothing incomplete can be
  submitted and re-publishing retries every idempotent write.

- `#setAppShared` flipped a raw app public before checking its Hub item id or
  syncing the embed, so a missing id or a failed embed sync left the app publicly
  accessible while reporting failure. Validate the Hub target up front and roll
  the policy back if the embed sync fails.

- `buildProjectBundle` could emit duplicate unresolved paths (a script and flow
  sharing a missing path), breaking the keyed publish-blocker render. Deduped.

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

* fix(hub-projects): full-set trigger sync; count iframe re-sync + URL failures

Address three Codex findings, two of them refinements of the incomplete-publish
gate and iframe-rollback fixes:

- `#pushTriggers` returned early on an empty set, so re-deploying a project after
  removing all its triggers left the previous Hub triggers intact. Always post the
  trigger list (an empty one clears them), mirroring the migrations full-set sync.

- A raw app's post-deploy iframe re-sync failure only toasted; it now increments
  `failures`, so a public app left with a stale embed keeps the draft out of the
  submit-ready phase.

- `#setAppShared` skipped the embed and still returned success when the public URL
  couldn't be resolved, leaving the app anonymous with no usable link. It now rolls
  the policy back and throws when a share has no resolvable URL, alongside the
  existing embed-failure rollback (factored into one helper).

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

* fix(hub-projects): count URL-less iframe re-sync; evict failed preview caches

Two Codex findings, both refinements of earlier fixes:

- The post-deploy iframe re-sync skipped a published raw app whose public URL was
  missing (URL resolution had failed) without counting it, so the re-bundle left
  the app public with a cleared Hub embed yet the draft still became submit-ready.
  Treat a published raw app with no resolvable URL as an incomplete publish and
  count it like a push failure.

- The bundle-preview dependency caches memoized promises that resolve to undefined
  after transient item/resource fetch failures, so fixing or retrying a dependency
  could never clear `bundlePreview.unresolved` and the Create bundle button stayed
  disabled until the session was recreated. Evict a cache entry once it resolves to
  undefined so a later rebuild re-fetches.

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

* fix(hub-projects): keep Unpublish for a public app whose URL didn't resolve

The iframe controls required both `published` and `publicUrl`, so an anonymous app
whose public-URL lookup failed rendered as unshared with only a Share action and no
way to unpublish. Branch the Public badge and Unpublish on `published` alone, gate
the URL-dependent Open/Copy-iframe actions on `publicUrl`, and offer a Retry link
that re-resolves the URL when it is missing.

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

* feat(hub-projects): make $var/$jsonvar dependencies portable on import

Variable references were neither retargeted nor materialized, so a published
project that used a variable broke on import: a renamed-folder import rejected the
containing item (the `$var:` kept the old folder prefix), and a same-folder import
left the reference dangling (the target variable never existed).

Treat variables like resource stubs, fully on the import side (their `$var:`/
`$jsonvar:` refs already travel inside the exported item values):

- `buildRetargetMap` now also relocates the internal variable paths embedded in
  the export's flows/apps/triggers, and `rewriteContent` rewrites `$var:`/
  `$jsonvar:` tokens (kind preserved) for any path in the map — so the publish map,
  which omits variables, is unaffected.
- `installProject` creates an empty secret placeholder for each in-folder variable
  ref, conflict-safe via `existsVariable`, for the importer to fill. Values are
  never shipped. External refs stay rejected by containment.

Custom resource-type definitions (the sibling finding) are intentionally left to
the standardized official Hub resource types, so no schema import is needed.

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

* fix(hub-projects): relocate $var refs structurally, never in inline code

Routing variable retargeting through `rewriteContent` also rewrote `$var:`/
`$jsonvar:` tokens embedded in script source, inline rawscript, and serialized app
strings, so an inert literal sharing a real variable's path was silently altered on
a renamed-folder import — contradicting the whole-string runtime-reference rule.

Relocate variables with a structural walk (`rewriteVarRefsInValue`) that rewrites
only whole-string `$var:`/`$jsonvar:` values (the sole form the worker resolves),
applied to flow/app/trigger values in `retargetProjectExport`; `rewriteContent` is
back to `$res:`-only. Inline code literals are left untouched.

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

* fix(hub-projects): relocate $var refs into the slug at publish

Import-side retargeting assumed exported `$var:`/`$jsonvar:` refs already began with
the project slug, but `buildProjectBundle` never relocated them from the source
folder. Publishing `f/source_folder/...` as slug `my-toolkit` therefore exported
`$var:f/source_folder/key`; import (fromSlug=my-toolkit) left it unchanged and
containment rejected the item.

Collect each item's runtime variable refs, feed them through the same path map that
relocates items/resources into `f/<slug>/`, and structurally rewrite the whole-value
refs — symmetric with the import retarget. The export is now slug-relative whatever
the source folder, and inline-code literals stay untouched.

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

* fix(hub-projects): relocate trigger config $var refs at publish

Item variable refs were relocated into the slug, but triggers publish through a
separate path (`#pushTriggers` → `rewriteTriggerConfig`), which doesn't touch
`$var:`/`$jsonvar:`. Publishing `f/source/...` under a different Hub slug left
schedule args and other config refs pointing at `f/source/...`, and import
containment then rejected the trigger.

Collect each trigger config's whole-string variable refs (`#triggerVarPaths`), feed
them through the bundle path map via a new `extraVarPaths` arg to
`buildProjectBundle`, and structurally rewrite the config on publish. Symmetric with
the item and import-side handling; the import retarget already relocated trigger vars.

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

* docs(hub-projects): correct varContainmentViolation retargeting contract

The comment claimed retargeting doesn't rewrite variable refs; it now relocates a
project's own refs into the target folder, and containment rejects only those left
outside it. Describe the current behavior.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: centdix <40307056+centdix@users.noreply.github.com>
Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
Co-authored-by: Aldrin Jenson <aldrinjenson@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: centdix <centdix@users.noreply.github.com>
Co-authored-by: Alexander Petric <alpetric@users.noreply.github.com>
Co-authored-by: Diego Imbert <70353967+diegoimbert@users.noreply.github.com>
Co-authored-by: Guilhem <guilhemlemouel@gmail.com>
Co-authored-by: Diego Imbert <diego@windmill.dev>
This commit is contained in:
Tristan TR
2026-07-23 13:47:28 +02:00
committed by GitHub
parent fa3644281f
commit 30eedf9ee1
22 changed files with 8057 additions and 210 deletions
+715
View File
@@ -22895,6 +22895,438 @@ paths:
schema:
type: string
/w/{workspace}/hub/publish_draft:
post:
summary: create or update a hub project draft
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubDraft
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishDraftBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/scripts:
post:
summary: publish a script to a hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubScript
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishScriptBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/flows:
post:
summary: publish a flow to a hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubFlow
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishFlowBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/apps:
post:
summary: publish an app to a hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubApp
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishAppBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/raw_apps:
post:
summary: publish a raw app to a hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubRawApp
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishRawAppBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/raw_apps/{id}/embed:
post:
summary: set or clear the embed url of a hub raw app
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubRawAppEmbed
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: id
in: path
required: true
description: hub id of the raw app
schema:
type: integer
format: int64
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RawAppEmbedBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/scripts/{ask_id}/recording:
post:
summary: attach a recording to a hub script
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubScriptRecording
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: ask_id
in: path
required: true
description: hub ask id of the script
schema:
type: integer
format: int64
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RecordingBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/flows/{flow_id}/recording:
post:
summary: attach a recording to a hub flow
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubFlowRecording
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: flow_id
in: path
required: true
description: hub id of the flow
schema:
type: integer
format: int64
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RecordingBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/resource_types:
post:
summary: publish a resource type to a hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubResourceType
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishResourceTypeBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/resources:
post:
summary: publish resource placeholders to a hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubResources
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishResourcesBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/triggers:
post:
summary: publish triggers to a hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubTriggers
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishTriggersBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/migrations:
post:
summary: publish data table migrations to a hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: publishHubMigrations
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishMigrationsBody"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/projects/{slug}/export:
get:
summary: export a hub project
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub and returns the Hub's status code and raw response body.
The folder scope is only needed to re-export the caller's own draft;
approved projects are public, so it is optional here.
operationId: getHubProjectExport
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: slug
in: path
required: true
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
schema:
type: string
minLength: 3
maxLength: 50
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
- name: folder
in: query
required: false
description: folder scoping the Hub project source (`{workspace}:{folder}`)
schema:
type: string
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/projects/{slug}/submit:
post:
summary: submit a hub project draft for review
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: submitHubProject
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: slug
in: path
required: true
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
schema:
type: string
minLength: 3
maxLength: 50
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
- $ref: "#/components/parameters/HubPublishFolder"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
/w/{workspace}/hub/project:
get:
summary: get the hub project linked to a workspace folder
description: |
Requires the caller to be a workspace admin. Forwards the request to the
configured Hub scoped to the `{workspace}:{folder}` source and returns
the Hub's status code and raw response body.
operationId: getHubProjectBySource
tags:
- hubPublish
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/HubPublishFolder"
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
content:
text/plain:
schema:
type: string
components:
securitySchemes:
bearerAuth:
@@ -22943,6 +23375,16 @@ components:
required: true
schema:
type: string
HubPublishFolder:
name: folder
in: query
required: true
description: |
workspace folder scoping the Hub publication: a workspace can publish
one Hub project per folder and the Hub-side source key is
`{workspace}:{folder}`
schema:
type: string
PublicationName:
name: publication
in: path
@@ -31839,3 +32281,276 @@ components:
- name
- owner
- private
HubProjectSlug:
type: string
description: hub project slug (3-50 chars, lowercase alphanumeric and hyphens, no leading/trailing hyphen)
minLength: 3
maxLength: 50
pattern: "^[a-z0-9][a-z0-9-]*[a-z0-9]$"
PublishDraftBody:
type: object
properties:
slug:
$ref: "#/components/schemas/HubProjectSlug"
name:
type: string
summary:
type: string
readme:
type: string
required:
- slug
- name
- summary
PublishScriptBody:
type: object
properties:
summary:
type: string
app:
type: string
description:
type: string
kind:
type: string
content:
type: string
language:
type: string
schema:
type: object
lockfile:
type: string
path:
type: string
source_path:
type: string
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- summary
- app
- content
- language
- project_slug
PublishFlowInner:
type: object
properties:
summary:
type: string
description:
type: string
value:
type: object
schema:
type: object
required:
- summary
- value
PublishFlowBody:
type: object
properties:
flow:
$ref: "#/components/schemas/PublishFlowInner"
apps:
type: array
items:
type: string
path:
type: string
source_path:
type: string
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- flow
- apps
- project_slug
PublishAppBody:
type: object
properties:
app:
type: object
apps:
type: array
items:
type: string
description:
type: string
summary:
type: string
path:
type: string
source_path:
type: string
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- app
- apps
- summary
- project_slug
PublishRawAppBody:
type: object
properties:
raw:
type: string
apps:
type: array
items:
type: string
description:
type: string
summary:
type: string
path:
type: string
source_path:
type: string
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- raw
- apps
- summary
- project_slug
RawAppEmbedBody:
type: object
properties:
external_embed_url:
type: string
nullable: true
description: explicit `null` clears the embed (unpublish)
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- project_slug
RecordingBody:
type: object
properties:
recording:
type: object
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- project_slug
PublishResourceTypeBody:
type: object
properties:
name:
type: string
schema:
type: object
description:
type: string
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- name
- project_slug
PublishResourceBody:
type: object
properties:
path:
type: string
resource_type:
type: string
required:
- path
- resource_type
PublishResourcesBody:
type: object
properties:
resources:
type: array
items:
$ref: "#/components/schemas/PublishResourceBody"
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- resources
- project_slug
PublishTriggerBody:
type: object
properties:
path:
type: string
kind:
type: string
summary:
type: string
nullable: true
description:
type: string
nullable: true
config:
type: object
script_ask_id:
type: integer
format: int64
nullable: true
flow_id:
type: integer
format: int64
nullable: true
required:
- path
- kind
- config
PublishTriggersBody:
type: object
properties:
triggers:
type: array
items:
$ref: "#/components/schemas/PublishTriggerBody"
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- triggers
- project_slug
PublishMigrationBody:
type: object
description: one best-effort data table migration attached to a project (per data table)
properties:
datatable_name:
type: string
sql:
type: string
sql_down:
type: string
description: defaults to an empty string when omitted
enabled:
type: boolean
required:
- datatable_name
- sql
- enabled
PublishMigrationsBody:
type: object
properties:
migrations:
type: array
items:
$ref: "#/components/schemas/PublishMigrationBody"
project_slug:
$ref: "#/components/schemas/HubProjectSlug"
required:
- migrations
- project_slug
+521
View File
@@ -0,0 +1,521 @@
use crate::auth::Tokened;
use crate::db::ApiAuthed;
use crate::HTTP_CLIENT;
use axum::{
extract::{FromRequestParts, Json, Path, Query, RawPathParams},
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
use serde::{Deserialize, Deserializer, Serialize};
use windmill_common::{
error::{to_anyhow, Error},
utils::require_admin,
HUB_BASE_URL,
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/publish_draft", post(publish_draft))
.route("/scripts", post(publish_script))
.route("/flows", post(publish_flow))
.route("/apps", post(publish_app))
.route("/raw_apps", post(publish_raw_app))
.route("/raw_apps/{id}/embed", post(publish_raw_app_embed))
.route(
"/scripts/{ask_id}/recording",
post(publish_script_recording),
)
.route("/flows/{flow_id}/recording", post(publish_flow_recording))
.route("/resource_types", post(publish_resource_type))
.route("/resources", post(publish_resources))
.route("/triggers", post(publish_triggers))
.route("/migrations", post(publish_migrations))
.route("/projects/{slug}/export", get(get_project_export))
.route("/projects/{slug}/submit", post(submit_project))
.route("/project", get(get_project_by_source))
}
#[derive(Deserialize)]
struct HubScope {
folder: Option<String>,
}
fn validate_folder(folder: &str) -> Result<(), Error> {
let ok = !folder.is_empty()
&& folder.len() <= 255
&& folder
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
if ok {
Ok(())
} else {
Err(Error::BadRequest(format!("invalid folder: {folder}")))
}
}
fn source_key(workspace: &str, folder: &str) -> Result<String, Error> {
validate_folder(folder)?;
Ok(format!("{workspace}:{folder}"))
}
fn validate_project_slug(slug: &str) -> Result<(), Error> {
let ok = slug.len() >= 3
&& slug.len() <= 50
&& !slug.starts_with('-')
&& !slug.ends_with('-')
&& slug
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if ok {
Ok(())
} else {
Err(Error::BadRequest(format!("invalid project slug: {slug}")))
}
}
/// A Hub project slug that is valid by construction: deserialization (from a
/// request body or a path segment) is the only way to obtain one and it runs
/// `validate_project_slug`, so no handler can forward or interpolate an
/// unvalidated slug into a Hub URL.
#[derive(Serialize)]
#[serde(transparent)]
struct ProjectSlug(String);
impl<'de> Deserialize<'de> for ProjectSlug {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
validate_project_slug(&s).map_err(serde::de::Error::custom)?;
Ok(ProjectSlug(s))
}
}
impl std::fmt::Display for ProjectSlug {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// The single gate every Hub endpoint goes through: an admin caller, their
/// token (the Hub authenticates it back against this instance's whoami), and
/// the validated `workspace_id:folder` source key scoping ownership Hub-side.
/// Handlers can only reach the Hub via this extractor's methods, so a new
/// endpoint cannot forget the admin check or folder validation.
///
/// A workspace can publish one Hub project per folder. The stable, never-mutated
/// link key is `workspace_id:folder_name` (folder name is the path segment and is
/// never renamed — only display_name changes). `:` is safe: neither workspace ids
/// nor folder names (alphanumeric, underscore, hyphen) contain it.
struct HubPublishCtx {
source_id: Option<String>,
token: String,
}
impl<S> FromRequestParts<S> for HubPublishCtx
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let authed = ApiAuthed::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let tokened = Tokened::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let params = RawPathParams::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let workspace = params
.iter()
.find(|(k, _)| *k == "workspace_id")
.map(|(_, v)| v.to_owned());
let Query(scope) = Query::<HubScope>::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
let build = || -> Result<HubPublishCtx, Error> {
require_admin(authed.is_admin, &authed.username)?;
let workspace = workspace.ok_or_else(|| {
Error::internal_err(
"hub publish route must be nested under /w/{workspace_id}".to_string(),
)
})?;
let source_id = scope
.folder
.as_deref()
.map(|f| source_key(&workspace, f))
.transpose()?;
Ok(HubPublishCtx { source_id, token: tokened.token })
};
build().map_err(IntoResponse::into_response)
}
}
impl HubPublishCtx {
fn require_source(&self) -> Result<&str, Error> {
self.source_id
.as_deref()
.ok_or_else(|| Error::BadRequest("missing folder query param".to_string()))
}
async fn post<T: Serialize>(
&self,
path: &str,
body: &T,
) -> Result<(StatusCode, String), Error> {
forward_to_hub(path, self.require_source()?, &self.token, body).await
}
async fn get(&self, path: &str) -> Result<(StatusCode, String), Error> {
get_from_hub(path, self.require_source()?, &self.token).await
}
/// GET without requiring a folder scope. Only for reads the Hub allows
/// publicly (e.g. exporting an approved project); the empty source id makes
/// the Hub skip the ownership match.
async fn get_maybe_unscoped(&self, path: &str) -> Result<(StatusCode, String), Error> {
get_from_hub(path, self.source_id.as_deref().unwrap_or(""), &self.token).await
}
}
#[derive(Deserialize, Serialize)]
struct PublishDraftBody {
slug: ProjectSlug,
name: String,
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
readme: Option<String>,
}
async fn publish_draft(
ctx: HubPublishCtx,
Json(body): Json<PublishDraftBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post("/projects", &body).await
}
#[derive(Deserialize, Serialize)]
struct PublishScriptBody {
summary: String,
app: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
content: String,
language: String,
#[serde(skip_serializing_if = "Option::is_none")]
schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
lockfile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source_path: Option<String>,
project_slug: ProjectSlug,
}
async fn publish_script(
ctx: HubPublishCtx,
Json(body): Json<PublishScriptBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post("/scripts/add", &body).await
}
#[derive(Deserialize, Serialize)]
struct PublishFlowInner {
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
value: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
schema: Option<serde_json::Value>,
}
#[derive(Deserialize, Serialize)]
struct PublishFlowBody {
flow: PublishFlowInner,
apps: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source_path: Option<String>,
project_slug: ProjectSlug,
}
async fn publish_flow(
ctx: HubPublishCtx,
Json(body): Json<PublishFlowBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post("/flows", &body).await
}
#[derive(Deserialize, Serialize)]
struct PublishAppBody {
app: serde_json::Value,
apps: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source_path: Option<String>,
project_slug: ProjectSlug,
}
async fn publish_app(
ctx: HubPublishCtx,
Json(body): Json<PublishAppBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post("/apps", &body).await
}
#[derive(Deserialize, Serialize)]
struct PublishRawAppBody {
raw: String,
apps: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
source_path: Option<String>,
project_slug: ProjectSlug,
}
async fn publish_raw_app(
ctx: HubPublishCtx,
Json(body): Json<PublishRawAppBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post("/raw_apps", &body).await
}
#[derive(Deserialize, Serialize)]
struct RawAppEmbedBody {
// No skip_serializing_if: `null` must reach the Hub to clear the embed (unpublish).
external_embed_url: Option<String>,
project_slug: ProjectSlug,
}
async fn publish_raw_app_embed(
ctx: HubPublishCtx,
Path((_workspace, id)): Path<(String, i64)>,
Json(body): Json<RawAppEmbedBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post(&format!("/raw_apps/{}/embed", id), &body).await
}
#[derive(Deserialize, Serialize)]
struct RecordingBody {
#[serde(skip_serializing_if = "Option::is_none")]
recording: Option<serde_json::Value>,
project_slug: ProjectSlug,
}
async fn publish_script_recording(
ctx: HubPublishCtx,
Path((_workspace, ask_id)): Path<(String, i64)>,
Json(body): Json<RecordingBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post(&format!("/scripts/{}/recording", ask_id), &body)
.await
}
async fn publish_flow_recording(
ctx: HubPublishCtx,
Path((_workspace, flow_id)): Path<(String, i64)>,
Json(body): Json<RecordingBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post(&format!("/flows/{}/recording", flow_id), &body)
.await
}
#[derive(Deserialize, Serialize)]
struct PublishResourceTypeBody {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
project_slug: ProjectSlug,
}
async fn publish_resource_type(
ctx: HubPublishCtx,
Json(body): Json<PublishResourceTypeBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post(
&format!("/projects/{}/resource_types", body.project_slug),
&body,
)
.await
}
#[derive(Deserialize, Serialize)]
struct PublishResourceBody {
path: String,
resource_type: String,
}
#[derive(Deserialize, Serialize)]
struct PublishResourcesBody {
resources: Vec<PublishResourceBody>,
project_slug: ProjectSlug,
}
async fn publish_resources(
ctx: HubPublishCtx,
Json(body): Json<PublishResourcesBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post(&format!("/projects/{}/resources", body.project_slug), &body)
.await
}
#[derive(Deserialize, Serialize)]
struct PublishTriggerBody {
path: String,
kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
config: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
script_ask_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
flow_id: Option<i64>,
}
#[derive(Deserialize, Serialize)]
struct PublishTriggersBody {
triggers: Vec<PublishTriggerBody>,
project_slug: ProjectSlug,
}
async fn publish_triggers(
ctx: HubPublishCtx,
Json(body): Json<PublishTriggersBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post(&format!("/projects/{}/triggers", body.project_slug), &body)
.await
}
// One best-effort data table migration attached to a project (per data table).
#[derive(Deserialize, Serialize)]
struct PublishMigrationBody {
datatable_name: String,
sql: String,
#[serde(default)]
sql_down: String,
enabled: bool,
}
#[derive(Deserialize, Serialize)]
struct PublishMigrationsBody {
migrations: Vec<PublishMigrationBody>,
project_slug: ProjectSlug,
}
async fn publish_migrations(
ctx: HubPublishCtx,
Json(body): Json<PublishMigrationsBody>,
) -> Result<impl IntoResponse, Error> {
ctx.post(
&format!("/projects/{}/migrations", body.project_slug),
&body,
)
.await
}
// Export is owner-scoped only when re-exporting your own draft; approved
// projects are public, so the folder scope is optional here.
async fn get_project_export(
ctx: HubPublishCtx,
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
) -> Result<impl IntoResponse, Error> {
ctx.get_maybe_unscoped(&format!("/projects/{}/export", slug))
.await
}
async fn get_project_by_source(ctx: HubPublishCtx) -> Result<impl IntoResponse, Error> {
ctx.get("/projects/by_source").await
}
async fn submit_project(
ctx: HubPublishCtx,
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
) -> Result<impl IntoResponse, Error> {
ctx.post(
&format!("/projects/{}/submit", slug),
&serde_json::json!({}),
)
.await
}
// The Hub has no auth of its own: it validates bearer tokens by calling this
// instance's /api/users/whoami. Forwarding the caller's own token logs them in
// on the Hub as themselves (account auto-created on first use).
async fn get_from_hub(
path: &str,
source_id: &str,
token: &str,
) -> Result<(StatusCode, String), Error> {
let url = format!("{}{}", **HUB_BASE_URL.load(), path);
let res = HTTP_CLIENT
.get(&url)
.query(&[("source_id", source_id)])
.bearer_auth(token)
.send()
.await
.map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?;
let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let text = res
.text()
.await
.map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?;
Ok((status, text))
}
async fn forward_to_hub<T: Serialize>(
path: &str,
source_id: &str,
token: &str,
body: &T,
) -> Result<(StatusCode, String), Error> {
let url = format!("{}{}", **HUB_BASE_URL.load(), path);
let mut payload = serde_json::to_value(body).map_err(to_anyhow)?;
let obj = payload
.as_object_mut()
.ok_or_else(|| Error::internal_err("hub publish body must be a JSON object".to_string()))?;
obj.insert(
"source_id".to_string(),
serde_json::Value::String(source_id.to_string()),
);
let res = HTTP_CLIENT
.post(&url)
.bearer_auth(token)
.json(&payload)
.send()
.await
.map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?;
let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let text = res
.text()
.await
.map_err(|e| Error::InternalErr(format!("hub response read failed: {e}")))?;
Ok((status, text))
}
+2
View File
@@ -94,6 +94,7 @@ mod granular_acls;
mod group_history;
mod groups;
mod health;
mod hub_publish;
#[cfg(feature = "private")]
pub mod indexer_ee;
mod indexer_oss;
@@ -656,6 +657,7 @@ pub async fn run_server(
.nest("/volumes", volumes_oss::workspaced_service())
.nest("/workers", windmill_api_workers::workspaced_service())
.nest("/workspaces", workspaces::workspaced_service())
.nest("/hub", hub_publish::workspaced_service())
.nest(
"/data_metrics",
windmill_api_workspaces::data_metrics::workspaced_service(),
@@ -1,195 +1,11 @@
<script module lang="ts">
import type {
TableEditorValues,
TableEditorValuesColumn,
TableEditorForeignKey
} from '$lib/components/apps/components/display/dbtable/tableEditor'
import {
diffTableEditorValues,
type AlterTableValues,
makeAlterTableQueries
} from '$lib/components/apps/components/display/dbtable/queries/alterTable'
import { renderForeignKey } from '$lib/components/apps/components/display/dbtable/queries/dbQueriesUtils'
import type { GetDatatableFullSchemaResponse } from '$lib/gen'
export type DatabaseSchema = Record<string, Record<string, TableEditorValues>>
export function apiSchemaToEditorSchema(
apiSchema: GetDatatableFullSchemaResponse
): DatabaseSchema {
const result: DatabaseSchema = {}
for (const [schemaName, tables] of Object.entries(apiSchema)) {
result[schemaName] = {}
for (const [tableName, table] of Object.entries(tables as Record<string, any>)) {
if (!table || typeof table !== 'object') continue
result[schemaName][tableName] = {
name: table.name ?? tableName,
columns: (table.columns ?? []).map(
(c: any): TableEditorValuesColumn => ({
name: c.name,
datatype: c.datatype,
primaryKey: c.primary_key ?? c.primaryKey,
defaultValue: c.default_value ?? c.defaultValue,
nullable: c.nullable
})
),
foreignKeys: (table.foreign_keys ?? table.foreignKeys ?? []).map(
(fk: any): TableEditorForeignKey => ({
targetTable: fk.target_table ?? fk.targetTable,
columns: (fk.columns ?? []).map((col: any) => ({
sourceColumn: col.source_column ?? col.sourceColumn,
targetColumn: col.target_column ?? col.targetColumn
})),
onDelete: (fk.on_delete ?? fk.onDelete ?? 'NO ACTION') as
| 'CASCADE'
| 'SET NULL'
| 'NO ACTION',
onUpdate: (fk.on_update ?? fk.onUpdate ?? 'NO ACTION') as
| 'CASCADE'
| 'SET NULL'
| 'NO ACTION',
fk_constraint_name: fk.fk_constraint_name
})
),
pk_constraint_name: table.pk_constraint_name
}
}
}
return result
}
export type TableDiff = {
schemaName: string
tableName: string
kind: 'added' | 'removed' | 'modified'
operations?: AlterTableValues
}
export type DatatableDiff = {
datatableName: string
aheadChanges: TableDiff[]
behindChanges: TableDiff[]
originalSchema: DatabaseSchema
parentSchema: DatabaseSchema
forkSchema: DatabaseSchema
}
export function diffDatabaseSchemas(
original: DatabaseSchema,
current: DatabaseSchema
): TableDiff[] {
const diffs: TableDiff[] = []
const allSchemas = new Set([...Object.keys(original), ...Object.keys(current)])
for (const schemaName of allSchemas) {
const origTables = original[schemaName] ?? {}
const currTables = current[schemaName] ?? {}
const allTables = new Set([...Object.keys(origTables), ...Object.keys(currTables)])
for (const tableName of allTables) {
const origTable = origTables[tableName]
const currTable = currTables[tableName]
if (!origTable && currTable) {
diffs.push({ schemaName, tableName, kind: 'added' })
} else if (origTable && !currTable) {
diffs.push({ schemaName, tableName, kind: 'removed' })
} else if (origTable && currTable) {
const currWithInitial: TableEditorValues = {
...currTable,
columns: currTable.columns.map((col) => ({
...col,
initialName: col.name,
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
}))
}
const origTableTransformed: TableEditorValues = {
...origTable,
columns: origTable.columns.map((col) => ({
...col,
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
}))
}
const diff = diffTableEditorValues(origTableTransformed, currWithInitial)
if (diff.operations.length > 0) {
diffs.push({ schemaName, tableName, kind: 'modified', operations: diff })
}
}
}
}
return diffs
}
export function computeDatatableDiff(
datatableName: string,
originalSchema: DatabaseSchema,
parentSchema: DatabaseSchema,
forkSchema: DatabaseSchema
): DatatableDiff {
return {
datatableName,
behindChanges: diffDatabaseSchemas(originalSchema, parentSchema),
aheadChanges: diffDatabaseSchemas(originalSchema, forkSchema),
originalSchema,
parentSchema,
forkSchema
}
}
/** Detect PostgreSQL auto-increment columns and return the serial type + cleaned props.
* e.g. bigint + nextval('seq'::regclass) → BIGSERIAL (no DEFAULT needed) */
function resolveColumnType(c: TableEditorValuesColumn): {
datatype: string
defaultValue: string | undefined
} {
const dv = c.defaultValue ?? ''
if (/^{?nextval\(/.test(dv)) {
const dt = c.datatype?.toLowerCase() ?? ''
if (dt === 'bigint') return { datatype: 'BIGSERIAL', defaultValue: undefined }
if (dt === 'integer' || dt === 'int') return { datatype: 'SERIAL', defaultValue: undefined }
if (dt === 'smallint') return { datatype: 'SMALLSERIAL', defaultValue: undefined }
}
return { datatype: c.datatype, defaultValue: c.defaultValue }
}
export function generateMigrationSql(change: TableDiff, sourceSchema: DatabaseSchema): string {
if (change.kind === 'modified' && change.operations) {
const queries = makeAlterTableQueries(change.operations, 'postgresql', change.schemaName)
if (queries.length === 0) return ''
return 'BEGIN;\n' + queries.join('\n') + '\nCOMMIT;'
}
if (change.kind === 'added') {
const table = sourceSchema[change.schemaName]?.[change.tableName]
if (!table) return ''
const colDefs = table.columns
.map((c) => {
const { datatype, defaultValue } = resolveColumnType(c)
let def = `"${c.name}" ${datatype}`
if (c.nullable === false) def += ' NOT NULL'
if (defaultValue) def += ` DEFAULT ${defaultValue}`
return def
})
.join(',\n ')
const pkCols = table.columns.filter((c) => c.primaryKey).map((c) => `"${c.name}"`)
const pkLine = pkCols.length > 0 ? `,\n PRIMARY KEY (${pkCols.join(', ')})` : ''
const qualifiedName = `"${change.schemaName}"."${change.tableName}"`
let sql = `BEGIN;\nCREATE TABLE ${qualifiedName} (\n ${colDefs}${pkLine}\n);`
for (const fk of table.foreignKeys ?? []) {
const fkSql = renderForeignKey(fk, {
useSchema: true,
dbType: 'postgresql',
tableName: change.tableName
})
sql += `\nALTER TABLE ${qualifiedName} ADD ${fkSql};`
}
sql += '\nCOMMIT;'
return sql
}
if (change.kind === 'removed') {
return `BEGIN;\nDROP TABLE IF EXISTS "${change.schemaName}"."${change.tableName}";\nCOMMIT;`
}
return ''
}
</script>
<script lang="ts">
import {
apiSchemaToEditorSchema,
computeDatatableDiff,
generateMigrationSql,
type DatatableDiff,
type TableDiff
} from './datatableSchemaSql'
import { WorkspaceService } from '$lib/gen'
import { Loader2, ChevronDown, ChevronRight, Plus, Minus, Pencil, Eye } from 'lucide-svelte'
import { Button } from '$lib/components/common'
@@ -34,6 +34,7 @@
deploymentStatus: Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
allSelected?: boolean
emptyMessage?: string
hideSelection?: boolean
children?: Snippet
// Snippets for customization
@@ -64,6 +65,7 @@
deploymentStatus,
allSelected = false,
emptyMessage = 'No items to deploy',
hideSelection = false,
header,
alerts,
selectAllActions,
@@ -150,12 +152,13 @@
{@render alerts()}
{/if}
<!-- Controls row: "Select all" (when there are items) + optional right-side
actions (e.g. a filter toggle). Renders when there are items OR actions are
provided, so a filter that empties the list doesn't take its own toggle with it. -->
<!-- Controls row: "Select all" (when there are items and selection is enabled) +
optional right-side actions (e.g. a filter toggle). Renders when there are
items OR actions are provided, so a filter that empties the list doesn't take
its own toggle with it. -->
{#if items.length > 0 || selectAllActions}
<div class="px-4 py-2 flex items-center justify-between">
{#if items.length > 0}
{#if items.length > 0 && !hideSelection}
<label
class="flex items-center gap-2 text-secondary text-xs"
class:opacity-50={!hasSelectableItems}
@@ -190,15 +193,17 @@
class="sticky top-0 z-10 flex items-center gap-2 px-4 py-1.5 bg-surface-secondary border-b first:rounded-t-md"
title={selectable.length === 0 ? 'No selectable items in this group' : undefined}
>
<Checkbox
checked={selectable.length > 0 && selectedCount === selectable.length}
indeterminate={selectedCount > 0 && selectedCount < selectable.length}
disabled={selectable.length === 0}
title={selectedCount === selectable.length
? `Deselect all in ${group.label}`
: `Select all in ${group.label}`}
onChange={() => toggleGroup(group)}
/>
{#if !hideSelection}
<Checkbox
checked={selectable.length > 0 && selectedCount === selectable.length}
indeterminate={selectedCount > 0 && selectedCount < selectable.length}
disabled={selectable.length === 0}
title={selectedCount === selectable.length
? `Deselect all in ${group.label}`
: `Select all in ${group.label}`}
onChange={() => toggleGroup(group)}
/>
{/if}
{#if group.groupKind === 'folder'}
<Folder size={14} class="text-tertiary shrink-0" />
{:else if group.groupKind === 'user'}
@@ -234,11 +239,11 @@
: item.path}
<Row
isSelectable={isSelectable && !isDeployed}
isSelectable={!hideSelection && isSelectable && !isDeployed}
selectDisabledReason={blockedReason}
selectOnRowClick={true}
alignWithSelectable={true}
disabled={blockedReason ? false : !isSelectable}
selectOnRowClick={!hideSelection}
alignWithSelectable={!hideSelection}
disabled={!hideSelection && (blockedReason ? false : !isSelectable)}
selected={isSelected && !isDeployed}
onSelect={() => handleSelect(item)}
path={showPath ? item.path : ''}
@@ -493,6 +493,10 @@
// restores its input. Guarded on the path so a staging round-trip
// (emit → page → runFormInitialArgs) doesn't re-seed and loop. The read-only
// branch uses PipelineScriptView's own onArgsChange instead.
// Declared before the pre-effect that seeds it: a `$state` referenced by an
// earlier-registered `$effect.pre` hits a TDZ ("Cannot access 'args' before
// initialization") when the pane remounts and the pre-effect runs before this
// line executes.
let args = $state<Record<string, any>>({})
let argsSeedPath: string | undefined = undefined
$effect.pre(() => {
@@ -0,0 +1,220 @@
import type {
TableEditorValues,
TableEditorValuesColumn,
TableEditorForeignKey
} from '$lib/components/apps/components/display/dbtable/tableEditor'
import {
diffTableEditorValues,
type AlterTableValues,
makeAlterTableQueries
} from '$lib/components/apps/components/display/dbtable/queries/alterTable'
import { renderForeignKey } from '$lib/components/apps/components/display/dbtable/queries/dbQueriesUtils'
import type { GetDatatableFullSchemaResponse } from '$lib/gen'
export type DatabaseSchema = Record<string, Record<string, TableEditorValues>>
export function apiSchemaToEditorSchema(apiSchema: GetDatatableFullSchemaResponse): DatabaseSchema {
const result: DatabaseSchema = {}
for (const [schemaName, tables] of Object.entries(apiSchema)) {
result[schemaName] = {}
for (const [tableName, table] of Object.entries(tables as Record<string, any>)) {
if (!table || typeof table !== 'object') continue
result[schemaName][tableName] = {
name: table.name ?? tableName,
columns: (table.columns ?? []).map(
(c: any): TableEditorValuesColumn => ({
name: c.name,
datatype: c.datatype,
primaryKey: c.primary_key ?? c.primaryKey,
defaultValue: c.default_value ?? c.defaultValue,
nullable: c.nullable
})
),
foreignKeys: (table.foreign_keys ?? table.foreignKeys ?? []).map(
(fk: any): TableEditorForeignKey => ({
targetTable: fk.target_table ?? fk.targetTable,
columns: (fk.columns ?? []).map((col: any) => ({
sourceColumn: col.source_column ?? col.sourceColumn,
targetColumn: col.target_column ?? col.targetColumn
})),
onDelete: (fk.on_delete ?? fk.onDelete ?? 'NO ACTION') as
| 'CASCADE'
| 'SET NULL'
| 'NO ACTION',
onUpdate: (fk.on_update ?? fk.onUpdate ?? 'NO ACTION') as
| 'CASCADE'
| 'SET NULL'
| 'NO ACTION',
fk_constraint_name: fk.fk_constraint_name
})
),
pk_constraint_name: table.pk_constraint_name
}
}
}
return result
}
export type TableDiff = {
schemaName: string
tableName: string
kind: 'added' | 'removed' | 'modified'
operations?: AlterTableValues
}
export type DatatableDiff = {
datatableName: string
aheadChanges: TableDiff[]
behindChanges: TableDiff[]
originalSchema: DatabaseSchema
parentSchema: DatabaseSchema
forkSchema: DatabaseSchema
}
export function diffDatabaseSchemas(
original: DatabaseSchema,
current: DatabaseSchema
): TableDiff[] {
const diffs: TableDiff[] = []
const allSchemas = new Set([...Object.keys(original), ...Object.keys(current)])
for (const schemaName of allSchemas) {
const origTables = original[schemaName] ?? {}
const currTables = current[schemaName] ?? {}
const allTables = new Set([...Object.keys(origTables), ...Object.keys(currTables)])
for (const tableName of allTables) {
const origTable = origTables[tableName]
const currTable = currTables[tableName]
if (!origTable && currTable) {
diffs.push({ schemaName, tableName, kind: 'added' })
} else if (origTable && !currTable) {
diffs.push({ schemaName, tableName, kind: 'removed' })
} else if (origTable && currTable) {
const currWithInitial: TableEditorValues = {
...currTable,
columns: currTable.columns.map((col) => ({
...col,
initialName: col.name,
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
}))
}
const origTableTransformed: TableEditorValues = {
...origTable,
columns: origTable.columns.map((col) => ({
...col,
defaultValue: col.defaultValue ? `{${col.defaultValue}}` : undefined
}))
}
const diff = diffTableEditorValues(origTableTransformed, currWithInitial)
if (diff.operations.length > 0) {
diffs.push({ schemaName, tableName, kind: 'modified', operations: diff })
}
}
}
}
return diffs
}
export function computeDatatableDiff(
datatableName: string,
originalSchema: DatabaseSchema,
parentSchema: DatabaseSchema,
forkSchema: DatabaseSchema
): DatatableDiff {
return {
datatableName,
behindChanges: diffDatabaseSchemas(originalSchema, parentSchema),
aheadChanges: diffDatabaseSchemas(originalSchema, forkSchema),
originalSchema,
parentSchema,
forkSchema
}
}
/** Detect PostgreSQL auto-increment columns and return the serial type + cleaned props.
* e.g. bigint + nextval('seq'::regclass) → BIGSERIAL (no DEFAULT needed) */
function resolveColumnType(c: TableEditorValuesColumn): {
datatype: string
defaultValue: string | undefined
} {
const dv = c.defaultValue ?? ''
if (/^{?nextval\(/.test(dv)) {
const dt = c.datatype?.toLowerCase() ?? ''
if (dt === 'bigint') return { datatype: 'BIGSERIAL', defaultValue: undefined }
if (dt === 'integer' || dt === 'int') return { datatype: 'SERIAL', defaultValue: undefined }
if (dt === 'smallint') return { datatype: 'SMALLSERIAL', defaultValue: undefined }
}
return { datatype: c.datatype, defaultValue: c.defaultValue }
}
/**
* SQL for an added table, with the CREATE TABLE and the FK constraints split so
* callers creating several tables can emit every CREATE before any constraint —
* required for circular FKs, where no creation order satisfies inline FKs.
*/
export function generateAddedTableSql(
change: TableDiff,
sourceSchema: DatabaseSchema,
options?: { ifNotExists?: boolean }
): { create: string; constraints: string[] } | undefined {
const table = sourceSchema[change.schemaName]?.[change.tableName]
if (!table) return undefined
const colDefs = table.columns
.map((c) => {
const { datatype, defaultValue } = resolveColumnType(c)
let def = `"${c.name}" ${datatype}`
if (c.nullable === false) def += ' NOT NULL'
if (defaultValue) def += ` DEFAULT ${defaultValue}`
return def
})
.join(',\n ')
const pkCols = table.columns.filter((c) => c.primaryKey).map((c) => `"${c.name}"`)
const pkLine = pkCols.length > 0 ? `,\n PRIMARY KEY (${pkCols.join(', ')})` : ''
const qualifiedName = `"${change.schemaName}"."${change.tableName}"`
const createKeyword = options?.ifNotExists ? 'CREATE TABLE IF NOT EXISTS' : 'CREATE TABLE'
// The target may not have the schema at all (fresh data table import).
const schemaDdl =
change.schemaName !== 'public' ? `CREATE SCHEMA IF NOT EXISTS "${change.schemaName}";\n` : ''
const create = `${schemaDdl}${createKeyword} ${qualifiedName} (\n ${colDefs}${pkLine}\n);`
const constraints: string[] = []
for (const fk of table.foreignKeys ?? []) {
const fkSql = renderForeignKey(fk, {
useSchema: true,
dbType: 'postgresql',
tableName: change.tableName
})
// With IF NOT EXISTS the table may pre-exist with this FK already in
// place; an unconditional ADD would then abort the whole transaction.
// The constraint name is emitted unquoted, so Postgres folds it to
// lowercase — compare against the folded form.
const fkName = options?.ifNotExists
? fkSql.match(/^CONSTRAINT\s+(\S+)/)?.[1]?.toLowerCase()
: undefined
constraints.push(
fkName
? `DO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_constraint\n WHERE conname = '${fkName}' AND conrelid = '${qualifiedName}'::regclass\n ) THEN\n ALTER TABLE ${qualifiedName} ADD ${fkSql};\n END IF;\nEND $$;`
: `ALTER TABLE ${qualifiedName} ADD ${fkSql};`
)
}
return { create, constraints }
}
export function generateMigrationSql(
change: TableDiff,
sourceSchema: DatabaseSchema,
options?: { ifNotExists?: boolean }
): string {
if (change.kind === 'modified' && change.operations) {
const queries = makeAlterTableQueries(change.operations, 'postgresql', change.schemaName)
if (queries.length === 0) return ''
return 'BEGIN;\n' + queries.join('\n') + '\nCOMMIT;'
}
if (change.kind === 'added') {
const gen = generateAddedTableSql(change, sourceSchema, options)
if (!gen) return ''
return `BEGIN;\n${[gen.create, ...gen.constraints].join('\n')}\nCOMMIT;`
}
if (change.kind === 'removed') {
return `BEGIN;\nDROP TABLE IF EXISTS "${change.schemaName}"."${change.tableName}";\nCOMMIT;`
}
return ''
}
@@ -102,7 +102,13 @@
}
}
loadAvailableNativeTriggers()
// Native triggers are EE-only; on CE the `/exists` checks just 404. Gate the
// load by license (reactive, since the license loads asynchronously).
$effect(() => {
if ($enterpriseLicense && $workspaceStore) {
loadAvailableNativeTriggers()
}
})
const triggersCollapsed = useLocalStorageValue(
'windmill_triggers_section_collapsed',
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest'
import {
portableTriggerConfig,
triggerHandlerRefs,
type WorkspaceTrigger
} from './workspaceTriggersList'
const trigger = (kind: string, config: Record<string, unknown>): WorkspaceTrigger =>
({ kind, path: 'f/p/t', script_path: 'f/p/s', is_flow: false, config }) as WorkspaceTrigger
describe('portableTriggerConfig', () => {
it('is an allowlist: unknown and instance-side fields never cross the boundary', () => {
const out = portableTriggerConfig('schedule', {
// portable
schedule: '0 0 * * * *',
cron_version: 'v2',
no_flow_overlap: true,
on_failure: 'script/f/p/handler',
dynamic_skip: 'f/p/skip',
retry: { constant: { attempts: 1 } },
// instance-side / identity — must all be dropped
email: 'owner@corp.com',
edited_by: 'admin',
is_draft: false,
paused_until: '2026-01-01',
enabled: true,
workspace_id: 'ws',
extra_perms: {},
permissioned_as: 'u/admin',
tag: 'gpu-worker',
// a field upstream might add tomorrow — dropped until admitted
some_future_field: 'x'
})
expect(Object.keys(out).sort()).toEqual([
'cron_version',
'dynamic_skip',
'no_flow_overlap',
'on_failure',
'retry',
'schedule'
])
})
it('appends shared behavior fields for non-schedule kinds', () => {
const out = portableTriggerConfig('mqtt', {
mqtt_resource_path: 'f/p/broker',
error_handler_path: 'f/p/handler',
retry: {},
permissioned_as: 'u/admin',
server_id: 'srv-1'
})
expect(Object.keys(out).sort()).toEqual(['error_handler_path', 'mqtt_resource_path', 'retry'])
})
it('returns empty for unknown kinds or missing config', () => {
expect(portableTriggerConfig('nope', { a: 1 })).toEqual({})
expect(portableTriggerConfig('mqtt', null)).toEqual({})
})
})
describe('triggerHandlerRefs', () => {
it('collects schedule handlers including dynamic_skip', () => {
expect(
triggerHandlerRefs(
trigger('schedule', {
on_failure: 'script/f/p/fail',
on_recovery: 'flow/f/p/recover',
dynamic_skip: 'f/p/skip'
})
)
).toEqual([
{ kind: 'script', path: 'f/p/fail' },
{ kind: 'flow', path: 'f/p/recover' },
{ kind: 'script', path: 'f/p/skip' }
])
})
it('collects bare error_handler_path for non-schedule kinds', () => {
expect(triggerHandlerRefs(trigger('mqtt', { error_handler_path: 'u/admin/handler' }))).toEqual([
{ kind: 'script', path: 'u/admin/handler' }
])
})
it('collects websocket runnable url and initial-message runnables', () => {
expect(
triggerHandlerRefs(
trigger('websocket', {
url: '$script:f/p/url_builder',
initial_messages: [
{ raw_message: 'hi' },
{ runnable_result: { path: 'f/p/greeter', args: {}, is_flow: true } }
]
})
)
).toEqual([
{ kind: 'script', path: 'f/p/url_builder' },
{ kind: 'flow', path: 'f/p/greeter' }
])
})
})
describe('triggerHandlerRefs edge cases', () => {
it('returns empty when no handlers are configured', () => {
expect(triggerHandlerRefs(trigger('schedule', { schedule: '0 0 * * * *', timezone: 'UTC' }))).toEqual([])
expect(triggerHandlerRefs(trigger('mqtt', { mqtt_resource_path: 'f/p/broker' }))).toEqual([])
})
it('collects on_success handlers', () => {
expect(triggerHandlerRefs(trigger('schedule', { on_success: 'script/f/p/notify' }))).toEqual([
{ kind: 'script', path: 'f/p/notify' }
])
})
})
@@ -0,0 +1,562 @@
import {
HttpTriggerService,
WebsocketTriggerService,
KafkaTriggerService,
NatsTriggerService,
SqsTriggerService,
MqttTriggerService,
AmqpTriggerService,
GcpTriggerService,
AzureTriggerService,
PostgresTriggerService,
EmailTriggerService,
ScheduleService
} from '$lib/gen'
export type WorkspaceTriggerKind =
| 'http'
| 'websocket'
| 'schedule'
| 'kafka'
| 'nats'
| 'sqs'
| 'mqtt'
| 'amqp'
| 'gcp'
| 'azure'
| 'postgres'
| 'email'
/** One trigger row, normalized across kinds; `config` keeps the raw API row. */
export interface WorkspaceTrigger {
kind: WorkspaceTriggerKind
path: string
script_path: string
is_flow: boolean
summary?: string
config: Record<string, unknown>
}
/**
* Single source of truth for per-kind trigger knowledge shared by surfaces that
* handle every trigger kind (Hub publish/install, exports): display badge, the
* workspace list route, an optional post-import caveat, the config field
* holding the kind's resource path, whether the kind requires an EE license
* (its endpoints 404 on CE), and the list/create calls. Schedules have no
* `create` entry: they disable via `enabled` (not `mode`) and use a dedicated
* body shape, handled in `createWorkspaceTriggerDisabled`.
*/
export const TRIGGER_KINDS: Record<
WorkspaceTriggerKind,
{
badge: string
route: string
note?: string
resourceField?: string
eeOnly?: boolean
/**
* Creating a trigger of this kind manages external cloud state (e.g.
* Pub/Sub or Event Grid subscriptions) before storing it, even with
* `mode: 'disabled'` so it must never be auto-created from an import.
*/
provisionsOnCreate?: boolean
/**
* The kind-specific config fields that cross the workspace boundary
* (export to the Hub AND import from it) an allowlist, so a field
* added upstream is dropped until someone consciously admits it here,
* instead of leaking to the Hub or being injectable from a crafted
* export. Identity/ownership/runtime fields (path, permissioned_as,
* email, enabled, server_id, provisioned subscription ids, ) must
* never be listed. `tag` (schedules) is excluded deliberately: it names
* a worker group of the source instance, and a foreign tag makes
* imported jobs queue forever on a tag no worker serves. Non-schedule
* kinds get the shared behavior fields (error handler, retry) appended
* by `portableTriggerConfig`.
*/
configFields: string[]
list: (
workspace: string,
onError?: (message: string) => void
) => Promise<Array<Record<string, any>>>
create?: (workspace: string, requestBody: any) => Promise<unknown>
}
> = {
http: {
configFields: [
'route_path',
'workspaced_route',
'http_method',
'authentication_resource_path',
'is_async',
'request_type',
'authentication_method',
'is_static_website',
'static_asset_config',
'wrap_body',
'raw_string'
],
badge: 'HTTP',
route: 'routes',
note: 'Webhook URL regenerates on import — re-register with the external service.',
resourceField: 'authentication_resource_path',
list: (workspace) => HttpTriggerService.listHttpTriggers({ workspace }),
create: (workspace, requestBody) =>
HttpTriggerService.createHttpTrigger({ workspace, requestBody })
},
websocket: {
configFields: [
'url',
'filters',
'filter_logic',
'initial_messages',
'url_runnable_args',
'can_return_message',
'can_return_error_result',
'heartbeat'
],
badge: 'WebSocket',
route: 'websocket_triggers',
note: 'Reconnect WebSocket auth after import if external service requires it.',
list: (workspace) => WebsocketTriggerService.listWebsocketTriggers({ workspace }),
create: (workspace, requestBody) =>
WebsocketTriggerService.createWebsocketTrigger({ workspace, requestBody })
},
schedule: {
configFields: [
'schedule',
'timezone',
'args',
'on_failure',
'on_failure_times',
'on_failure_exact',
'on_failure_extra_args',
'on_recovery',
'on_recovery_times',
'on_recovery_extra_args',
'on_success',
'on_success_extra_args',
'ws_error_handler_muted',
'retry',
'no_flow_overlap',
'cron_version',
'dynamic_skip'
],
badge: 'Schedule',
route: 'schedules',
// listSchedules returns slim rows (no args, handlers, cron_version, retry,
// no_flow_overlap) — resolve each to the full schedule so exported configs
// are complete. A schedule whose detail fetch fails is excluded and
// reported: exporting the slim row would silently publish a schedule with
// default behavior instead of its real settings.
list: async (workspace, onError) => {
const rows = await ScheduleService.listSchedules({ workspace })
const full = await Promise.all(
rows.map((r) =>
ScheduleService.getSchedule({ workspace, path: r.path }).catch((e: any) => {
onError?.(
`Failed to load schedule ${r.path} (${e?.message ?? e}) — excluded from the project`
)
return undefined
})
)
)
return full.filter((r): r is NonNullable<typeof r> => r !== undefined)
}
},
kafka: {
configFields: [
'kafka_resource_path',
'group_id',
'topics',
'filters',
'filter_logic',
'auto_offset_reset',
'auto_commit'
],
badge: 'Kafka',
route: 'kafka_triggers',
note: 'Verify Kafka broker access from the importing instance.',
resourceField: 'kafka_resource_path',
eeOnly: true,
list: (workspace) => KafkaTriggerService.listKafkaTriggers({ workspace }),
create: (workspace, requestBody) =>
KafkaTriggerService.createKafkaTrigger({ workspace, requestBody })
},
nats: {
configFields: [
'nats_resource_path',
'use_jetstream',
'stream_name',
'consumer_name',
'subjects'
],
badge: 'NATS',
route: 'nats_triggers',
note: 'Verify NATS connection from the importing instance.',
resourceField: 'nats_resource_path',
eeOnly: true,
list: (workspace) => NatsTriggerService.listNatsTriggers({ workspace }),
create: (workspace, requestBody) =>
NatsTriggerService.createNatsTrigger({ workspace, requestBody })
},
sqs: {
configFields: [
'queue_url',
'aws_auth_resource_type',
'aws_resource_path',
'message_attributes'
],
badge: 'SQS',
route: 'sqs_triggers',
resourceField: 'aws_resource_path',
eeOnly: true,
list: (workspace) => SqsTriggerService.listSqsTriggers({ workspace }),
create: (workspace, requestBody) =>
SqsTriggerService.createSqsTrigger({ workspace, requestBody })
},
mqtt: {
configFields: [
'mqtt_resource_path',
'subscribe_topics',
'client_id',
'v3_config',
'v5_config',
'client_version'
],
badge: 'MQTT',
route: 'mqtt_triggers',
resourceField: 'mqtt_resource_path',
list: (workspace) => MqttTriggerService.listMqttTriggers({ workspace }),
create: (workspace, requestBody) =>
MqttTriggerService.createMqttTrigger({ workspace, requestBody })
},
amqp: {
configFields: ['amqp_resource_path', 'queue_name', 'exchange', 'options'],
badge: 'AMQP',
route: 'amqp_triggers',
note: 'Verify AMQP broker access from the importing instance.',
resourceField: 'amqp_resource_path',
list: (workspace) => AmqpTriggerService.listAmqpTriggers({ workspace }),
create: (workspace, requestBody) =>
AmqpTriggerService.createAmqpTrigger({ workspace, requestBody })
},
gcp: {
configFields: ['gcp_resource_path', 'topic_id', 'delivery_type', 'subscription_mode'],
provisionsOnCreate: true,
badge: 'GCP Pub/Sub',
route: 'gcp_triggers',
note: 'Re-link GCP Pub/Sub subscription after import.',
resourceField: 'gcp_resource_path',
eeOnly: true,
list: (workspace) => GcpTriggerService.listGcpTriggers({ workspace }),
create: (workspace, requestBody) =>
GcpTriggerService.createGcpTrigger({ workspace, requestBody })
},
azure: {
configFields: [
'azure_resource_path',
'azure_mode',
'scope_resource_id',
'topic_name',
'event_type_filters'
],
provisionsOnCreate: true,
badge: 'Azure',
route: 'azure_triggers',
note: 'Re-link Azure Event Grid subscription after import.',
resourceField: 'azure_resource_path',
eeOnly: true,
list: (workspace) => AzureTriggerService.listAzureTriggers({ workspace }),
create: (workspace, requestBody) =>
AzureTriggerService.createAzureTrigger({ workspace, requestBody })
},
postgres: {
configFields: [
'postgres_resource_path',
'replication_slot_name',
'publication_name',
'publication'
],
badge: 'Postgres',
route: 'postgres_triggers',
resourceField: 'postgres_resource_path',
list: (workspace) => PostgresTriggerService.listPostgresTriggers({ workspace }),
create: (workspace, requestBody) =>
PostgresTriggerService.createPostgresTrigger({ workspace, requestBody })
},
email: {
configFields: ['local_part', 'workspaced_local_part'],
badge: 'Email',
route: 'email_triggers',
note: 'Email address regenerates on import.',
list: (workspace) => EmailTriggerService.listEmailTriggers({ workspace }),
create: (workspace, requestBody) =>
EmailTriggerService.createEmailTrigger({ workspace, requestBody })
}
}
export const WORKSPACE_TRIGGER_KINDS = Object.keys(TRIGGER_KINDS) as WorkspaceTriggerKind[]
export interface WorkspaceTriggersListing {
triggers: WorkspaceTrigger[]
/**
* Kinds whose discovery failed or was incomplete. A 404 on a kind's list
* endpoint is NOT a failure (the instance doesn't have that trigger feature
* compiled in); anything else means triggers of that kind may exist but
* couldn't be enumerated, so consumers must not treat the listing as a
* complete snapshot (e.g. publishing should be blocked until a retry).
*/
failedKinds: WorkspaceTriggerKind[]
}
/**
* List every trigger of every kind in the workspace, normalized. EE-only kinds
* are skipped without a license (their endpoints 404 on CE and would flood the
* console). Failures are reported through `opts.onError` and recorded in
* `failedKinds` rather than silently yielding an empty kind.
*/
export async function listAllWorkspaceTriggers(
workspace: string,
opts: { includeEeOnly: boolean; onError?: (message: string) => void }
): Promise<WorkspaceTriggersListing> {
const failedKinds: WorkspaceTriggerKind[] = []
const perKind = await Promise.all(
WORKSPACE_TRIGGER_KINDS.map(async (kind) => {
const def = TRIGGER_KINDS[kind]
if (def.eeOnly && !opts.includeEeOnly) return []
let kindFailed = false
const reportError = (message: string) => {
kindFailed = true
opts.onError?.(message)
}
let rows: Array<Record<string, any>>
try {
rows = await def.list(workspace, reportError)
} catch (e: any) {
if (e?.status !== 404) {
reportError(`Failed to list ${kind} triggers: ${e?.message ?? e}`)
}
rows = []
}
if (kindFailed) failedKinds.push(kind)
return rows.map(
(r): WorkspaceTrigger => ({
kind,
path: r.path,
script_path: r.script_path,
is_flow: r.is_flow ?? false,
summary: r.summary,
config: r
})
)
})
)
return { triggers: perKind.flat(), failedKinds }
}
/**
* Create a trigger in its disabled state the semantics differ per kind
* (schedules use `enabled: false`, every other kind uses `mode: 'disabled'`)
* and are encoded here once so importers can't get them wrong.
*/
export async function createWorkspaceTriggerDisabled(
workspace: string,
trigger: {
kind: string
path: string
script_path: string
is_flow: boolean
summary?: string | null
config?: Record<string, any> | null
},
opts: { hasEeLicense: boolean }
): Promise<unknown> {
const def = TRIGGER_KINDS[trigger.kind as WorkspaceTriggerKind]
if (!def) throw new Error(`trigger kind '${trigger.kind}' not supported yet`)
if (def.eeOnly && !opts.hasEeLicense) {
throw new Error(`trigger kind '${trigger.kind}' requires Enterprise`)
}
if (def.provisionsOnCreate) {
throw new Error(
`${def.badge} triggers manage cloud subscriptions at creation — fill in the imported resource, then re-create this trigger manually`
)
}
// Remote input: only the allowlisted portable slice may reach the create call.
const config = portableTriggerConfig(trigger.kind, trigger.config)
if (trigger.kind === 'schedule') {
// Spread the portable config first so behavioral settings survive the
// import (cron_version, retry, failure/recovery/success handlers,
// no_flow_overlap, …) — restoring only cron+timezone would silently
// change the schedule's semantics once re-enabled.
return ScheduleService.createSchedule({
workspace,
requestBody: {
...config,
path: trigger.path,
schedule: (config.schedule as string) ?? '0 0 * * * *',
timezone: (config.timezone as string) ?? 'UTC',
script_path: trigger.script_path,
is_flow: trigger.is_flow,
enabled: false,
args: (config.args as any) ?? {},
summary: trigger.summary ?? null
}
})
}
// `config` holds only allowlisted kind-specific fields; explicit fields win.
return def.create!(workspace, {
...config,
path: trigger.path,
script_path: trigger.script_path,
is_flow: trigger.is_flow,
summary: trigger.summary ?? null,
mode: 'disabled'
})
}
/** The resource path a trigger's config points at, if its kind has one. */
export function triggerResourcePath(t: WorkspaceTrigger): string | undefined {
const field = TRIGGER_KINDS[t.kind]?.resourceField
const v = field ? (t.config as any)?.[field] : undefined
return typeof v === 'string' && v !== '' ? v : undefined
}
/**
* Runnables a trigger's config references beyond its primary target, so they
* can be bundled alongside it: `error_handler_path` (bare script path) for
* non-schedule kinds, and schedules' `on_failure`/`on_recovery`/`on_success`
* (`script/<path>` or `flow/<path>`) plus `dynamic_skip` (bare script path
* schedule creation refuses a dynamic_skip whose script doesn't exist, so an
* unbundled one would make the import fail).
*/
export function triggerHandlerRefs(
t: WorkspaceTrigger
): Array<{ kind: 'script' | 'flow'; path: string }> {
const c = t.config as any
const out: Array<{ kind: 'script' | 'flow'; path: string }> = []
if (t.kind === 'schedule') {
for (const field of ['on_failure', 'on_recovery', 'on_success']) {
const v = c?.[field]
const m = typeof v === 'string' ? /^(script|flow)\/(.+)$/.exec(v) : null
if (m) out.push({ kind: m[1] as 'script' | 'flow', path: m[2] })
}
if (typeof c?.dynamic_skip === 'string' && c.dynamic_skip !== '') {
out.push({ kind: 'script', path: c.dynamic_skip })
}
} else {
if (typeof c?.error_handler_path === 'string' && c.error_handler_path !== '') {
out.push({ kind: 'script', path: c.error_handler_path })
}
if (t.kind === 'websocket') {
// The URL itself can be a runnable ($script:<path> / $flow:<path>), and
// initial messages can be runnable results.
const um = typeof c?.url === 'string' ? /^\$(script|flow):(.+)$/.exec(c.url) : null
if (um) out.push({ kind: um[1] as 'script' | 'flow', path: um[2] })
for (const msg of Array.isArray(c?.initial_messages) ? c.initial_messages : []) {
const rr = msg?.runnable_result
if (rr && typeof rr.path === 'string' && rr.path !== '') {
out.push({ kind: rr.is_flow ? 'flow' : 'script', path: rr.path })
}
}
}
}
return out
}
// Instance-side metadata that has no meaning outside the source workspace
// (ownership, runtime state). Anything `last_*` or `captured_*` is also
// dropped. Error handlers (error_handler_path/args, schedules' on_* fields)
// are functional config and stay: their runnables are bundled with the
// project and the paths relocated.
// Portable behavior fields shared by every non-schedule kind (schedules carry
// their own handler fields in configFields).
const COMMON_CONFIG_FIELDS = ['error_handler_path', 'error_handler_args', 'retry']
/**
* The portable slice of a trigger config the ONLY fields that cross the
* workspace boundary, in either direction. Applied on export (what reaches
* the Hub) and on import (what a Hub export may feed into a create call), so
* an upstream field addition can't leak out, and a crafted export can't
* inject non-allowlisted fields like `permissioned_as`.
*/
export function portableTriggerConfig(
kind: string,
config: Record<string, unknown> | null | undefined
): Record<string, unknown> {
const def = TRIGGER_KINDS[kind as WorkspaceTriggerKind]
if (!def || !config) return {}
const fields =
kind === 'schedule' ? def.configFields : [...def.configFields, ...COMMON_CONFIG_FIELDS]
const out: Record<string, unknown> = {}
for (const f of fields) {
if (config[f] !== undefined) out[f] = config[f]
}
return out
}
/** Human-readable config facts per kind, for detail panes. */
export function triggerDetails(t: WorkspaceTrigger): Array<{ label: string; value: string }> {
const c = t.config as any
const out: Array<{ label: string; value: string }> = []
const push = (label: string, v: any) => {
if (v != null && v !== '') out.push({ label, value: String(v) })
}
switch (t.kind) {
case 'http':
push('Route', `${(c.http_method ?? '').toUpperCase()} /${c.route_path ?? ''}`)
push('Auth', c.authentication_method)
break
case 'schedule':
push('Cron', c.schedule)
push('Timezone', c.timezone)
break
case 'websocket':
push('URL', c.url)
break
case 'kafka':
push('Resource', c.kafka_resource_path)
push('Group', c.group_id)
push('Topics', (Array.isArray(c.topics) ? c.topics : []).join(', '))
break
case 'nats':
push('Resource', c.nats_resource_path)
push('Subjects', (Array.isArray(c.subjects) ? c.subjects : []).join(', '))
push('Jetstream', c.use_jetstream)
break
case 'sqs':
push('Queue', c.queue_url)
push('Resource', c.aws_resource_path)
break
case 'mqtt':
push('Resource', c.mqtt_resource_path)
push(
'Topics',
(Array.isArray(c.subscribe_topics) ? c.subscribe_topics : [])
.map((x: any) => x?.topic ?? x)
.join(', ')
)
break
case 'amqp':
push('Resource', c.amqp_resource_path)
push('Queue', c.queue_name)
break
case 'gcp':
push('Resource', c.gcp_resource_path)
push('Topic', c.topic_id)
push('Subscription', c.subscription_id)
break
case 'azure':
push('Resource', c.azure_resource_path)
push('Scope', c.scope_resource_id)
push('Subscription', c.subscription_name)
break
case 'postgres':
push('Resource', c.postgres_resource_path)
push('Publication', c.publication_name)
break
case 'email':
push('Email prefix', c.local_part ? `${c.local_part}@…` : undefined)
break
}
return out
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
<script lang="ts">
import SimpleEditor from '../SimpleEditor.svelte'
// Up/Down SQL editor for a data table migration. Two tabs keep the up (CREATE)
// and down (DROP) SQL from cluttering the view; both are editable Monaco.
let {
up = $bindable(),
down = $bindable(),
// Bump to force the Monaco editors to re-mount with fresh code — Monaco does
// not sync external `code` changes, so re-keying is how regenerated SQL shows.
generation = 0
}: { up: string; down: string; generation?: number } = $props()
let tab = $state<'up' | 'down'>('up')
</script>
<div class="flex flex-col gap-1">
<div class="flex gap-1 text-[11px]">
{#each [{ id: 'up', label: 'Up' }, { id: 'down', label: 'Down' }] as t (t.id)}
<button
type="button"
class="rounded px-2 py-0.5 font-medium {tab === t.id
? 'bg-surface-selected text-primary'
: 'text-secondary hover:bg-surface-hover'}"
onclick={() => (tab = t.id as 'up' | 'down')}
>
{t.label}
</button>
{/each}
</div>
{#key generation}
<div class="h-44 overflow-hidden rounded border bg-surface">
{#if tab === 'up'}
<SimpleEditor class="h-full" lang="sql" bind:code={up} small automaticLayout />
{:else}
<SimpleEditor class="h-full" lang="sql" bind:code={down} small automaticLayout />
{/if}
</div>
{/key}
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { canShareAsIframe, mergeShareState, type DeployItem } from './deployToHubSession.svelte'
function item(over: Partial<DeployItem> & Pick<DeployItem, 'key' | 'path' | 'kind'>): DeployItem {
return { rec: 'none', ...over }
}
describe('canShareAsIframe', () => {
it('allows low-code apps and app-table raw apps', () => {
expect(canShareAsIframe(item({ key: 'app:f/a', path: 'f/a', kind: 'app' }))).toBe(true)
expect(
canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true }))
).toBe(true)
})
it('hides the action for legacy raw apps (raw_app table only)', () => {
// Legacy entries from RawAppService carry no appTable flag; AppService can't load them.
expect(canShareAsIframe(item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' }))).toBe(false)
})
it('never offers the action for flows or scripts', () => {
expect(canShareAsIframe(item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' }))).toBe(false)
})
})
describe('mergeShareState', () => {
it('carries live public-share state from workspace items onto matching drafts', () => {
const drafts = [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })]
const workspace = [
item({
key: 'app:f/a',
path: 'f/a',
kind: 'app',
published: true,
publicUrl: 'https://x/app'
})
]
const merged = mergeShareState(drafts, workspace)
expect(merged[0].published).toBe(true)
expect(merged[0].publicUrl).toBe('https://x/app')
})
it('restores the app-table origin so app-table raw apps stay shareable', () => {
const drafts = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app' })]
const workspace = [item({ key: 'raw_app:f/r', path: 'f/r', kind: 'raw_app', appTable: true })]
expect(canShareAsIframe(mergeShareState(drafts, workspace)[0])).toBe(true)
})
it('returns the same reference when nothing changes', () => {
const drafts = [item({ key: 'flow:f/f', path: 'f/f', kind: 'flow' })]
expect(mergeShareState(drafts, drafts)).toBe(drafts)
})
it('leaves drafts without a workspace match untouched', () => {
const drafts = [item({ key: 'app:f/gone', path: 'f/gone', kind: 'app' })]
const merged = mergeShareState(drafts, [item({ key: 'app:f/a', path: 'f/a', kind: 'app' })])
expect(merged).toBe(drafts)
})
})
@@ -0,0 +1,869 @@
import { describe, it, expect } from 'vitest'
import {
classifyPath,
extractScriptRefs,
extractFlowRefs,
extractAppRefs,
buildPathMap,
rewriteContent,
rewriteTriggerConfig,
rewriteFlowValue,
rewriteAppValue,
extractRawAppRefs,
rewriteRawAppContent,
buildProjectBundle,
retargetProjectExport,
collectExportVarPaths,
extractTriggerConfigResourceRefs,
extractVarRefsFromValue,
type ProjectExport,
type FetchedItem,
type ItemRef
} from './projectBundle'
describe('classifyPath', () => {
it('internal for paths under the project folder', () => {
expect(classifyPath('f/proj/db', 'proj')).toBe('internal')
expect(classifyPath('f/proj', 'proj')).toBe('internal')
})
it('hub for hub paths', () => {
expect(classifyPath('hub/16043/discord/send', 'proj')).toBe('hub')
})
it('external for user and other folders', () => {
expect(classifyPath('u/admin/db', 'proj')).toBe('external')
expect(classifyPath('f/other/db', 'proj')).toBe('external')
})
it('does not treat a prefix-only match as internal', () => {
expect(classifyPath('f/project2/db', 'proj')).toBe('external')
})
})
describe('extractScriptRefs', () => {
it('finds $res: and res:// resource refs, deduped', () => {
const c = `const a = "$res:u/admin/db"; const b = "res://f/x/api"; const c2 = "$res:u/admin/db"`
expect(extractScriptRefs(c)).toEqual([
{ kind: 'resource', path: 'u/admin/db' },
{ kind: 'resource', path: 'f/x/api' }
])
})
it('returns nothing when no refs', () => {
expect(extractScriptRefs('export async function main() {}')).toEqual([])
})
})
describe('extractFlowRefs', () => {
it('finds inline-code, static-input, and script-path refs', () => {
const value = {
modules: [
{
id: 'a',
value: {
type: 'rawscript',
content: 'const db = "$res:u/admin/pg"',
input_transforms: {
other: { type: 'static', value: '$res:f/shared/api' },
expr1: { type: 'javascript', expr: 'flow_input.x' }
}
}
},
{
id: 'b',
value: {
type: 'branchone',
branches: [
{
modules: [
{ id: 'c', value: { type: 'script', path: 'u/admin/my_script' } },
{ id: 'd', value: { type: 'script', path: 'hub/123/x/y' } }
]
}
],
default: [{ id: 'e', value: { type: 'rawscript', content: 'no refs' } }]
}
}
]
}
const refs = extractFlowRefs(value)
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
expect(refs).toContainEqual({ kind: 'resource', path: 'f/shared/api' })
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/my_script' })
expect(refs).toContainEqual({ kind: 'script', path: 'hub/123/x/y' })
// a javascript expr (flow_input) is not a hardcoded ref
expect(refs.filter((r) => r.path === 'flow_input.x')).toEqual([])
})
it('finds sub-flow refs from type: flow steps', () => {
const value = {
modules: [
{ id: 'a', value: { type: 'flow', path: 'u/admin/sub_flow' } },
{ id: 'b', value: { type: 'flow', path: 'hub/9/x/y' } }
]
}
const refs = extractFlowRefs(value)
expect(refs).toContainEqual({ kind: 'flow', path: 'u/admin/sub_flow' })
expect(refs).toContainEqual({ kind: 'flow', path: 'hub/9/x/y' })
})
})
describe('buildPathMap', () => {
it('reparents into the project folder keeping the leaf name', () => {
const m = buildPathMap(['u/admin/db', 'f/other/api'], 'proj')
expect(m.get('u/admin/db')).toBe('f/proj/db')
expect(m.get('f/other/api')).toBe('f/proj/api')
})
it('suffixes collisions deterministically', () => {
const m = buildPathMap(['u/alice/db', 'f/shared/db', 'u/bob/db'], 'proj')
// sorted: f/shared/db, u/alice/db, u/bob/db
expect(m.get('f/shared/db')).toBe('f/proj/db')
expect(m.get('u/alice/db')).toBe('f/proj/db_2')
expect(m.get('u/bob/db')).toBe('f/proj/db_3')
})
it('maps internal paths to themselves, preserving subfolder depth', () => {
const m = buildPathMap(['f/proj/api', 'f/proj/sub/deep/script'], 'proj')
expect(m.get('f/proj/api')).toBe('f/proj/api')
expect(m.get('f/proj/sub/deep/script')).toBe('f/proj/sub/deep/script')
})
it('does not flatten two internal items sharing a leaf name', () => {
const m = buildPathMap(['f/proj/a/x', 'f/proj/b/x'], 'proj')
expect(m.get('f/proj/a/x')).toBe('f/proj/a/x')
expect(m.get('f/proj/b/x')).toBe('f/proj/b/x')
})
it('relocates an external onto a suffix when its leaf collides with an internal path', () => {
const m = buildPathMap(['f/proj/db', 'u/admin/db'], 'proj')
expect(m.get('f/proj/db')).toBe('f/proj/db')
expect(m.get('u/admin/db')).toBe('f/proj/db_2')
})
})
describe('rewriteContent', () => {
it('rewrites mapped refs and leaves unmapped ones', () => {
const map = new Map([['u/admin/db', 'f/proj/db']])
expect(rewriteContent('x = "$res:u/admin/db"', map)).toBe('x = "$res:f/proj/db"')
expect(rewriteContent('x = "res://u/admin/db"', map)).toBe('x = "$res:f/proj/db"')
expect(rewriteContent('x = "$res:hub/1/a/b"', map)).toBe('x = "$res:hub/1/a/b"')
})
it('does not partial-match a longer path', () => {
const map = new Map([['u/admin/db', 'f/proj/db']])
// u/admin/db2 must not be rewritten by the u/admin/db entry
expect(rewriteContent('x = "$res:u/admin/db2"', map)).toBe('x = "$res:u/admin/db2"')
})
})
describe('rewriteTriggerConfig', () => {
const map = new Map([
['f/proj/kafka', 'f/target/kafka'],
['f/proj/script', 'f/target/script']
])
it('remaps plain resource path fields', () => {
expect(
rewriteTriggerConfig({ kafka_resource_path: 'f/proj/kafka', group_id: 'g1' }, map)
).toEqual({ kafka_resource_path: 'f/target/kafka', group_id: 'g1' })
})
it('remaps nested objects, arrays, and $res: tokens', () => {
expect(
rewriteTriggerConfig(
{
nested: { path: 'f/proj/script' },
list: ['f/proj/kafka', 'unrelated'],
code: 'x = "$res:f/proj/kafka"'
},
map
)
).toEqual({
nested: { path: 'f/target/script' },
list: ['f/target/kafka', 'unrelated'],
code: 'x = "$res:f/target/kafka"'
})
})
it('leaves non-matching strings and non-string values untouched', () => {
const config = { url: 'wss://example.com', port: 9092, enabled: true, extra: null }
expect(rewriteTriggerConfig(config, map)).toEqual(config)
})
})
describe('rewriteFlowValue', () => {
it('rewrites inline code, static inputs, and script paths; clones input', () => {
const map = new Map([
['u/admin/pg', 'f/proj/pg'],
['f/shared/api', 'f/proj/api'],
['u/admin/my_script', 'f/proj/my_script']
])
const value = {
modules: [
{
id: 'a',
value: {
type: 'rawscript',
content: 'const db = "$res:u/admin/pg"',
input_transforms: { other: { type: 'static', value: '$res:f/shared/api' } }
}
},
{ id: 'b', value: { type: 'script', path: 'u/admin/my_script' } },
{ id: 'c', value: { type: 'script', path: 'hub/1/keep/me' } }
]
}
const out = rewriteFlowValue(value, map)
expect(out.modules[0].value.content).toBe('const db = "$res:f/proj/pg"')
expect(out.modules[0].value.input_transforms.other.value).toBe('$res:f/proj/api')
expect(out.modules[1].value.path).toBe('f/proj/my_script')
expect(out.modules[2].value.path).toBe('hub/1/keep/me')
// original untouched (deep clone)
expect(value.modules[0].value.content).toBe('const db = "$res:u/admin/pg"')
})
})
// A trimmed app value: a runnable-by-path component, a hub runnable, a $res in an
// inline script, and incidental `f/...` text that must NOT be rewritten.
const appValue = () => ({
grid: [
{
data: {
componentInput: {
runnable: { type: 'runnableByPath', runType: 'script', path: 'u/admin/charts' }
}
}
},
{
data: {
componentInput: {
runnable: { type: 'runnableByPath', runType: 'flow', path: 'f/shared/sync' }
}
}
},
{
data: {
componentInput: {
runnable: { type: 'runnableByPath', runType: 'hubscript', path: 'hub/1/keep' }
}
}
}
],
hiddenInlineScripts: [
{ name: 'h', inlineScript: { content: 'x = "$res:u/admin/pg"', language: 'deno' } }
],
someLabel: 'see docs at f/shared/sync for details'
})
describe('extractAppRefs', () => {
it('extracts runnable-by-path scripts/flows and $res resources, skips hub', () => {
const refs = extractAppRefs(appValue())
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/charts' })
expect(refs).toContainEqual({ kind: 'flow', path: 'f/shared/sync' })
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
expect(refs.some((r) => r.path === 'hub/1/keep')).toBe(false)
})
})
describe('rewriteAppValue', () => {
it('relocates runnable paths and $res, leaves hub refs and incidental text intact', () => {
const map = new Map([
['u/admin/charts', 'f/proj/charts'],
['f/shared/sync', 'f/proj/sync'],
['u/admin/pg', 'f/proj/pg']
])
const value = appValue()
const out = rewriteAppValue(value, map)
expect(out.grid[0].data.componentInput.runnable.path).toBe('f/proj/charts')
expect(out.grid[1].data.componentInput.runnable.path).toBe('f/proj/sync')
expect(out.grid[2].data.componentInput.runnable.path).toBe('hub/1/keep')
expect(out.hiddenInlineScripts[0].inlineScript.content).toBe('x = "$res:f/proj/pg"')
// incidental text untouched
expect(out.someLabel).toBe('see docs at f/shared/sync for details')
// original untouched (deep clone)
expect(value.grid[0].data.componentInput.runnable.path).toBe('u/admin/charts')
})
})
describe('raw app (value.raw JSON string)', () => {
const rawContent = () =>
JSON.stringify({
runnables: {
a: { type: 'path', runType: 'flow', path: 'u/admin/sync' },
b: { type: 'path', runType: 'script', path: 'f/shared/calc' },
c: { type: 'path', runType: 'hubscript', path: 'hub/1/keep' }
},
files: { '/bundle.js': 'const conn = "$res:u/admin/pg"' }
})
it('extractRawAppRefs sees nested runnables and $res, skips hub', () => {
const refs = extractRawAppRefs(rawContent())
expect(refs).toContainEqual({ kind: 'flow', path: 'u/admin/sync' })
expect(refs).toContainEqual({ kind: 'script', path: 'f/shared/calc' })
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
expect(refs.some((r) => r.path === 'hub/1/keep')).toBe(false)
})
it('rewriteRawAppContent relocates nested runnable paths and $res', () => {
const map = new Map([
['u/admin/sync', 'f/proj/sync'],
['f/shared/calc', 'f/proj/calc'],
['u/admin/pg', 'f/proj/pg']
])
const out = JSON.parse(rewriteRawAppContent(rawContent(), map))
expect(out.runnables.a.path).toBe('f/proj/sync')
expect(out.runnables.b.path).toBe('f/proj/calc')
expect(out.runnables.c.path).toBe('hub/1/keep')
expect(out.files['/bundle.js']).toBe('const conn = "$res:f/proj/pg"')
})
it('falls back to $res scan on non-JSON content', () => {
expect(extractRawAppRefs('x = "$res:u/admin/pg"')).toContainEqual({
kind: 'resource',
path: 'u/admin/pg'
})
expect(
rewriteRawAppContent('x = "$res:u/admin/pg"', new Map([['u/admin/pg', 'f/proj/pg']]))
).toBe('x = "$res:f/proj/pg"')
})
})
describe('buildProjectBundle', () => {
// A flow that calls an external script which itself hardcodes a resource.
const flow: FetchedItem = {
kind: 'flow',
path: 'u/admin/my_flow',
summary: 'Flow',
value: {
modules: [
{ id: 'a', value: { type: 'script', path: 'u/admin/helper' } },
{
id: 'b',
value: {
type: 'rawscript',
content: 'const x = "$res:f/shared/api"',
input_transforms: {}
}
}
]
}
}
const helper: FetchedItem = {
kind: 'script',
path: 'u/admin/helper',
summary: 'Helper',
language: 'bun',
content: 'const db = "$res:u/admin/pg"; export async function main(){}'
}
const deps = {
fetchItem: async (ref: ItemRef) => {
if (ref.path === 'u/admin/my_flow') return flow
if (ref.path === 'u/admin/helper') return helper
return undefined
},
resolveResourceType: async (path: string) => {
if (path === 'u/admin/pg') return 'postgresql'
if (path === 'f/shared/api') return 'http_api'
return undefined
}
}
it('pulls in referenced scripts + resources and rewrites everything under the folder', async () => {
const bundle = await buildProjectBundle(
[{ kind: 'flow', path: 'u/admin/my_flow' }],
'proj',
deps
)
// flow + transitively-pulled helper script are both bundled
const byPath = Object.fromEntries(bundle.items.map((i) => [i.path, i]))
expect(Object.keys(byPath).sort()).toEqual(['u/admin/helper', 'u/admin/my_flow'])
// items relocated under f/proj/
expect(byPath['u/admin/my_flow'].newPath).toBe('f/proj/my_flow')
expect(byPath['u/admin/helper'].newPath).toBe('f/proj/helper')
// flow's script-path ref rewritten to the helper's new path
expect(byPath['u/admin/my_flow'].value.modules[0].value.path).toBe('f/proj/helper')
// flow inline + helper code resource refs rewritten
expect(byPath['u/admin/my_flow'].value.modules[1].value.content).toBe(
'const x = "$res:f/proj/api"'
)
expect(byPath['u/admin/helper'].content).toContain('"$res:f/proj/pg"')
// resource stubs created at new paths with resolved types
const stubs = Object.fromEntries(bundle.resourceStubs.map((s) => [s.originalPath, s]))
expect(stubs['u/admin/pg'].newPath).toBe('f/proj/pg')
expect(stubs['u/admin/pg'].resource_type).toBe('postgresql')
expect(stubs['f/shared/api'].resource_type).toBe('http_api')
expect(bundle.unresolved).toEqual([])
})
it('pulls in a sub-flow referenced by a type: flow step and rewrites its path', async () => {
const parent: FetchedItem = {
kind: 'flow',
path: 'u/admin/parent_flow',
value: { modules: [{ id: 'a', value: { type: 'flow', path: 'u/admin/sub_flow' } }] }
}
const sub: FetchedItem = {
kind: 'flow',
path: 'u/admin/sub_flow',
value: {
modules: [{ id: 'a', value: { type: 'script', path: 'hub/1/keep/me' } }]
}
}
const d = {
fetchItem: async (ref: ItemRef) => {
if (ref.path === 'u/admin/parent_flow') return parent
if (ref.path === 'u/admin/sub_flow') return sub
return undefined
},
resolveResourceType: async () => undefined
}
const bundle = await buildProjectBundle(
[{ kind: 'flow', path: 'u/admin/parent_flow' }],
'proj',
d
)
const byPath = Object.fromEntries(bundle.items.map((i) => [i.path, i]))
// both flows bundled
expect(Object.keys(byPath).sort()).toEqual(['u/admin/parent_flow', 'u/admin/sub_flow'])
// parent's type: flow ref rewritten to the sub-flow's new path
expect(byPath['u/admin/parent_flow'].value.modules[0].value.path).toBe('f/proj/sub_flow')
expect(byPath['u/admin/sub_flow'].newPath).toBe('f/proj/sub_flow')
// hub ref inside the sub-flow left untouched
expect(byPath['u/admin/sub_flow'].value.modules[0].value.path).toBe('hub/1/keep/me')
expect(bundle.unresolved).toEqual([])
})
it('leaves hub script references untouched and does not fetch them', async () => {
const hubFlow: FetchedItem = {
kind: 'flow',
path: 'u/admin/hub_flow',
value: { modules: [{ id: 'a', value: { type: 'script', path: 'hub/1/x/y' } }] }
}
const d = {
fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/hub_flow' ? hubFlow : undefined),
resolveResourceType: async () => undefined
}
const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/hub_flow' }], 'proj', d)
expect(bundle.items.map((i) => i.path)).toEqual(['u/admin/hub_flow'])
expect(bundle.items[0].value.modules[0].value.path).toBe('hub/1/x/y')
expect(bundle.unresolved).toEqual([])
})
it('reports a missing item and an unresolvable resource as unresolved', async () => {
const root: FetchedItem = {
kind: 'flow',
path: 'u/admin/root',
value: {
modules: [
{ id: 'a', value: { type: 'script', path: 'u/admin/gone' } },
{
id: 'b',
value: {
type: 'rawscript',
content: 'const x = "$res:u/admin/untyped"',
input_transforms: {}
}
}
]
}
}
const d = {
fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/root' ? root : undefined),
resolveResourceType: async () => undefined
}
const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/root' }], 'proj', d)
expect(bundle.unresolved.sort()).toEqual(['u/admin/gone', 'u/admin/untyped'])
})
it('relocates $var:/$jsonvar: refs into the slug when it differs from the source folder', async () => {
const flow: FetchedItem = {
kind: 'flow',
path: 'f/source_folder/main',
value: {
flow_env: { CFG: '$jsonvar:f/source_folder/cfg' },
modules: [
{
id: 'a',
value: {
type: 'rawscript',
// Whole-value ref is relocated; the inline literal is not.
content: 'return "$var:f/source_folder/key"',
input_transforms: { k: { type: 'static', value: '$var:f/source_folder/key' } }
}
}
]
}
}
const d = {
fetchItem: async (ref: ItemRef) => (ref.path === 'f/source_folder/main' ? flow : undefined),
resolveResourceType: async () => undefined
}
const bundle = await buildProjectBundle(
[{ kind: 'flow', path: 'f/source_folder/main' }],
'kit',
d
)
const v = bundle.items[0].value
expect(v.modules[0].value.input_transforms.k.value).toBe('$var:f/kit/key')
expect(v.flow_env.CFG).toBe('$jsonvar:f/kit/cfg')
// Inline code literal is untouched.
expect(v.modules[0].value.content).toBe('return "$var:f/source_folder/key"')
})
it('dedupes a path missing as both a script and a flow', async () => {
// A missing script + flow sharing a path each push the bare path once; the
// list must stay unique so a keyed UI render of it can't collide.
const root: FetchedItem = {
kind: 'flow',
path: 'u/admin/root',
value: {
modules: [
{ id: 'a', value: { type: 'script', path: 'u/admin/dup' } },
{ id: 'b', value: { type: 'flow', path: 'u/admin/dup' } }
]
}
}
const d = {
fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/root' ? root : undefined),
resolveResourceType: async () => undefined
}
const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/root' }], 'proj', d)
expect(bundle.unresolved).toEqual(['u/admin/dup'])
})
})
describe('extractVarRefsFromValue', () => {
it('collects whole-value `$var:`/`$jsonvar:` refs, deduped, walking nested JSON', () => {
const value = {
flow_env: { API: '$var:u/admin/key' },
modules: [
{ value: { input_transforms: { a: { type: 'static', value: '$var:f/proj/token' } } } },
{ value: { input_transforms: { b: { type: 'static', value: '$jsonvar:u/admin/cfg' } } } },
{ value: { input_transforms: { c: { type: 'static', value: '$var:u/admin/key' } } } }
]
}
expect(extractVarRefsFromValue(value).sort()).toEqual([
'f/proj/token',
'u/admin/cfg',
'u/admin/key'
])
})
it('ignores a `$var:` token embedded in inline code (not a whole value)', () => {
// The worker only substitutes a value that *is* the reference, so an inline
// script literal must not be treated as a variable arg.
const value = {
modules: [{ value: { type: 'rawscript', content: 'return "$var:u/example/template"' } }]
}
expect(extractVarRefsFromValue(value)).toEqual([])
})
})
describe('retargetProjectExport', () => {
const baseExport = (): ProjectExport => ({
project: { slug: 'proj', name: 'Proj', summary: '', readme: null },
scripts: [
{
path: 'f/proj/hello',
content: 'const r = "$res:f/proj/db"',
summary: 'hello'
}
],
flows: [
{
path: 'f/proj/main_flow',
value: {
modules: [
{ id: 'a', value: { type: 'script', path: 'f/proj/hello', input_transforms: {} } }
]
}
}
],
apps: [
{
path: 'f/proj/dashboard',
value: { grid: [{ data: { componentInput: { runnable: {} } } }] }
},
{
path: 'f/proj/rawapp',
app_type: 'raw',
value: { raw: JSON.stringify({ files: {}, runnables: {} }) }
}
],
resources: [{ path: 'f/proj/db', resource_type: 'postgresql' }],
triggers: [
{
path: 'f/proj/every_day',
kind: 'schedule',
runnable_path: 'f/proj/hello',
runnable_kind: 'script',
config: { schedule: '0 0 12 * * *' }
},
{
path: 'f/proj/kafka_in',
kind: 'kafka',
runnable_path: 'f/proj/hello',
runnable_kind: 'script',
config: { kafka_resource_path: 'f/proj/db' }
}
]
})
it('returns the bundle unchanged when the folder matches the slug', () => {
const bundle = baseExport()
expect(retargetProjectExport(bundle, 'proj', 'proj')).toBe(bundle)
})
it('relocates every item path and internal reference into the target folder', () => {
const out = retargetProjectExport(baseExport(), 'proj', 'dest')
expect(out.scripts[0].path).toBe('f/dest/hello')
expect(out.scripts[0].content).toContain('$res:f/dest/db')
expect(out.flows[0].path).toBe('f/dest/main_flow')
expect(out.flows[0].value.modules[0].value.path).toBe('f/dest/hello')
expect(out.apps.map((a) => a.path)).toEqual(['f/dest/dashboard', 'f/dest/rawapp'])
expect(out.resources[0].path).toBe('f/dest/db')
expect(out.triggers[0].path).toBe('f/dest/every_day')
expect(out.triggers[0].runnable_path).toBe('f/dest/hello')
// Plain-string resource path in a trigger config is remapped too.
expect(out.triggers[1].config.kafka_resource_path).toBe('f/dest/db')
})
it('leaves external and hub paths untouched', () => {
const bundle = baseExport()
bundle.scripts[0].content = 'const a = "$res:u/admin/db"; const b = "$res:hub/1/x"'
const out = retargetProjectExport(bundle, 'proj', 'dest')
expect(out.scripts[0].content).toContain('$res:u/admin/db')
expect(out.scripts[0].content).toContain('$res:hub/1/x')
})
it('retargets internal $var:/$jsonvar: refs but leaves external ones', () => {
const bundle = baseExport()
bundle.flows[0].value.modules[0].value.input_transforms = {
key: { type: 'static', value: '$var:f/proj/api_key' },
ext: { type: 'static', value: '$var:u/admin/personal' }
}
bundle.flows[0].value.flow_env = { CFG: '$jsonvar:f/proj/cfg' }
bundle.triggers[1].config.queue_url = '$var:f/proj/sqs'
const out = retargetProjectExport(bundle, 'proj', 'dest')
const it = out.flows[0].value.modules[0].value.input_transforms
expect(it.key.value).toBe('$var:f/dest/api_key')
expect(it.ext.value).toBe('$var:u/admin/personal')
expect(out.flows[0].value.flow_env.CFG).toBe('$jsonvar:f/dest/cfg')
expect(out.triggers[1].config.queue_url).toBe('$var:f/dest/sqs')
})
it('leaves an inert $var: literal embedded in inline code unchanged', () => {
const bundle = baseExport()
// Same path as a real runtime ref, but here it is a literal inside code: it
// must not be rewritten even once the path enters the retarget map.
bundle.flows[0].value.modules[0].value = {
type: 'rawscript',
content: 'return "$var:f/proj/api_key"',
input_transforms: { real: { type: 'static', value: '$var:f/proj/api_key' } }
}
const out = retargetProjectExport(bundle, 'proj', 'dest')
const mod = out.flows[0].value.modules[0].value
expect(mod.content).toBe('return "$var:f/proj/api_key"')
expect(mod.input_transforms.real.value).toBe('$var:f/dest/api_key')
})
})
describe('collectExportVarPaths', () => {
it('gathers variable refs from flows, apps, and triggers (deduped)', () => {
const bundle: ProjectExport = {
project: { slug: 'proj', name: 'P', summary: '', readme: null },
scripts: [],
flows: [{ path: 'f/proj/f', value: { flow_env: { A: '$var:f/proj/a' }, modules: [] } }],
apps: [
{
path: 'f/proj/raw',
app_type: 'raw',
value: { raw: JSON.stringify({ runnables: { r: { fields: { x: '$var:u/admin/b' } } } }) }
}
],
triggers: [{ path: 'f/proj/t', kind: 'sqs', config: { queue_url: '$jsonvar:f/proj/a' } }],
resources: []
}
expect(collectExportVarPaths(bundle).sort()).toEqual(['f/proj/a', 'u/admin/b'])
})
})
describe('trigger handler relocation', () => {
it('rewriteTriggerConfig remaps script/- and flow/-prefixed handler refs', () => {
const map = new Map([
['u/admin/handler', 'f/proj/handler'],
['u/admin/recovery_flow', 'f/proj/recovery_flow']
])
const out = rewriteTriggerConfig(
{
error_handler_path: 'u/admin/handler',
on_failure: 'script/u/admin/handler',
on_recovery: 'flow/u/admin/recovery_flow',
on_success: 'script/u/admin/unmapped'
},
map
)
expect(out.error_handler_path).toBe('f/proj/handler')
expect(out.on_failure).toBe('script/f/proj/handler')
expect(out.on_recovery).toBe('flow/f/proj/recovery_flow')
expect(out.on_success).toBe('script/u/admin/unmapped')
})
it('remaps $script:/$flow: only in the url field, never in literal payloads', () => {
const map = new Map([['u/admin/builder', 'f/proj/builder']])
const out = rewriteTriggerConfig(
{
url: '$script:u/admin/builder',
initial_messages: [{ raw_message: '$script:u/admin/builder' }]
},
map
)
expect(out.url).toBe('$script:f/proj/builder')
expect(out.initial_messages[0].raw_message).toBe('$script:u/admin/builder')
})
it('leaves literal handler-shaped strings in args untouched', () => {
const map = new Map([['f/proj/handler', 'f/dest/handler']])
const out = rewriteTriggerConfig(
{
on_failure: 'script/f/proj/handler',
args: { note: 'script/f/proj/handler' }
},
map
)
expect(out.on_failure).toBe('script/f/dest/handler')
expect(out.args.note).toBe('script/f/proj/handler')
})
it('leaves nested url keys untouched, rewriting only the top-level websocket url', () => {
const map = new Map([['u/admin/builder', 'f/proj/builder']])
const out = rewriteTriggerConfig(
{
url: '$script:u/admin/builder',
args: { url: '$script:u/admin/builder' }
},
map
)
expect(out.url).toBe('$script:f/proj/builder')
expect(out.args.url).toBe('$script:u/admin/builder')
})
it('extracts and relocates $res refs nested in static input transform JSON', () => {
const value = {
modules: [
{
id: 'a',
value: {
type: 'script',
path: 'f/proj/step',
input_transforms: {
provider: { type: 'static', value: { resource: '$res:u/admin/openai' } },
note: { type: 'static', value: 'plain text' }
}
}
}
]
}
const refs = extractFlowRefs(value)
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/openai' })
const out = rewriteFlowValue(value, new Map([['u/admin/openai', 'f/proj/openai']]))
const it0 = out.modules[0].value.input_transforms
expect(it0.provider.value).toEqual({ resource: '$res:f/proj/openai' })
expect(typeof it0.note.value).toBe('string')
})
it('retargetProjectExport remaps trigger error handlers with the bundle', () => {
const bundle: ProjectExport = {
project: { slug: 'proj', name: 'P', summary: '', readme: null },
scripts: [{ path: 'f/proj/handler', content: '' }],
flows: [],
apps: [],
resources: [],
triggers: [
{
path: 'f/proj/sched',
kind: 'schedule',
runnable_path: 'f/proj/handler',
runnable_kind: 'script',
config: { schedule: '0 0 * * * *', on_failure: 'script/f/proj/handler' }
},
{
path: 'f/proj/mq',
kind: 'mqtt',
runnable_path: 'f/proj/handler',
runnable_kind: 'script',
config: { error_handler_path: 'f/proj/handler' }
}
]
}
const out = retargetProjectExport(bundle, 'proj', 'dest')
expect(out.triggers[0].config.on_failure).toBe('script/f/dest/handler')
expect(out.triggers[1].config.error_handler_path).toBe('f/dest/handler')
})
})
describe('extractTriggerConfigResourceRefs', () => {
it('collects $res: tokens nested anywhere in a trigger config', () => {
expect(
extractTriggerConfigResourceRefs({
schedule: '0 0 * * * *',
args: { channel: '$res:u/admin/slack' },
on_failure_extra_args: { db: 'res://f/other/pg' },
error_handler_args: { nested: { deep: '$res:u/admin/slack' } }
})
).toEqual(['u/admin/slack', 'f/other/pg'])
})
})
describe('flow_env and preprocessor_module', () => {
const flowValue = {
modules: [],
preprocessor_module: {
id: 'pre',
value: { type: 'script', path: 'u/admin/preproc', input_transforms: {} }
},
flow_env: { SLACK: '$res:u/admin/slack', PLAIN: 'not-a-ref' }
}
it('walks nested children of the failure module', () => {
const refs = extractFlowRefs({
modules: [],
failure_module: {
id: 'failure',
value: {
type: 'forloopflow',
modules: [
{ id: 'f-a', value: { type: 'script', path: 'u/admin/cleanup', input_transforms: {} } }
]
}
}
})
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/cleanup' })
})
it('extractFlowRefs sees preprocessor scripts and flow_env resources', () => {
const refs = extractFlowRefs(flowValue)
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/preproc' })
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/slack' })
})
it('sees and relocates $res refs nested inside JSON flow_env values', () => {
const value = {
modules: [],
flow_env: { CFG: { db: '$res:u/admin/pg', opts: ['res://u/admin/s3'] } }
}
const refs = extractFlowRefs(value)
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/s3' })
const map = new Map([
['u/admin/pg', 'f/proj/pg'],
['u/admin/s3', 'f/proj/s3']
])
const out = rewriteFlowValue(value, map)
expect(out.flow_env.CFG.db).toBe('$res:f/proj/pg')
expect(out.flow_env.CFG.opts[0]).toBe('$res:f/proj/s3')
})
it('rewriteFlowValue relocates both', () => {
const map = new Map([
['u/admin/preproc', 'f/proj/preproc'],
['u/admin/slack', 'f/proj/slack']
])
const out = rewriteFlowValue(flowValue, map)
expect(out.preprocessor_module.value.path).toBe('f/proj/preproc')
expect(out.flow_env.SLACK).toBe('$res:f/proj/slack')
expect(out.flow_env.PLAIN).toBe('not-a-ref')
})
})
@@ -0,0 +1,652 @@
// Pure logic for the "project = folder" Hub bundle. A project is one folder
// `f/<slug>/...`. Bundling: collect the transitive closure, relocate external
// refs (`u/<user>/<name>`, `f/<other>/<name>` -> `f/<slug>/<name>`, `_2`/`_3`…
// on collision) and rewrite them. Hub refs stay external; runtime string-concat
// paths are out of scope. No API/Svelte deps so it's unit-testable.
import { getAllModules } from '$lib/components/flows/flowExplorer'
import { isRunnableByPath } from '$lib/components/apps/inputType'
export type RefKind = 'resource' | 'script' | 'flow'
export interface Ref {
kind: RefKind
/** Bare path, without the `$res:` / `res://` prefix for resources. */
path: string
}
export type PathClass = 'internal' | 'hub' | 'external'
/** A single `$res:PATH` / `res://PATH` token (path captured in group 1). */
const RES_TOKEN_RE = /(?:\$res:|res:\/\/)([\w\-./]+)/g
// A whole-string `$var:PATH` / `$jsonvar:PATH` value. The worker substitutes these
// only when an argument value *is* the reference (walking nested JSON), never a
// token embedded in inline code, so the whole value must match. `_KIND` captures
// the prefix (group 1) and path (group 2) so a rewrite can preserve `var`/`jsonvar`.
const VAR_VALUE_RE = /^\$(?:json)?var:([\w\-./]+)$/
const VAR_VALUE_RE_KIND = /^\$(var|jsonvar):([\w\-./]+)$/
// Variable paths a value will resolve at runtime (flow static inputs, flow_env,
// app runnable inputs, trigger config fields). Walk the parsed structure and match
// whole string values so inline code carrying a literal `$var:` string is ignored.
export function extractVarRefsFromValue(value: any): string[] {
const out = new Set<string>()
const walk = (v: any) => {
if (typeof v === 'string') {
const m = VAR_VALUE_RE.exec(v)
if (m) out.add(m[1])
} else if (Array.isArray(v)) {
for (const x of v) walk(x)
} else if (v && typeof v === 'object') {
for (const k of Object.keys(v)) walk(v[k])
}
}
walk(value)
return [...out]
}
export function classifyPath(path: string, slug: string): PathClass {
if (path.startsWith(`f/${slug}/`) || path === `f/${slug}`) return 'internal'
if (path.startsWith('hub/')) return 'hub'
return 'external'
}
export function extractScriptRefs(content: string): Ref[] {
const out: Ref[] = []
const seen = new Set<string>()
let m: RegExpExecArray | null
RES_TOKEN_RE.lastIndex = 0
while ((m = RES_TOKEN_RE.exec(content)) !== null) {
if (!seen.has(m[1])) {
seen.add(m[1])
out.push({ kind: 'resource', path: m[1] })
}
}
return out
}
/**
* References inside a flow value:
* - inline rawscript code with `$res:` (resource)
* - static step inputs whose value is a `$res:` literal (resource)
* - `type: script` steps that reference a script by path (script)
* - `type: flow` steps that reference a sub-flow by path (flow)
*/
export function extractFlowRefs(value: any): Ref[] {
const out: Ref[] = []
const seen = new Set<string>()
const add = (kind: RefKind, path: string) => {
const key = `${kind}:${path}`
if (!seen.has(key)) {
seen.add(key)
out.push({ kind, path })
}
}
// getAllModules flattens the whole tree (loops, branches, aiagent tools,
// failure module) so each module only needs local inspection; the
// preprocessor module sits outside `modules` and is walked the same way.
for (const mod of allFlowModules(value)) {
const v: any = (mod as any)?.value
if (!v || typeof v !== 'object') continue
if (v.type === 'script' && typeof v.path === 'string') add('script', v.path)
if (v.type === 'flow' && typeof v.path === 'string') add('flow', v.path)
if (typeof v.content === 'string') {
for (const r of extractScriptRefs(v.content)) add('resource', r.path)
}
const it = v.input_transforms
if (it && typeof it === 'object') {
for (const key of Object.keys(it)) {
const t = it[key]
// Static values can be a bare `$res:` string or arbitrary JSON with
// refs nested anywhere — the worker resolves both, so scan the full
// serialization.
if (t?.type === 'static' && t.value !== undefined) {
const text = typeof t.value === 'string' ? t.value : JSON.stringify(t.value)
for (const r of extractScriptRefs(text)) add('resource', r.path)
}
}
}
}
// flow_env values support `$res:path` references — as whole string values or
// nested inside JSON values (the worker resolves both), so scan the full
// serialization.
if (value?.flow_env && typeof value.flow_env === 'object') {
for (const r of extractScriptRefs(JSON.stringify(value.flow_env))) add('resource', r.path)
}
return out
}
// Every module of a flow value: the tree under `modules`, the failure module,
// and the preprocessor module (which lives outside `modules`). Any walk over a
// flow's modules must go through this — a walk that misses a module class
// silently drops its dependencies from bundles or migrations. All three go in
// the root list (not getAllModules' failure_module parameter, which appends
// the module without expanding its descendants) so nested children of a
// failure or preprocessor module are walked too.
export function allFlowModules(value: any) {
return getAllModules([
...(value?.modules ?? []),
...(value?.preprocessor_module ? [value.preprocessor_module] : []),
...(value?.failure_module ? [value.failure_module] : [])
])
}
// Visit every object node in an app value tree (JSON-safe, no cycles).
function walkAppNodes(value: any, visit: (node: Record<string, any>) => void): void {
if (value == null || typeof value !== 'object') return
if (Array.isArray(value)) {
for (const v of value) walkAppNodes(v, visit)
return
}
visit(value)
for (const k of Object.keys(value)) walkAppNodes(value[k], visit)
}
// `runnableByPath`/`path` nodes reference a workspace runnable by path.
function runnableRef(node: Record<string, any>): Ref | undefined {
if (!isRunnableByPath(node as any) || typeof node.path !== 'string') return undefined
if (node.runType === 'flow') return { kind: 'flow', path: node.path }
if (node.runType === 'script') return { kind: 'script', path: node.path }
return undefined // hubscript -> external hub, ignored
}
// App refs: `$res:` resources anywhere in the value, plus script/flow runnables
// referenced by path in components.
export function extractAppRefs(value: any): Ref[] {
const out: Ref[] = []
const seen = new Set<string>()
const add = (kind: RefKind, path: string) => {
const key = `${kind}:${path}`
if (!seen.has(key)) {
seen.add(key)
out.push({ kind, path })
}
}
walkAppNodes(value, (node) => {
const r = runnableRef(node)
if (r) add(r.kind, r.path)
})
for (const r of extractScriptRefs(JSON.stringify(value ?? {}))) add('resource', r.path)
return out
}
/**
* Build the relocation map. Internal paths (`f/<slug>/...`) map to themselves
* and are reserved first; external paths relocate to `f/<slug>/<name>` (`_2`/`_3`
* on collision). Input is sorted so suffix assignment is deterministic.
*/
export function buildPathMap(paths: Iterable<string>, slug: string): Map<string, string> {
const map = new Map<string, string>()
const used = new Set<string>()
const sorted = [...new Set(paths)].sort()
for (const p of sorted) {
if (classifyPath(p, slug) === 'internal') {
map.set(p, p)
used.add(p)
}
}
for (const old of sorted) {
if (map.has(old)) continue
const name = old.split('/').filter(Boolean).pop() ?? old
let candidate = `f/${slug}/${name}`
let n = 2
while (used.has(candidate)) candidate = `f/${slug}/${name}_${n++}`
used.add(candidate)
map.set(old, candidate)
}
return map
}
// Both ref forms normalize to `$res:` on rewrite.
export function rewriteContent(content: string, map: Map<string, string>): string {
return content.replace(RES_TOKEN_RE, (whole, path) => {
const next = map.get(path)
return next ? `$res:${next}` : whole
})
}
// Structurally relocate whole-string `$var:`/`$jsonvar:` values — the only form the
// worker resolves. Walks the parsed value so an inert token embedded in inline code
// or arbitrary text is left untouched, unlike token replacement over serialized
// strings. Only paths present in the map move (the retarget map carries variables).
export function rewriteVarRefsInValue(value: any, map: Map<string, string>): any {
if (typeof value === 'string') {
const m = VAR_VALUE_RE_KIND.exec(value)
if (m) {
const next = map.get(m[2])
if (next) return `$${m[1]}:${next}`
}
return value
}
if (Array.isArray(value)) return value.map((v) => rewriteVarRefsInValue(v, map))
if (value && typeof value === 'object') {
const out: Record<string, any> = {}
for (const k of Object.keys(value)) out[k] = rewriteVarRefsInValue(value[k], map)
return out
}
return value
}
/**
* `$res:`/`res://` tokens anywhere in a trigger config schedule args,
* on_*_extra_args, error_handler_args, (e.g. the built-in Slack handler
* stores its channel resource this way). These must enter the bundle path map
* so `rewriteTriggerConfig` relocates them and a stub is exported.
*/
export function extractTriggerConfigResourceRefs(config: any): string[] {
return extractScriptRefs(JSON.stringify(config ?? {})).map((r) => r.path)
}
/**
* Trigger configs reference resources as plain path strings (e.g.
* `kafka_resource_path: "f/slug/db"`), not `$res:` tokens, so token rewriting
* misses them. Deep-walk the config and remap any string that exact-matches a
* map key (map keys are full bundle paths, so an exact match is a reference),
* or a `script/<path>`/`flow/<path>` handler reference (schedules' on_failure
* et al.), falling back to `$res:` token rewriting for embedded refs.
*/
// Top-level config fields whose string values are prefixed runnable refs.
// Prefixed forms are remapped ONLY in these known positions: deciding meaning
// from string shape alone rewrote literal payloads that merely looked like
// refs. Bare-path exact matches and $res: tokens stay position-independent.
const HANDLER_REF_FIELDS = new Set(['on_failure', 'on_recovery', 'on_success'])
export function rewriteTriggerConfig(config: any, map: Map<string, string>, depth = 0): any {
if (typeof config === 'string') {
const direct = map.get(config)
if (direct) return direct
return rewriteContent(config, map)
}
if (Array.isArray(config)) return config.map((v) => rewriteTriggerConfig(v, map, depth + 1))
if (config && typeof config === 'object') {
return Object.fromEntries(
Object.entries(config).map(([k, v]) => {
if (depth === 0 && typeof v === 'string') {
// Websocket url: $script:<path> / $flow:<path>.
if (k === 'url') {
const m = /^\$(script|flow):(.+)$/.exec(v)
if (m && map.has(m[2])) return [k, `$${m[1]}:${map.get(m[2])}`]
}
// Schedule handlers: script/<path> / flow/<path>.
if (HANDLER_REF_FIELDS.has(k)) {
const m = /^(script|flow)\/(.+)$/.exec(v)
if (m && map.has(m[2])) return [k, `${m[1]}/${map.get(m[2])}`]
}
}
return [k, rewriteTriggerConfig(v, map, depth + 1)]
})
)
}
return config
}
export function rewriteFlowValue(value: any, map: Map<string, string>): any {
const cloned = JSON.parse(JSON.stringify(value ?? {}))
for (const mod of allFlowModules(cloned)) {
const v: any = (mod as any)?.value
if (!v || typeof v !== 'object') continue
if (
(v.type === 'script' || v.type === 'flow') &&
typeof v.path === 'string' &&
map.has(v.path)
) {
v.path = map.get(v.path)
}
if (typeof v.content === 'string') v.content = rewriteContent(v.content, map)
const it = v.input_transforms
if (it && typeof it === 'object') {
for (const key of Object.keys(it)) {
const t = it[key]
// Mirror extraction: rewrite refs wherever they sit, preserving the
// value's type (a string stays a string, JSON round-trips).
if (t?.type === 'static' && t.value !== undefined) {
if (typeof t.value === 'string') {
t.value = rewriteContent(t.value, map)
} else {
t.value = JSON.parse(rewriteContent(JSON.stringify(t.value), map))
}
}
}
}
}
if (cloned?.flow_env && typeof cloned.flow_env === 'object') {
// Tokens can sit inside nested JSON values, not just string values; the
// serialize→rewrite→parse round-trip reaches all of them (paths contain
// no characters that would break JSON string literals).
cloned.flow_env = JSON.parse(rewriteContent(JSON.stringify(cloned.flow_env), map))
}
return cloned
}
// Relocate `$res:` tokens (one round-trip, also produces a fresh clone) then
// runnable-by-path refs structurally. Incidental `f/<slug>/` strings stay intact.
export function rewriteAppValue(value: any, map: Map<string, string>): any {
if (value == null) return value
const cloned = JSON.parse(rewriteContent(JSON.stringify(value), map))
walkAppNodes(cloned, (node) => {
if (runnableRef(node) && map.has(node.path)) node.path = map.get(node.path)
})
return cloned
}
// Raw/compiled apps store their structure as a JSON string (`{ runnables, files }`).
// Parse it so runnable-by-path refs in the runnables map are seen, reusing the
// same walk; fall back to plain `$res:` scanning if it isn't valid JSON.
export function extractRawAppRefs(content: string): Ref[] {
let parsed: any
try {
parsed = JSON.parse(content)
} catch {
return extractScriptRefs(content)
}
return extractAppRefs(parsed)
}
export function rewriteRawAppContent(content: string, map: Map<string, string>): string {
let parsed: any
try {
parsed = JSON.parse(content)
} catch {
return rewriteContent(content, map)
}
return JSON.stringify(rewriteAppValue(parsed, map))
}
// ---------------------------------------------------------------------------
// Hub project export format (what /projects/{slug}/export returns) and its
// retargeting into a destination folder. Kept here, next to the rewriters,
// so the bundle format is defined in one module for both publish and install.
// ---------------------------------------------------------------------------
export type ExportItem = Record<string, any>
export interface ProjectMigration {
datatable_name: string
sql: string
sql_down?: string
enabled: boolean
}
export interface ProjectExport {
project: { slug: string; name: string; summary: string; readme: string | null }
scripts: ExportItem[]
flows: ExportItem[]
apps: ExportItem[]
resources: ExportItem[]
triggers: ExportItem[]
migrations?: ProjectMigration[]
}
// Map bundled paths `f/<fromSlug>/...` -> `f/<folder>/...`. Only enumerated
// paths go in, so rewriters touch real refs, never incidental text.
export function buildRetargetMap(
bundle: ProjectExport,
fromSlug: string,
folder: string
): Map<string, string> {
const map = new Map<string, string>()
const prefix = `f/${fromSlug}/`
const add = (p: unknown) => {
if (typeof p === 'string' && p.startsWith(prefix)) {
map.set(p, `f/${folder}/${p.slice(prefix.length)}`)
}
}
for (const s of bundle.scripts) add(s.path)
for (const f of bundle.flows) add(f.path)
for (const a of bundle.apps) add(a.path)
for (const r of bundle.resources) add(r.path)
for (const t of bundle.triggers) {
add(t.path)
add(t.runnable_path)
}
// Variables aren't enumerated in the export; their `$var:`/`$jsonvar:` refs live
// inside item values. Relocate the internal ones so a renamed-folder import
// rewrites them into the target folder instead of retaining the old prefix.
for (const p of collectExportVarPaths(bundle)) add(p)
return map
}
// Internal-or-external variable paths referenced by the export's flows, apps and
// triggers. Scripts carry no variable args. Raw apps hold their structure in the
// `value.raw` JSON string.
export function collectExportVarPaths(bundle: ProjectExport): string[] {
const out = new Set<string>()
const collect = (value: any) => {
for (const p of extractVarRefsFromValue(value)) out.add(p)
}
for (const f of bundle.flows) collect(f.value)
for (const a of bundle.apps) collect(a.app_type === 'raw' ? safeParseRaw(a.value?.raw) : a.value)
for (const t of bundle.triggers) collect(t.config)
return [...out]
}
function safeParseRaw(raw: unknown): any {
if (typeof raw !== 'string') return undefined
try {
return JSON.parse(raw)
} catch {
return undefined
}
}
// Structural retarget: rewrite each item's path and its internal refs,
// leaving Hub refs and arbitrary content untouched.
export function retargetProjectExport(
bundle: ProjectExport,
fromSlug: string,
folder: string
): ProjectExport {
if (folder === fromSlug) return bundle
const map = buildRetargetMap(bundle, fromSlug, folder)
const remap = (p: unknown) => (typeof p === 'string' ? (map.get(p) ?? p) : p)
return {
...bundle,
scripts: bundle.scripts.map((s) => ({
...s,
path: remap(s.path),
content: rewriteContent(s.content ?? '', map)
})),
flows: bundle.flows.map((f) => ({
...f,
path: remap(f.path),
value: rewriteVarRefsInValue(rewriteFlowValue(f.value, map), map)
})),
apps: bundle.apps.map((a) => ({
...a,
path: remap(a.path),
// Raw apps keep their structure in the `value.raw` JSON string.
value:
a.app_type === 'raw'
? {
...a.value,
raw: rewriteRawVarRefs(rewriteRawAppContent(a.value?.raw ?? '', map), map)
}
: rewriteVarRefsInValue(rewriteAppValue(a.value, map), map)
})),
resources: bundle.resources.map((r) => ({ ...r, path: remap(r.path) })),
triggers: bundle.triggers.map((t) => ({
...t,
path: remap(t.path),
runnable_path: remap(t.runnable_path),
// Configs hold `$res:` tokens, plain resource paths (kafka_resource_path
// etc.) and whole-string `$var:` values — rewrite all three.
config: t.config ? rewriteVarRefsInValue(rewriteTriggerConfig(t.config, map), map) : t.config
}))
}
}
// Var relocation for a raw app's `value.raw` JSON string: parse, structurally
// rewrite whole-string var values, re-serialize; leave invalid JSON untouched.
function rewriteRawVarRefs(raw: string, map: Map<string, string>): string {
const parsed = safeParseRaw(raw)
if (parsed === undefined) return raw
return JSON.stringify(rewriteVarRefsInValue(parsed, map))
}
export type ItemKind = 'script' | 'flow' | 'app' | 'raw_app'
export interface ItemRef {
kind: ItemKind
path: string
}
export interface FetchedItem {
kind: ItemKind
path: string
summary?: string
description?: string
/** scripts + raw_apps */
content?: string
/** flows + apps */
value?: any
/** scripts */
language?: string
schema?: any
lock?: string
scriptKind?: string
}
export interface BundleDeps {
/** Fetch a workspace item by ref, or undefined if it doesn't exist. */
fetchItem: (ref: ItemRef) => Promise<FetchedItem | undefined>
/** Resolve a resource path to its type, or undefined if missing. */
resolveResourceType: (path: string) => Promise<string | undefined>
}
export interface BundledItem extends FetchedItem {
/** Path the item takes inside the project folder. */
newPath: string
}
export interface ResourceStub {
originalPath: string
newPath: string
resource_type: string
}
export interface ProjectBundle {
items: BundledItem[]
resourceStubs: ResourceStub[]
/** Original -> relocated path for every item and resource (incl. unresolved). */
pathMap: Map<string, string>
/** External paths we couldn't fetch/resolve (missing items or untyped resources). */
unresolved: string[]
}
function refsForFetched(item: FetchedItem): Ref[] {
if (item.kind === 'script') return extractScriptRefs(item.content ?? '')
if (item.kind === 'flow') return extractFlowRefs(item.value)
if (item.kind === 'app') return extractAppRefs(item.value)
if (item.kind === 'raw_app') return extractRawAppRefs(item.content ?? '')
return []
}
// Whole-string `$var:`/`$jsonvar:` paths an item resolves at runtime. Scripts carry
// no variable args; raw apps hold their structure in the `content` JSON string.
function varRefsForFetched(item: FetchedItem): string[] {
if (item.kind === 'flow' || item.kind === 'app') return extractVarRefsFromValue(item.value)
if (item.kind === 'raw_app') return extractVarRefsFromValue(safeParseRaw(item.content))
return []
}
// Walks the transitive closure: scripts referenced by path are pulled in
// recursively, resources become empty stubs, hub refs stay external.
export async function buildProjectBundle(
seed: ItemRef[],
slug: string,
deps: BundleDeps,
extraResourcePaths: string[] = [],
extraVarPaths: string[] = []
): Promise<ProjectBundle> {
const fetched = new Map<string, FetchedItem>()
const queued = new Set<string>()
const resourcePaths = new Set<string>()
const varPaths = new Set<string>()
const unresolved: string[] = []
// Resources and variables referenced by triggers (by config value, not `$res:`
// in code) — relocated through the same map so the export stays slug-relative.
for (const p of extraResourcePaths) {
if (classifyPath(p, slug) !== 'hub') resourcePaths.add(p)
}
for (const p of extraVarPaths) varPaths.add(p)
// Key by `${kind}:${path}`, not bare path: a script and flow can share a path,
// and keying by path alone would silently drop one.
const refKey = (kind: string, path: string) => `${kind}:${path}`
// Refs at the same BFS depth are independent: fetch each level concurrently.
let level: ItemRef[] = []
for (const s of seed) {
const key = refKey(s.kind, s.path)
if (!queued.has(key)) {
queued.add(key)
level.push(s)
}
}
while (level.length > 0) {
const results = await Promise.all(
level.map(async (ref) => ({ ref, item: await deps.fetchItem(ref) }))
)
const next: ItemRef[] = []
for (const { ref, item } of results) {
if (!item) {
unresolved.push(ref.path)
continue
}
fetched.set(refKey(ref.kind, ref.path), item)
for (const r of refsForFetched(item)) {
if (classifyPath(r.path, slug) === 'hub') continue
if (r.kind === 'resource') {
resourcePaths.add(r.path)
} else if (r.kind === 'script' || r.kind === 'flow') {
const key = refKey(r.kind, r.path)
if (!queued.has(key)) {
queued.add(key)
next.push({ kind: r.kind, path: r.path })
}
}
}
// Relocate the item's runtime variable refs into the project folder too, so
// the export is slug-relative regardless of the source folder (import then
// materializes them as placeholders). Variables are never hub-hosted.
for (const p of varRefsForFetched(item)) varPaths.add(p)
}
level = next
}
const fetchedItems = [...fetched.values()]
const itemPaths = fetchedItems.map((it) => it.path)
const map = buildPathMap([...itemPaths, ...resourcePaths, ...varPaths], slug)
const items: BundledItem[] = fetchedItems.map((it) => {
const rewritten: BundledItem = { ...it, newPath: map.get(it.path) ?? it.path }
if (it.kind === 'script') {
rewritten.content = rewriteContent(it.content ?? '', map)
} else if (it.kind === 'raw_app') {
rewritten.content = rewriteRawVarRefs(rewriteRawAppContent(it.content ?? '', map), map)
} else if (it.kind === 'flow') {
rewritten.value = rewriteVarRefsInValue(rewriteFlowValue(it.value, map), map)
} else if (it.kind === 'app') {
rewritten.value = rewriteVarRefsInValue(rewriteAppValue(it.value, map), map)
}
return rewritten
})
const resourceStubs: ResourceStub[] = []
const resolved = await Promise.all(
[...resourcePaths].map(async (path) => ({ path, type: await deps.resolveResourceType(path) }))
)
for (const { path, type } of resolved) {
if (!type) {
unresolved.push(path)
continue
}
resourceStubs.push({ originalPath: path, newPath: map.get(path) ?? path, resource_type: type })
}
// `unresolved` keys missing items by kind:path but stores the bare path, so a
// missing script and flow (or a runnable and resource) sharing a path can push
// the same string twice. Dedupe: callers use it as a display/blocker list where
// duplicate keys would break keyed rendering.
return { items, resourceStubs, pathMap: map, unresolved: [...new Set(unresolved)] }
}
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest'
import { refContainmentViolation, varContainmentViolation } from './projectInstall'
import type { Ref } from './projectBundle'
describe('refContainmentViolation', () => {
const folder = 'proj'
const violation = (r: Ref) => refContainmentViolation([r], folder)
it('allows references relocated into the target folder', () => {
expect(violation({ kind: 'resource', path: 'f/proj/db' })).toBeUndefined()
expect(violation({ kind: 'script', path: 'f/proj/helper' })).toBeUndefined()
expect(violation({ kind: 'flow', path: 'f/proj/sub' })).toBeUndefined()
})
it('allows hub script/flow references but never hub resources', () => {
expect(violation({ kind: 'script', path: 'hub/1/x/y' })).toBeUndefined()
expect(violation({ kind: 'flow', path: 'hub/1/a/b' })).toBeUndefined()
// Resources are not hub-hosted, so a hub/ resource path is still an escape.
expect(violation({ kind: 'resource', path: 'hub/1/x/y' })).toBeDefined()
})
it('rejects references bound to another namespace', () => {
// The crux: an in-folder runnable pointing its resource at an existing asset.
expect(violation({ kind: 'resource', path: 'u/admin/db' })).toContain('escapes')
expect(violation({ kind: 'script', path: 'f/other/helper' })).toContain('escapes')
expect(violation({ kind: 'flow', path: 'u/admin/sub' })).toContain('escapes')
})
it('does not treat a prefix-only folder match as internal', () => {
expect(violation({ kind: 'script', path: 'f/proj2/helper' })).toContain('escapes')
})
it('reports the first offending reference and passes a fully-contained set', () => {
expect(
refContainmentViolation(
[
{ kind: 'resource', path: 'f/proj/db' },
{ kind: 'script', path: 'hub/1/x/y' }
],
folder
)
).toBeUndefined()
expect(
refContainmentViolation(
[
{ kind: 'resource', path: 'f/proj/db' },
{ kind: 'resource', path: 'u/admin/secret' }
],
folder
)
).toContain('u/admin/secret')
})
})
describe('varContainmentViolation', () => {
const folder = 'proj'
it('allows in-folder variable references', () => {
expect(varContainmentViolation({ token: '$var:f/proj/token' }, folder)).toBeUndefined()
expect(varContainmentViolation({ x: 'no refs here' }, folder)).toBeUndefined()
})
it('rejects a `$var:` or `$jsonvar:` bound to another namespace', () => {
// The crux: a variable arg the ref extractors miss, resolved under the perms.
expect(varContainmentViolation({ queue_url: '$var:u/admin/token' }, folder)).toContain(
'u/admin/token'
)
expect(varContainmentViolation({ cfg: '$jsonvar:f/other/secret' }, folder)).toContain('escapes')
})
it('ignores a `$var:` literal embedded in inline code', () => {
const flowValue = {
flow_env: { API: '$var:f/proj/api_key' },
modules: [{ value: { type: 'rawscript', content: 'return "$var:u/admin/should_not_flag"' } }]
}
expect(varContainmentViolation(flowValue, folder)).toBeUndefined()
})
})
@@ -0,0 +1,405 @@
// Imports a Hub project export into a workspace: one importer per item kind,
// each item reported individually so one bad item never aborts the rest.
// UI-free — the install page owns folder choice and migration review.
import {
AppService,
FlowService,
FolderService,
ResourceService,
ScriptService,
VariableService,
WorkspaceService
} from '$lib/gen'
import {
TRIGGER_KINDS,
createWorkspaceTriggerDisabled,
triggerHandlerRefs,
type WorkspaceTrigger,
type WorkspaceTriggerKind
} from '../triggers/workspaceTriggersList'
import { updatePolicy } from '$lib/components/apps/editor/appPolicy'
import { updateRawAppPolicy } from '$lib/sharedUtils'
import type { App } from '$lib/components/apps/types'
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
import {
classifyPath,
collectExportVarPaths,
extractAppRefs,
extractFlowRefs,
extractRawAppRefs,
extractScriptRefs,
extractTriggerConfigResourceRefs,
extractVarRefsFromValue,
retargetProjectExport,
type ExportItem,
type ProjectExport,
type ProjectMigration,
type Ref
} from './projectBundle'
export interface InstallResult {
path: string
ok: boolean
error?: string
}
// Guarding an item's own path is not enough: the `$res:`/script/flow refs baked
// into its content are live bindings the backend acts on. A well-formed export
// relocates them all into f/<folder>/ (hub/ script refs stay external); anything
// else points a runnable at an existing asset in another namespace, so refuse the
// item rather than bind it there. Resources are never hub-hosted, so a hub/ path
// there is not a valid escape hatch. Mirrors the trigger-config containment.
export function refContainmentViolation(refs: Ref[], folder: string): string | undefined {
for (const r of refs) {
const cls = classifyPath(r.path, folder)
if (cls === 'internal') continue
if (cls === 'hub' && r.kind !== 'resource') continue
return `reference '${r.path}' escapes the target folder f/${folder}/ — skipped`
}
return undefined
}
// `$var:`/`$jsonvar:` references (in flow static inputs, flow_env, app runnable
// inputs, trigger config) are resolved at runtime under the imported runnable's
// permissions and are never hub-hosted. Retargeting relocates a project's own refs
// into the target folder; anything still outside it points at another namespace, so
// reject those. Takes the parsed value so inline code carrying a literal is ignored.
export function varContainmentViolation(value: any, folder: string): string | undefined {
for (const p of extractVarRefsFromValue(value)) {
if (classifyPath(p, folder) !== 'internal') {
return `variable '${p}' escapes the target folder f/${folder}/ — skipped`
}
}
return undefined
}
// Surface the backend's explanation: API errors carry the real message in
// `.body` (plain text for Windmill 4xx), while `.message` is the generic
// status text ("Bad Request"). Prefer the body so e.g. a path/route_path
// collision reads as the actual reason, not just "Bad Request".
function errorMessage(e: any): string {
const body = e?.body
if (typeof body === 'string' && body.trim() !== '') return body
if (body && typeof body === 'object')
return body.error?.message ?? body.message ?? JSON.stringify(body)
return e?.message ?? String(e)
}
// Recompute an app's execution policy from its (retargeted) value, mirroring
// what the editor does on deploy. `triggerables_v2` is keyed by
// `<component>:rawscript/<sha256(inline content)>`; retargeting rewrites that
// content, so a copied or empty policy would leave every inline runnable
// "forbidden by policy" at runtime. Default to publisher (auth required).
async function computeAppPolicy(value: any): Promise<any> {
const policy = (await updatePolicy(value as App, undefined)) as any
if (!policy.execution_mode) policy.execution_mode = 'publisher'
return policy
}
async function computeRawAppPolicy(runnables: Record<string, any>): Promise<any> {
const policy = (await updateRawAppPolicy(runnables, undefined)) as any
if (!policy.execution_mode) policy.execution_mode = 'publisher'
return policy
}
function importScript(workspace: string, s: ExportItem): Promise<unknown> {
return ScriptService.createScript({
workspace,
requestBody: {
path: s.path,
summary: s.summary ?? '',
description: s.description ?? '',
content: s.content ?? '',
language: s.language,
schema: s.schema ?? undefined,
kind: s.kind ?? 'script',
lock: s.lockfile ?? undefined
}
})
}
function importFlow(workspace: string, f: ExportItem): Promise<unknown> {
return FlowService.createFlow({
workspace,
requestBody: {
path: f.path,
summary: f.summary ?? '',
description: f.description ?? '',
value: f.value,
schema: f.schema ?? undefined
}
})
}
// Stubs only: never overwrite an existing resource's value (updateIfExists
// stays false so a path collision is reported as a failed item instead).
function importResourceStub(workspace: string, r: ExportItem): Promise<unknown> {
return ResourceService.createResource({
workspace,
updateIfExists: false,
requestBody: {
path: r.path,
resource_type: r.resource_type,
value: {},
description: 'Imported stub — fill in the value.'
}
})
}
// Variables hold secrets/config, so their values are never shipped. Create an empty
// secret placeholder for a project variable the importer must fill, mirroring the
// resource stubs. Conflict-safe: an already-present variable (the importer filled it,
// or a re-import) is left untouched rather than clobbered.
async function importVariablePlaceholder(workspace: string, path: string): Promise<void> {
if (await VariableService.existsVariable({ workspace, path })) return
await VariableService.createVariable({
workspace,
requestBody: {
path,
value: '',
is_secret: true,
description: 'Imported placeholder — fill in the value.'
}
})
}
async function importApp(workspace: string, a: ExportItem): Promise<unknown> {
if (a.app_type === 'raw') {
let parsed: any
try {
parsed = JSON.parse(a.value?.raw ?? '{}')
} catch (e: any) {
throw new Error(`invalid raw app bundle: ${e?.message ?? String(e)}`)
}
const files = { ...(parsed.files ?? {}) }
const js = files['/bundle.js'] ?? ''
const css = files['/bundle.css'] ?? ''
delete files['/bundle.js']
delete files['/bundle.css']
const runnables = parsed.runnables ?? {}
return AppService.createAppRaw({
workspace,
formData: {
app: {
path: a.path,
summary: a.summary ?? '',
value: {
files,
runnables,
// Keep the full-code app's explicit data table declaration.
...(parsed.data !== undefined ? { data: parsed.data } : {}),
...(parsed.datatables !== undefined ? { datatables: parsed.datatables } : {})
},
policy: await computeRawAppPolicy(runnables)
},
js,
css
}
})
}
return AppService.createApp({
workspace,
requestBody: {
path: a.path,
summary: a.summary ?? '',
value: a.value,
policy: await computeAppPolicy(a.value)
}
})
}
// Apply one migration to the target data table. If the data table opted into
// migrations, record it (datatable_migrations + _wm_migrations, run only this
// version); otherwise run the SQL once as a preview job (unrecorded).
async function applyOneMigration(
workspace: string,
projectSlug: string,
m: ProjectMigration
): Promise<void> {
let recorded = false
try {
const status = await WorkspaceService.getDatatableMigrationsStatus({
workspace,
datatableName: m.datatable_name
})
recorded = !!status.enabled
} catch {}
if (recorded) {
// Record the shipped down migration (DROP the created tables) so it can be
// rolled back.
const codeDown = (m.sql_down ?? '').trim()
const created = await WorkspaceService.createDatatableMigration({
workspace,
datatableName: m.datatable_name,
requestBody: {
name: `hub_import_${projectSlug}`,
code_up: m.sql,
code_down: codeDown || undefined
}
})
await WorkspaceService.runDatatableMigrations({
workspace,
datatableName: m.datatable_name,
only: created.timestamp
})
} else {
await runScriptAndPollResult({
workspace,
requestBody: {
language: 'postgresql',
content: m.sql,
args: { database: `datatable://${m.datatable_name}` }
}
})
}
}
/**
* Install a project export into `workspace` under `f/<folder>/`: create the
* folder, retarget every item, import kind by kind, then apply the (already
* reviewed) migrations. Each item's outcome is reported through `onResult`;
* failures never abort the remaining items.
*/
export async function installProject(args: {
workspace: string
exportData: ProjectExport
folder: string
migrations: ProjectMigration[]
hasEeLicense: boolean
onResult: (r: InstallResult) => void
}): Promise<void> {
const { workspace, exportData, folder, migrations, hasEeLicense, onResult } = args
const record = (path: string, p: Promise<unknown>): Promise<void> =>
p.then(
() => onResult({ path, ok: true }),
(e: any) => onResult({ path, ok: false, error: errorMessage(e) })
)
try {
await FolderService.createFolder({ workspace, requestBody: { name: folder } })
} catch {}
const proj = retargetProjectExport(exportData, exportData.project.slug, folder)
// The export is remote input: every path it wants to write must stay inside
// the folder the user chose. Anything else (crafted export, or an export
// whose items weren't relocated into f/<slug>/ at publish) is refused
// per-item instead of being created in another namespace.
const prefix = `f/${folder}/`
const guard = (path: unknown, ...also: unknown[]): string | undefined => {
for (const p of [path, ...also]) {
if (typeof p !== 'string' || !p.startsWith(prefix)) {
return `path '${String(p)}' escapes the target folder ${prefix} — skipped`
}
}
return undefined
}
const checked = (path: unknown, run: () => Promise<unknown>, ...also: unknown[]) => {
const violation = guard(path, ...also)
return violation
? record(String(path), Promise.reject(new Error(violation)))
: record(String(path), run())
}
// `refs` catches structured runnable/`$res:` refs; `varValue` is the parsed item
// walked for `$var:`/`$jsonvar:` argument refs (which the ref extractors miss).
const checkedItem = (path: unknown, refs: Ref[], varValue: any, run: () => Promise<unknown>) => {
const violation =
guard(path) ??
refContainmentViolation(refs, folder) ??
varContainmentViolation(varValue, folder)
return violation
? record(String(path), Promise.reject(new Error(violation)))
: record(String(path), run())
}
for (const s of proj.scripts) {
// `$var:` is resolved in job args (flow inputs, schedule args, trigger config),
// not in script source, so there is no variable arg to contain here.
await checkedItem(s.path, extractScriptRefs(s.content ?? ''), undefined, () =>
importScript(workspace, s)
)
}
for (const f of proj.flows) {
await checkedItem(f.path, extractFlowRefs(f.value), f.value, () => importFlow(workspace, f))
}
for (const r of proj.resources) {
await checked(r.path, () => importResourceStub(workspace, r))
}
// Placeholders for the project's internal `$var:`/`$jsonvar:` refs (retargeted
// into this folder). External refs are rejected per-item, so only stub in-folder
// ones; guard again in case an out-of-folder ref slipped through retargeting.
for (const p of collectExportVarPaths(proj)) {
if (!p.startsWith(prefix)) continue
await record(`variable: ${p}`, importVariablePlaceholder(workspace, p))
}
for (const a of proj.apps) {
const isRaw = a.app_type === 'raw'
const refs = isRaw ? extractRawAppRefs(a.value?.raw ?? '') : extractAppRefs(a.value)
// Raw apps hold their runnables in the `value.raw` JSON string; parse it so the
// walk sees the same structure the backend resolves. Malformed raw fails at import.
let varValue: any = a.value
if (isRaw) {
try {
varValue = JSON.parse(a.value?.raw ?? '{}')
} catch {
varValue = undefined
}
}
await checkedItem(a.path, refs, varValue, () => importApp(workspace, a))
}
// A trigger's config is a live binding, not inert content: resource fields,
// handler runnables and $res: refs it names are acted on by the backend, so
// every one must stay inside the chosen folder (handlers may also point at
// hub/ scripts). Otherwise a crafted export could bind the trigger to
// existing assets in another namespace.
const triggerConfigViolation = (t: ExportItem): string | undefined => {
const cfg = (t.config ?? {}) as Record<string, any>
for (const r of triggerHandlerRefs({ kind: t.kind, config: cfg } as WorkspaceTrigger)) {
if (!r.path.startsWith(prefix) && !r.path.startsWith('hub/')) {
return `handler '${r.path}' escapes the target folder ${prefix} — skipped`
}
}
const resourceRefs = new Set(extractTriggerConfigResourceRefs(cfg))
const field = TRIGGER_KINDS[t.kind as WorkspaceTriggerKind]?.resourceField
const fieldValue = field ? cfg[field] : undefined
if (typeof fieldValue === 'string' && fieldValue !== '') resourceRefs.add(fieldValue)
for (const p of resourceRefs) {
if (!p.startsWith(prefix)) {
return `resource '${p}' escapes the target folder ${prefix} — skipped`
}
}
// Config fields (e.g. SQS queue_url) can carry `$var:`/`$jsonvar:` refs too.
return varContainmentViolation(cfg, folder)
}
for (const t of proj.triggers) {
const violation = guard(t.path, t.runnable_path) ?? triggerConfigViolation(t)
await record(
String(t.path),
violation
? Promise.reject(new Error(violation))
: createWorkspaceTriggerDisabled(
workspace,
{
kind: t.kind,
path: t.path,
script_path: t.runnable_path,
is_flow: t.runnable_kind === 'flow',
summary: t.summary ?? null,
config: t.config ?? null
},
{ hasEeLicense }
)
)
}
// Apply the reviewed data table migrations after items exist.
for (const m of migrations) {
await record(
`data table: ${m.datatable_name}`,
applyOneMigration(workspace, exportData.project.slug, m)
)
}
}
@@ -0,0 +1,372 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// inferAssets loads WASM; stub it so script detection is deterministic and no
// wasm init runs in the test.
const inferAssetsMock = vi.fn()
vi.mock('$lib/infer', () => ({ inferAssets: (...a: any[]) => inferAssetsMock(...a) }))
// Only getDatatableFullSchema is used by the generator; stub the whole service.
const getDatatableFullSchemaMock = vi.fn()
vi.mock('$lib/gen', () => ({
WorkspaceService: {
getDatatableFullSchema: (...a: any[]) => getDatatableFullSchemaMock(...a)
}
}))
import { detectDatatableTables, generateDatatableMigrations } from './projectMigrations'
import type { FetchedItem } from './projectBundle'
describe('detectDatatableTables', () => {
beforeEach(() => inferAssetsMock.mockReset())
it('collects datatable/table refs from scripts (re-parsed), flows and raw apps', async () => {
inferAssetsMock.mockResolvedValue({
status: 'ok',
assets: [
{ kind: 'datatable', path: 'main/customers' },
{ kind: 'resource', path: 'u/admin/pg' } // ignored
]
})
const items: FetchedItem[] = [
{ kind: 'script', path: 'f/p/s', language: 'duckdb', content: 'select 1' },
{
kind: 'flow',
path: 'f/p/fl',
value: {
modules: [
{
id: 'a',
value: {
type: 'rawscript',
language: 'duckdb',
content: '',
assets: [{ kind: 'datatable', path: 'main/orders' }]
}
}
]
}
},
{
kind: 'raw_app',
path: 'f/p/app',
content: JSON.stringify({
runnables: {
r1: { inlineScript: { assets: [{ kind: 'datatable', path: 'analytics/events' }] } }
}
})
}
]
const usage = await detectDatatableTables(items)
expect([...(usage.get('main') ?? [])].sort()).toEqual(['customers', 'orders'])
expect([...(usage.get('analytics') ?? [])]).toEqual(['events'])
})
it('collects datatable refs from the preprocessor module', async () => {
inferAssetsMock.mockResolvedValue({ status: 'ok', assets: [] })
const items: FetchedItem[] = [
{
kind: 'flow',
path: 'f/p/fl',
value: {
modules: [],
preprocessor_module: {
id: 'pre',
value: {
type: 'rawscript',
language: 'duckdb',
content: '',
assets: [{ kind: 'datatable', path: 'main/inbox' }]
}
}
}
}
]
const usage = await detectDatatableTables(items)
expect([...(usage.get('main') ?? [])]).toEqual(['inbox'])
})
it('records a datatable used with no specific table', async () => {
inferAssetsMock.mockResolvedValue({
status: 'ok',
assets: [{ kind: 'datatable', path: 'main' }]
})
const usage = await detectDatatableTables([
{ kind: 'script', path: 'f/p/s', language: 'duckdb', content: 'x' }
])
expect(usage.has('main')).toBe(true)
expect(usage.get('main')?.size).toBe(0)
})
it('reads a full-code apps explicit data.tables declaration', async () => {
const items: FetchedItem[] = [
{
kind: 'raw_app',
path: 'f/p/app',
content: JSON.stringify({
runnables: {},
data: {
datatable: 'main',
schema: 'app1',
tables: ['main/customers', 'main/app1:orders']
}
})
}
]
const usage = await detectDatatableTables(items)
// public-schema ref keeps the bare name; non-public keeps schema.table.
expect([...(usage.get('main') ?? [])].sort()).toEqual(['app1.orders', 'customers'])
})
})
describe('generateDatatableMigrations', () => {
beforeEach(() => getDatatableFullSchemaMock.mockReset())
const schema = {
public: {
customers: {
name: 'customers',
columns: [
{ name: 'id', datatype: 'integer', primary_key: true, nullable: false },
{ name: 'email', datatype: 'text', nullable: true }
],
foreign_keys: []
},
orders: {
name: 'orders',
columns: [
{ name: 'id', datatype: 'integer', primary_key: true, nullable: false },
{ name: 'customer_id', datatype: 'integer', nullable: false }
],
foreign_keys: [
{
target_table: 'public.customers',
columns: [{ source_column: 'customer_id', target_column: 'id' }],
on_delete: 'NO ACTION',
on_update: 'NO ACTION'
}
]
}
}
}
it('creates referenced tables in FK-dependency order in one transaction, enabled', async () => {
getDatatableFullSchemaMock.mockResolvedValue(schema)
const usage = new Map([['main', new Set(['orders', 'customers'])]])
const migrations = await generateDatatableMigrations('ws', usage)
expect(migrations).toHaveLength(1)
const m = migrations[0]
expect(m.datatable_name).toBe('main')
expect(m.enabled).toBe(true)
expect(m.sql.startsWith('BEGIN;')).toBe(true)
expect(m.sql.trimEnd().endsWith('COMMIT;')).toBe(true)
// customers (FK target) must be created before orders (FK source).
expect(m.sql.indexOf('"public"."customers"')).toBeLessThan(m.sql.indexOf('"public"."orders"'))
// A single wrapping transaction, not one per table.
expect(m.sql.match(/BEGIN;/g)?.length).toBe(1)
// Idempotent: won't abort if a pulled-in parent already exists in the target.
expect(m.sql).toContain('CREATE TABLE IF NOT EXISTS "public"."customers"')
// Down migration lists drops commented out (nothing dropped by default),
// in reverse order: orders (child) before customers (parent).
expect(m.sql_down).toContain('-- DROP TABLE IF EXISTS "public"."orders";')
expect(m.sql_down).toContain('-- DROP TABLE IF EXISTS "public"."customers";')
// No uncommented DROP TABLE anywhere.
expect(/^\s*DROP TABLE/m.test(m.sql_down)).toBe(false)
expect(m.sql_down.indexOf('"public"."orders"')).toBeLessThan(
m.sql_down.indexOf('"public"."customers"')
)
})
it('accepts schema-qualified table refs', async () => {
getDatatableFullSchemaMock.mockResolvedValue(schema)
const usage = new Map([['main', new Set(['public.customers'])]])
const migrations = await generateDatatableMigrations('ws', usage)
expect(migrations[0].enabled).toBe(true)
expect(migrations[0].sql).toContain('"public"."customers"')
})
it('leaves a qualified ref unresolved when its schema misses, never another schema\'s table', async () => {
getDatatableFullSchemaMock.mockResolvedValue(schema)
const usage = new Map([['main', new Set(['sales.orders'])]])
const migrations = await generateDatatableMigrations('ws', usage)
expect(migrations[0].sql).toContain('"sales.orders" is referenced but was not found')
expect(migrations[0].sql).not.toContain('CREATE TABLE "')
})
it('emits all CREATE TABLEs before any FK constraint so circular FKs work', async () => {
const cyclicSchema = {
public: {
a: {
name: 'a',
columns: [
{ name: 'id', datatype: 'integer', primary_key: true, nullable: false },
{ name: 'b_id', datatype: 'integer', nullable: true }
],
foreign_keys: [
{
target_table: 'public.b',
columns: [{ source_column: 'b_id', target_column: 'id' }],
on_delete: 'NO ACTION',
on_update: 'NO ACTION'
}
]
},
b: {
name: 'b',
columns: [
{ name: 'id', datatype: 'integer', primary_key: true, nullable: false },
{ name: 'a_id', datatype: 'integer', nullable: true }
],
foreign_keys: [
{
target_table: 'public.a',
columns: [{ source_column: 'a_id', target_column: 'id' }],
on_delete: 'NO ACTION',
on_update: 'NO ACTION'
}
]
}
}
}
getDatatableFullSchemaMock.mockResolvedValue(cyclicSchema)
const usage = new Map([['main', new Set(['a', 'b'])]])
const migrations = await generateDatatableMigrations('ws', usage)
const sql = migrations[0].sql
expect(sql).toContain('"public"."a"')
expect(sql).toContain('"public"."b"')
// Both FK constraints present, and every CREATE TABLE precedes the first one.
expect(sql.match(/ADD CONSTRAINT/g)?.length).toBe(2)
const lastCreate = sql.lastIndexOf('CREATE TABLE IF NOT EXISTS')
const firstConstraint = sql.indexOf('DO $$')
expect(lastCreate).toBeGreaterThan(-1)
expect(firstConstraint).toBeGreaterThan(lastCreate)
})
it('guards FK creation so re-running on an existing table does not abort', async () => {
getDatatableFullSchemaMock.mockResolvedValue(schema)
const usage = new Map([['main', new Set(['orders'])]])
const migrations = await generateDatatableMigrations('ws', usage)
const sql = migrations[0].sql
// The ADD CONSTRAINT must be wrapped in a pg_constraint existence check.
expect(sql).toContain('DO $$')
expect(sql).toContain('SELECT 1 FROM pg_constraint')
expect(sql).toContain(`conrelid = '"public"."orders"'::regclass`)
// No unguarded ALTER TABLE ... ADD at the start of a line.
expect(/^ALTER TABLE .* ADD CONSTRAINT/m.test(sql)).toBe(false)
})
it('creates non-public schemas before their tables', async () => {
const appSchema = {
app: {
customers: {
name: 'customers',
columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }],
foreign_keys: []
}
}
}
getDatatableFullSchemaMock.mockResolvedValue(appSchema)
const usage = new Map([['main', new Set(['app.customers'])]])
const migrations = await generateDatatableMigrations('ws', usage)
const sql = migrations[0].sql
expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS "app";')
expect(sql.indexOf('CREATE SCHEMA IF NOT EXISTS "app";')).toBeLessThan(
sql.indexOf('CREATE TABLE IF NOT EXISTS "app"."customers"')
)
expect(sql).not.toContain('CREATE SCHEMA IF NOT EXISTS "public"')
})
it('keeps same-named tables from different schemas both created', async () => {
const twoSchemas = {
public: {
customers: {
name: 'customers',
columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }],
foreign_keys: []
}
},
app: {
customers: {
name: 'customers',
columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }],
foreign_keys: []
}
}
}
getDatatableFullSchemaMock.mockResolvedValue(twoSchemas)
const usage = new Map([['main', new Set(['public.customers', 'app.customers'])]])
const migrations = await generateDatatableMigrations('ws', usage)
expect(migrations[0].sql).toContain('"public"."customers"')
expect(migrations[0].sql).toContain('"app"."customers"')
})
it('transitively pulls in FK-referenced tables not directly used', async () => {
getDatatableFullSchemaMock.mockResolvedValue(schema)
// Only `orders` is referenced; `customers` (its FK target) must still be
// created, and before `orders`.
const usage = new Map([['main', new Set(['orders'])]])
const migrations = await generateDatatableMigrations('ws', usage)
const m = migrations[0]
expect(m.enabled).toBe(true)
expect(m.sql).toContain('"public"."customers"')
expect(m.sql).toContain('"public"."orders"')
expect(m.sql.indexOf('"public"."customers"')).toBeLessThan(m.sql.indexOf('"public"."orders"'))
})
it('drops a foreign key whose target is not in the schema', async () => {
// `orders` references a `warehouses` table that no longer exists in the
// schema: the FK must be pruned so the migration still runs.
const schemaWithDanglingFk = {
public: {
orders: {
name: 'orders',
columns: [{ name: 'id', datatype: 'integer', primary_key: true, nullable: false }],
foreign_keys: [
{
target_table: 'public.warehouses',
columns: [{ source_column: 'id', target_column: 'id' }],
on_delete: 'NO ACTION',
on_update: 'NO ACTION'
}
]
}
}
}
getDatatableFullSchemaMock.mockResolvedValue(schemaWithDanglingFk)
const usage = new Map([['main', new Set(['orders'])]])
const migrations = await generateDatatableMigrations('ws', usage)
expect(migrations[0].enabled).toBe(true)
expect(migrations[0].sql).toContain('"public"."orders"')
expect(migrations[0].sql).not.toContain('warehouses')
})
it('emits a disabled comment entry when a referenced table is not found', async () => {
getDatatableFullSchemaMock.mockResolvedValue(schema)
const usage = new Map([['main', new Set(['nonexistent'])]])
const migrations = await generateDatatableMigrations('ws', usage)
expect(migrations).toHaveLength(1)
expect(migrations[0].enabled).toBe(false)
expect(migrations[0].sql).toContain('-- Table "nonexistent" is referenced but was not found')
expect(migrations[0].sql).not.toContain('BEGIN;')
})
it('keeps found tables and comments the missing ones in one migration', async () => {
getDatatableFullSchemaMock.mockResolvedValue(schema)
const usage = new Map([['main', new Set(['customers', 'ghost'])]])
const migrations = await generateDatatableMigrations('ws', usage)
expect(migrations[0].enabled).toBe(true)
expect(migrations[0].sql).toContain('"public"."customers"')
expect(migrations[0].sql).toContain('-- Table "ghost" is referenced but was not found')
// Comments precede the runnable transaction.
expect(migrations[0].sql.indexOf('-- Table "ghost"')).toBeLessThan(
migrations[0].sql.indexOf('BEGIN;')
)
})
it('comments a data table used with no specific table', async () => {
getDatatableFullSchemaMock.mockResolvedValue(schema)
const usage = new Map([['main', new Set<string>()]])
const migrations = await generateDatatableMigrations('ws', usage)
expect(migrations[0].enabled).toBe(false)
expect(migrations[0].sql).toContain('no specific table was referenced')
})
})
@@ -0,0 +1,345 @@
// Best-effort data table migration generation for the "project = folder" Hub
// bundle. Detects which data tables (and tables within them) a project's
// scripts/flows/raw apps reference via `datatable` assets, then generates a
// `CREATE TABLE` bundle per data table from the source workspace's live schema,
// so importing the project into another workspace can recreate those tables.
//
// Best-effort by design: the generated SQL is shown to the publisher and is
// fully editable before publishing. Low-code (non-raw) apps have no persisted
// asset list and are not scanned.
import { inferAssets } from '$lib/infer'
import type { SupportedLanguage } from '$lib/common'
import { allFlowModules } from './projectBundle'
import { getFlowModuleAssets } from '$lib/components/assets/lib'
import { extractDataConfig, parseDataTableRef } from '$lib/components/raw_apps/dataTableRefUtils'
import {
apiSchemaToEditorSchema,
generateAddedTableSql,
type DatabaseSchema
} from '$lib/components/datatableSchemaSql'
import { WorkspaceService } from '$lib/gen'
import type { FetchedItem } from './projectBundle'
export interface GeneratedMigration {
datatable_name: string
/** Up migration: creates the tables. */
sql: string
/** Down migration: drops the created tables. Best-effort, generated once and
* editable by the publisher (not re-derived from `sql`). */
sql_down: string
enabled: boolean
}
// A datatable asset path is `datatable`, `datatable/table`, or
// `datatable/schema.table` (see the SQL asset parser). The first segment is the
// data table name; the remainder identifies a specific table (absent = whole
// data table, no table to create).
function parseDatatableAssetPath(path: string): { datatable: string; table?: string } {
const slash = path.indexOf('/')
if (slash === -1) return { datatable: path }
const datatable = path.slice(0, slash)
const table = path.slice(slash + 1).trim()
return { datatable, table: table || undefined }
}
function addDatatableTable(
map: Map<string, Set<string>>,
datatable: string,
table: string | undefined
): void {
if (!datatable) return
const set = map.get(datatable) ?? new Set<string>()
if (table) set.add(table)
map.set(datatable, set)
}
function addUsage(map: Map<string, Set<string>>, path: string): void {
const { datatable, table } = parseDatatableAssetPath(path)
addDatatableTable(map, datatable, table)
}
/**
* Scan a project's fetched items for data table usage and return
* `datatable -> set of table refs` (a table ref is `table` or `schema.table`).
* - scripts: re-parse the code with the asset parser (`inferAssets`)
* - flows: read each module's stored `assets`
* - full-code (raw) apps: read the explicit `data.tables` declaration; fall back
* to `runnables[key].inlineScript.assets` for older apps
*/
export async function detectDatatableTables(
items: FetchedItem[]
): Promise<Map<string, Set<string>>> {
const map = new Map<string, Set<string>>()
for (const item of items) {
if (item.kind === 'script') {
const res = await inferAssets(
item.language as SupportedLanguage | undefined,
item.content ?? ''
)
if (res.status === 'ok') {
for (const a of res.assets) if (a.kind === 'datatable') addUsage(map, a.path)
}
} else if (item.kind === 'flow') {
for (const mod of allFlowModules(item.value)) {
const assets = getFlowModuleAssets(mod)
if (assets) for (const a of assets) if (a.kind === 'datatable') addUsage(map, a.path)
}
} else if (item.kind === 'raw_app') {
let parsed: any
try {
parsed = JSON.parse(item.content ?? '{}')
} catch {
continue
}
// Full-code apps explicitly declare the data tables/tables they use
// (`data.tables`, refs like `main/customers` or `main/schema:table`), so
// read that rather than parsing assets.
const config = extractDataConfig(parsed)
if (config) {
for (const ref of config.tables) {
const r = parseDataTableRef(ref)
const table = r.table
? r.schema && r.schema !== 'public'
? `${r.schema}.${r.table}`
: r.table
: undefined
addDatatableTable(map, r.datatable, table)
}
}
// Older raw apps instead carry datatable usage as inline-script assets.
const runnables = parsed?.runnables ?? {}
for (const key of Object.keys(runnables)) {
const assets = runnables[key]?.inlineScript?.assets
if (Array.isArray(assets))
for (const a of assets)
if (a?.kind === 'datatable' && typeof a.path === 'string') addUsage(map, a.path)
}
}
}
return map
}
// Resolve a table ref (`table` or `schema.table`) to a concrete
// `{ schemaName, tableName }` present in the live schema, or undefined if the
// table can't be found (dropped since, typo, …). A schema-qualified ref that
// misses stays unresolved: falling back to a same-named table in another
// schema would generate a migration for an unrelated table while the code
// still references the missing one.
function resolveTable(
schema: DatabaseSchema,
tableRef: string
): { schemaName: string; tableName: string } | undefined {
const dot = tableRef.indexOf('.')
if (dot !== -1) {
const schemaName = tableRef.slice(0, dot)
const tableName = tableRef.slice(dot + 1)
return schema[schemaName]?.[tableName] ? { schemaName, tableName } : undefined
}
// Bare name: find it across every schema, first match wins.
for (const schemaName of Object.keys(schema)) {
if (schema[schemaName][tableRef]) return { schemaName, tableName: tableRef }
}
return undefined
}
type ResolvedTable = { schemaName: string; tableName: string }
const tableKey = (t: ResolvedTable) => `${t.schemaName}.${t.tableName}`
// Grow the set of tables to create so it's closed under foreign keys: a used
// table's FK targets (and their FK targets, transitively) are pulled in, so the
// generated CREATE TABLEs never reference a table that isn't also created. FK
// targets that don't resolve in this schema are left out (their FK is pruned by
// pruneSchemaForTables).
function expandFkClosure(schema: DatabaseSchema, seed: ResolvedTable[]): ResolvedTable[] {
const inSet = new Map(seed.map((t) => [tableKey(t), t]))
const queue = [...seed]
while (queue.length > 0) {
const t = queue.shift()!
const fks = schema[t.schemaName]?.[t.tableName]?.foreignKeys ?? []
for (const fk of fks) {
const target = resolveTable(schema, fk.targetTable ?? '')
if (target && !inSet.has(tableKey(target))) {
inSet.set(tableKey(target), target)
queue.push(target)
}
}
}
return [...inSet.values()]
}
// A copy of the schema restricted to `tables`, with each table's foreign keys
// filtered to targets that are also in `tables`. generateAddedTableSql emits every
// FK it finds on a table, so pruning here keeps a stray FK (to a table outside the
// migration) from making the generated SQL fail.
function pruneSchemaForTables(schema: DatabaseSchema, tables: ResolvedTable[]): DatabaseSchema {
const inSet = new Set(tables.map(tableKey))
const pruned: DatabaseSchema = {}
for (const t of tables) {
const orig = schema[t.schemaName]?.[t.tableName]
if (!orig) continue
;(pruned[t.schemaName] ??= {})[t.tableName] = {
...orig,
foreignKeys: (orig.foreignKeys ?? []).filter((fk) => {
const target = resolveTable(schema, fk.targetTable ?? '')
return target != null && inSet.has(tableKey(target))
})
}
}
return pruned
}
// Order tables so a table is created after the in-set tables it references via a
// foreign key. Keyed by schema-qualified name (like the rest of the pipeline) so
// two same-named tables in different schemas aren't collapsed. Falls back to input
// order on a cycle so generation never hangs.
function orderByFkDependency(schema: DatabaseSchema, tables: ResolvedTable[]): ResolvedTable[] {
const inSet = new Set(tables.map(tableKey))
const deps = new Map<string, Set<string>>()
for (const t of tables) {
const fks = schema[t.schemaName]?.[t.tableName]?.foreignKeys ?? []
const targets = new Set<string>()
for (const fk of fks) {
const target = resolveTable(schema, fk.targetTable ?? '')
if (target && tableKey(target) !== tableKey(t) && inSet.has(tableKey(target))) {
targets.add(tableKey(target))
}
}
deps.set(tableKey(t), targets)
}
const ordered: ResolvedTable[] = []
const done = new Set<string>()
const visiting = new Set<string>()
const byKey = new Map(tables.map((t) => [tableKey(t), t]))
const visit = (key: string) => {
if (done.has(key) || visiting.has(key)) return
visiting.add(key)
for (const dep of deps.get(key) ?? []) visit(dep)
visiting.delete(key)
done.add(key)
const t = byKey.get(key)
if (t) ordered.push(t)
}
for (const t of tables) visit(tableKey(t))
return ordered
}
// Pull a readable one-line message out of an API error for embedding in a SQL
// comment (collapse whitespace so it can't break out of the `--` line).
function errorText(e: any): string {
const body = e?.body
const raw =
typeof body === 'string' && body.trim()
? body
: body && typeof body === 'object'
? (body.error?.message ?? body.message ?? JSON.stringify(body))
: (e?.message ?? String(e))
return String(raw).replace(/\s+/g, ' ').trim()
}
/**
* Generate one best-effort migration per used data table. Resolved tables (plus
* the tables they depend on via foreign key, in FK-dependency order) become a
* single CREATE TABLE transaction, enabled by default. Anything that couldn't be
* auto-generated a table not found in the schema, a data table referenced as a
* whole, or a schema that couldn't be loaded is written as a `--` SQL comment
* describing the problem, so the publisher sees what's missing instead of a blank
* entry. A migration with no runnable statements (only comments) is left disabled.
*/
export async function generateDatatableMigrations(
workspace: string,
usage: Map<string, Set<string>>
): Promise<GeneratedMigration[]> {
const out: GeneratedMigration[] = []
for (const [datatable, tableRefs] of usage) {
let schema: DatabaseSchema
try {
const api = await WorkspaceService.getDatatableFullSchema({
workspace,
requestBody: { source: `datatable://${datatable}` }
})
schema = apiSchemaToEditorSchema(api)
} catch (e) {
// Couldn't reach the schema at all: leave a commented stub explaining why,
// so the publisher can fill it in rather than seeing a silent blank.
out.push({
datatable_name: datatable,
sql:
`-- Could not load the schema of data table "${datatable}": ${errorText(e)}\n` +
`-- Add the CREATE TABLE statement(s) for the tables this project uses.`,
sql_down: '',
enabled: false
})
continue
}
// Resolve the referenced tables; record a comment for each one we can't find
// so a partial migration still explains what's missing.
const resolved: ResolvedTable[] = []
const comments: string[] = []
for (const ref of tableRefs) {
const t = resolveTable(schema, ref)
if (t) resolved.push(t)
else
comments.push(
`-- Table "${ref}" is referenced but was not found in data table "${datatable}"; add its CREATE TABLE manually.`
)
}
if (tableRefs.size === 0) {
comments.push(
`-- Data table "${datatable}" is used but no specific table was referenced; nothing to generate automatically.`
)
}
// Pull in the tables the referenced ones depend on via FK, then generate
// against a schema whose FKs are restricted to this set, so the migration
// creates everything it references and never emits a dangling FK.
const closure = expandFkClosure(schema, resolved)
const ordered = orderByFkDependency(schema, closure)
const prunedSchema = pruneSchemaForTables(schema, ordered)
// Every CREATE TABLE is emitted before any FK constraint: circular FKs have
// no valid creation order, so constraints can only run once all tables exist.
const creates: string[] = []
const constraints: string[] = []
for (const t of ordered) {
// IF NOT EXISTS: FK closure pulls in shared parent tables (e.g. a
// referenced `orders` drags in `customers`) that often already exist in
// the target, so a plain CREATE would abort the whole transaction. The
// caveat — an existing differently-shaped table is silently left as-is —
// is acceptable for a best-effort, editable migration.
const gen = generateAddedTableSql(
{ schemaName: t.schemaName, tableName: t.tableName, kind: 'added' },
prunedSchema,
{ ifNotExists: true }
)
if (!gen) continue
creates.push(gen.create)
constraints.push(...gen.constraints)
}
const statements = [...creates, ...constraints]
// Comments (the errors) go on top; the CREATE TABLE transaction, if any,
// follows. Enabled only when there's something to run.
const parts: string[] = []
if (comments.length > 0) parts.push(comments.join('\n'))
if (statements.length > 0) parts.push(`BEGIN;\n${statements.join('\n\n')}\nCOMMIT;`)
// Best-effort down migration: the DROP TABLE statements are commented out
// because the FK closure pulls in shared parent tables that may have
// pre-existed in the target (dropping them would lose data the project never
// created). The publisher uncomments the tables this migration should drop.
const drops = [...ordered]
.reverse()
.map((t) => `-- DROP TABLE IF EXISTS "${t.schemaName}"."${t.tableName}";`)
const sqlDown =
drops.length > 0
? `-- Rollback: uncomment the tables this migration should drop (leave shared\n` +
`-- tables that already existed in the workspace commented out).\nBEGIN;\n${drops.join('\n')}\nCOMMIT;`
: ''
out.push({
datatable_name: datatable,
sql: parts.join('\n\n'),
sql_down: sqlDown,
enabled: statements.length > 0
})
}
return out.sort((a, b) => a.datatable_name.localeCompare(b.datatable_name))
}
@@ -14,7 +14,8 @@
import { sendUserToast } from '$lib/utils'
import DataTable from '$lib/components/table/DataTable.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import { Pen, Trash, Plus } from 'lucide-svelte'
import { Pen, Trash, Plus, UploadCloud } from 'lucide-svelte'
import DeployToHub from '$lib/components/workspaceSettings/DeployToHub.svelte'
import Head from '$lib/components/table/Head.svelte'
import Row from '$lib/components/table/Row.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
@@ -30,6 +31,8 @@
let newFolderName: string = $state('')
let folders: FolderW[] | undefined = $state(undefined)
let folderDrawer: Drawer | undefined = $state()
let hubDrawer: Drawer | undefined = $state()
let publishFolderName: string = $state('')
async function loadFolders(): Promise<void> {
folders = (await FolderService.listFolders({ workspace: $workspaceStore! })).map((x) => {
@@ -88,6 +91,22 @@
</DrawerContent>
</Drawer>
<Drawer bind:this={hubDrawer} size="1100px">
<DrawerContent
title="Publish {publishFolderName} to Hub"
on:close={() => {
hubDrawer?.closeDrawer()
publishFolderName = ''
}}
>
{#if publishFolderName}
{#key publishFolderName}
<DeployToHub folder={publishFolderName} />
{/key}
{/if}
</DrawerContent>
</Drawer>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.folders}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
@@ -235,6 +254,15 @@
folderDrawer?.openDrawer()
}
},
{
displayName: 'Publish to Hub',
icon: UploadCloud,
disabled: !($userStore?.is_admin || $userStore?.is_super_admin),
action: () => {
publishFolderName = name
hubDrawer?.openDrawer()
}
},
{
displayName: `Delete${canWrite ? '' : ' (require owner permissions)'}`,
icon: Trash,
@@ -0,0 +1,374 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { workspaceStore, enterpriseLicense } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import { WorkspaceService } from '$lib/gen'
import FolderPicker from '$lib/components/FolderPicker.svelte'
import type {
ProjectExport,
ProjectMigration
} from '$lib/components/workspaceSettings/projectBundle'
import {
installProject,
type InstallResult
} from '$lib/components/workspaceSettings/projectInstall'
import MigrationSqlEditor from '$lib/components/workspaceSettings/MigrationSqlEditor.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { Cloud, Download, Loader2 } from 'lucide-svelte'
let slug = $derived($page.url.searchParams.get('hub') ?? '')
let workspace = $derived($workspaceStore)
let loading = $state(true)
let loadError = $state<string | undefined>(undefined)
let data = $state<ProjectExport | undefined>(undefined)
let installing = $state(false)
// True while the migration review/missing-datatable modals are open, before the
// import spinner starts — keeps the Import button from launching a second import.
let planningMigrations = $state(false)
let results = $state<InstallResult[]>([])
let done = $state(false)
let folderName = $state('')
// When the target lacks a needed data table, "import without that migration".
const missingDatatableModal = createAsyncConfirmationModal()
// Migration review drawer: preview + edit each runnable migration's SQL and
// choose which to run, resolved linearly via `reviewResolve`.
let reviewDrawer = $state<Drawer | undefined>()
let reviewList = $state<
{ datatable_name: string; sql: string; sql_down: string; run: boolean }[]
>([])
// Bumped per review session so the Monaco editors re-mount with the new SQL.
let reviewGeneration = $state(0)
let reviewResolve: ((run: boolean) => void) | undefined
function openMigrationReview(migs: ProjectMigration[]): Promise<boolean> {
reviewList = migs.map((m) => ({
datatable_name: m.datatable_name,
sql: m.sql,
sql_down: m.sql_down ?? '',
run: true
}))
reviewGeneration++
reviewDrawer?.openDrawer()
return new Promise((resolve) => (reviewResolve = resolve))
}
function closeMigrationReview(run: boolean) {
// Capture + clear first so the `on:close` fired by closeDrawer() (which would
// call this again with run=false) can't override an explicit Run/Skip choice.
const resolve = reviewResolve
reviewResolve = undefined
reviewDrawer?.closeDrawer()
resolve?.(run)
}
let loadSeq = 0
$effect(() => {
if (slug && workspace) void load()
})
async function load() {
// Token + captured slug/workspace: a slow /export for an old ?hub= must not
// overwrite the data of a newer one once we've navigated away.
const reqSeq = ++loadSeq
const reqSlug = slug
const reqWorkspace = workspace
loading = true
loadError = undefined
// New slug/workspace = a fresh import session: drop the previous project's
// outcome, otherwise project B stays disabled as "Imported" with A's
// results, and keeps A's folder.
data = undefined
done = false
results = []
folderName = ''
try {
const res = await fetch(
`/api/w/${reqWorkspace}/hub/projects/${encodeURIComponent(reqSlug)}/export`,
{ credentials: 'include', headers: { accept: 'application/json' } }
)
const text = await res.text()
if (reqSeq !== loadSeq) return // a newer load() superseded this one
if (!res.ok) throw new Error(`export ${res.status}: ${text}`)
data = JSON.parse(text)
if (data && !folderName) folderName = data.project.slug
} catch (e: any) {
if (reqSeq !== loadSeq) return
loadError = e?.message ?? String(e)
} finally {
if (reqSeq === loadSeq) loading = false
}
}
const counts = $derived(
data
? {
scripts: data.scripts.length,
flows: data.flows.length,
apps: data.apps.length,
resources: data.resources.length,
triggers: data.triggers.length,
migrations: (data.migrations ?? []).filter(
(m) => m.enabled && (m.sql ?? '').trim() !== ''
).length
}
: undefined
)
// Decide which data table migrations to run. Migrations are keyed by data table
// name and applied only to a target data table of the same name. Returns the
// migrations to run (with any edits the user made), an empty array when there's
// nothing to run, or `null` when the user backs out of the whole import at the
// missing-data-table warning.
async function planMigrations(
workspace: string,
migrations: ProjectMigration[]
): Promise<ProjectMigration[] | null> {
const enabled = migrations.filter((m) => m.enabled && (m.sql ?? '').trim() !== '')
if (enabled.length === 0) return []
let present: Set<string>
try {
const dts = await WorkspaceService.listDataTables({ workspace })
present = new Set(dts.map((d) => d.name))
} catch {
// Can't read the target's data tables — skip migrations rather than guess.
return []
}
const runnable = enabled.filter((m) => present.has(m.datatable_name))
const missingNames = [
...new Set(enabled.filter((m) => !present.has(m.datatable_name)).map((m) => m.datatable_name))
]
// Warn about missing data tables first: confirming imports without their
// migrations, cancelling backs out of the whole import so the user can create
// the data table(s) and re-run.
if (missingNames.length > 0) {
const proceed = await missingDatatableModal.ask({
title: 'Some data tables are missing',
confirmationText: 'Import without them',
children: `This project uses data table(s) "${missingNames.join(
'", "'
)}" that don't exist in this workspace, so their migrations will be skipped. To apply them, cancel, create the data table(s) with the same name in Workspace settings → Data tables, then re-run this import.`
})
if (!proceed) return null
}
let toRun: ProjectMigration[] = []
if (runnable.length > 0) {
const run = await openMigrationReview(runnable)
if (run) {
toRun = reviewList
.filter((r) => r.run && r.sql.trim() !== '')
.map((r) => ({
datatable_name: r.datatable_name,
sql: r.sql,
sql_down: r.sql_down,
enabled: true
}))
}
}
return toRun
}
async function install() {
// Snapshot reactive state up-front: `workspace` ($derived) and `data`
// ($state, replaced by load()) can both change mid-import on a workspace
// switch, which would split items or mix two exports. Pin both.
// Guard against a second click while the review modal is open (the Import
// button isn't `installing` yet during planning, so it would otherwise be
// clickable and start a concurrent import).
if (installing || planningMigrations) return
const workspace = $workspaceStore
const exportData = data
if (!exportData || !workspace) return
const folder = folderName.trim() || exportData.project.slug
// A slug/workspace switch mid-import bumps loadSeq and resets the view for
// the new project; this import's UI writes (results/done/toast) must then
// be dropped so they can't mark the new project as imported.
const sessionSeq = loadSeq
const sessionSlug = slug
// loadSeq only advances when a NEW load starts; navigating away (workspace
// or ?hub becoming empty) never bumps it, so also require the live
// identity to still match the captured one.
const current = () =>
sessionSeq === loadSeq && slug === sessionSlug && $workspaceStore === workspace
// Review data table migrations first (before the import spinner), so the user
// previews/edits and decides, then the whole import runs uninterrupted.
planningMigrations = true
let migrationsToRun: ProjectMigration[] | null
try {
migrationsToRun = await planMigrations(workspace, exportData.migrations ?? [])
} finally {
planningMigrations = false
}
// User backed out at the missing-data-table warning — abort the whole import.
if (migrationsToRun === null) return
// The migration review can stay open indefinitely; if the project or
// workspace changed underneath it, confirming the stale dialog must not
// write the old export into the old workspace (with all feedback
// suppressed by the session guard).
if (!current()) {
sendUserToast('Import cancelled — the project or workspace changed during review.', true)
return
}
installing = true
results = []
done = false
try {
await installProject({
workspace,
exportData,
folder,
migrations: migrationsToRun,
hasEeLicense: !!$enterpriseLicense,
onResult: (r) => {
if (current()) results = [...results, r]
}
})
if (current()) {
done = true
const failed = results.filter((r) => !r.ok).length
sendUserToast(
failed > 0
? `Imported with ${failed} item(s) failed.`
: `Project imported into ${workspace}.`,
failed > 0
)
}
} finally {
installing = false
}
}
</script>
<div class="mx-auto w-full max-w-screen-md px-4 py-10">
{#if !slug}
<p class="text-sm text-secondary">Missing <span class="font-mono">?hub=&lt;slug&gt;</span>.</p>
{:else if loading}
<div class="flex items-center gap-2 text-sm text-secondary">
<Loader2 size={16} class="animate-spin" /> Loading project…
</div>
{:else if loadError}
<p class="text-sm text-red-600">Failed to load project: {loadError}</p>
{:else if data}
<h1 class="text-2xl font-semibold text-primary">Add “{data.project.name}” to workspace</h1>
<p class="mt-1 text-sm text-secondary">{data.project.summary}</p>
<div class="mt-4 max-w-xs">
<p class="mb-1 text-xs text-tertiary">
Folder in <span class="font-mono">{workspace}</span>
</p>
<FolderPicker bind:folderName disabled={installing || done} size="sm" />
<p class="mt-1 text-xs text-tertiary">
Items import under <span class="font-mono">f/{folderName.trim() || data.project.slug}/</span
>.
</p>
</div>
<div class="mt-6 flex flex-wrap gap-2 text-xs">
<span class="rounded border px-2 py-1">{counts?.scripts} scripts</span>
<span class="rounded border px-2 py-1">{counts?.flows} flows</span>
<span class="rounded border px-2 py-1">{counts?.apps} apps</span>
<span class="rounded border px-2 py-1">{counts?.resources} resources</span>
<span class="rounded border px-2 py-1">{counts?.triggers} triggers</span>
{#if counts && counts.migrations > 0}
<span class="rounded border px-2 py-1">{counts.migrations} data table migrations</span>
{/if}
</div>
<div
class="mt-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100"
>
Resources are imported as empty stubs — set their values after import; a resource whose path
already exists is reported as failed (existing values are never overwritten). Trigger kinds
are recreated disabled, except GCP and Azure triggers, which manage cloud subscriptions at
creation and must be re-created manually after filling their resource. Kafka, NATS, SQS, GCP
and Azure triggers all require Enterprise. Triggers that reference a resource depend on stubs
imported empty, so fill in the resource value before re-enabling the trigger.
</div>
<div class="mt-6 flex items-center gap-3">
<Button
variant="accent"
startIcon={{ icon: done ? Cloud : Download }}
disabled={installing || done || planningMigrations}
onclick={install}
>
{#if installing}
<Loader2 size={16} class="animate-spin mr-1" /> Importing…
{:else if done}
Imported
{:else}
Import to {workspace}
{/if}
</Button>
{#if done}
<Button variant="border" onclick={() => goto(`/`)}>Go to workspace</Button>
{/if}
</div>
{#if results.length}
<ul class="mt-6 flex flex-col gap-1 text-xs">
{#each results as r}
<li class="flex items-center gap-2">
<span class={r.ok ? 'text-emerald-600' : 'text-red-600'}>{r.ok ? '✓' : '✗'}</span>
<span class="font-mono">{r.path}</span>
{#if !r.ok}<span class="text-red-600">{r.error}</span>{/if}
</li>
{/each}
</ul>
{/if}
{/if}
</div>
<Portal>
<ConfirmationModal {...missingDatatableModal.props} />
</Portal>
<Drawer bind:this={reviewDrawer} size="700px" on:close={() => closeMigrationReview(false)}>
<DrawerContent title="Data table migrations" on:close={() => closeMigrationReview(false)}>
<div class="flex flex-col gap-4">
<p class="text-xs text-secondary">
This project ships migrations that recreate the data tables it uses. Review and edit the
SQL, then choose which to run. A migration runs against the data table of the same name in
<span class="font-mono">{workspace}</span>; if that data table has migrations enabled it is
recorded, otherwise it runs once as a preview job.
</p>
{#each reviewList as m (m.datatable_name)}
<div class="flex flex-col gap-1.5 rounded border bg-surface-secondary p-2 text-xs">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-primary">{m.datatable_name}</span>
<Toggle bind:checked={m.run} size="xs" options={{ right: 'Run' }} />
</div>
{#if m.run}
<MigrationSqlEditor
bind:up={m.sql}
bind:down={m.sql_down}
generation={reviewGeneration}
/>
{/if}
</div>
{/each}
</div>
{#snippet actions()}
<Button variant="border" onclick={() => closeMigrationReview(false)}>Skip migrations</Button>
<Button
variant="accent"
disabled={!reviewList.some((m) => m.run && m.sql.trim() !== '')}
onclick={() => closeMigrationReview(true)}
>
Run selected
</Button>
{/snippet}
</DrawerContent>
</Drawer>