Files
windmill/docs/pipeline-local-dev.md
T
Ruben Fiszel 74f579e6d9 feat(pipeline): local development for data pipelines (CLI --local + pipeline dev preview) (#9840)
* feat(pipeline): local development for data pipelines (CLI --local + pipeline dev preview)

Add the local edit→preview→run loop for data pipelines (folders of `// pipeline`
scripts), the analog of `wmill dev` / `wmill app dev`, usable from a code editor
or an agentic loop — without deploying.

No backend changes: full body inference comes from the same wasm the frontend
uses (windmill-parser-wasm-asset), which returns assets + pipeline annotations in
one call; local runs reuse runScriptPreview with _wmill_skip_asset_dispatch.

- localGraph.ts: wasm-backed working-tree → asset-graph builder (the enabler)
- pipeline show/run --local; new pipeline docs (PIPELINE.md/AGENTS.md) subcommand
- pipeline dev watcher + /pipeline_dev page (PipelineDevView) rendering the same
  PipelineGraphEditor from the pushed local graph, run via preview
- cascadeRun.ts: reusable run primitives extracted from the route page
- regenerated CLI agent docs

See docs/pipeline-local-dev.md for the full design, test steps, and handoff TODOs.
The live `pipeline dev` browser preview is implemented but not yet stack-verified.

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

* feat(pipeline): improve local dev preview (run, activity, responsive)

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

* feat(pipeline): dev-preview args, multi-root run, ws auto-reconnect

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

* fix(pipeline): connect managed-materialize producer in local dev graph

The CLI pinned windmill-parser-wasm-asset ^1.728.1, which predates managed-materialize support (added in 1.733.1); the frontend already pins 1.740.0. The CLI's wasm therefore never emitted `// materialize`, so the producer had no output edge and showed disconnected from its `// on` consumers. Bump the CLI to 1.740.0 (matching the frontend) and translate the parsed materialize target into the producer's write edge + materialize_target, mirroring frontend resolveGraph.ts.

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

* fix(pipeline): harden local-dev CLI (bare-.sql crash, defaultTs, docs clobber)

Review fixes, complementary to the dev-preview/materialize/multi-root work already
on the branch (none overlap those commits):

- localGraph: a bare `.sql` (no dialect) made inferContentTypeFromFilePath throw and
  abort the whole graph build — and wedge `pipeline dev` at startup. Skip the
  unclassifiable file instead. Also map `bunnative` → parse_assets_ts and add
  ruby/rlang/nu/powershell to the `#`-comment fallback.
- show/run/docs/dev: thread the resolved `wmill.yaml` defaultTs into the graph
  builder so `.ts` infers under the workspace's runtime (bun vs deno) instead of
  always bun — `opts.defaultTs` was always undefined (no such CLI flag).
- dev: wrap the startup graph build so a half-written file can't abort the watcher.
- docs: don't clobber a user-authored AGENTS.md/CLAUDE.md — only (over)write the
  pointer when absent or already a generated `@PIPELINE.md` pointer.

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

* fix(pipeline): bind dev WS to loopback + local-graph regression tests

- pipeline dev WS broadcast the folder's full script source (scripts[].content + temp_script_refs) unauthenticated on 0.0.0.0:3201 — bind 127.0.0.1 so it's not LAN-reachable (webview localhost + SSH/devbox port-forward still work).

- Add regression tests for the just-landed local-graph fixes: bare .sql is skipped (was a build/dev-startup crash), defaultTs threads into .ts runtime inference (bun vs deno), and #-comment languages (ruby) use the # annotation fallback.

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

* feat(pipeline): --frontend flag for pipeline dev page origin

wmill pipeline dev opens <remote>/pipeline_dev, but that route only exists in this build's frontend, so it 404s against a remote whose deployed frontend predates it. --frontend <origin> points the page at a locally-run frontend (REMOTE=<remote> npm run dev) while the API/token still target the remote — enabling the live preview against a real backend before the PR is deployed. No behavior change when omitted. Regenerated CLI agent docs.

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

* fix(pipeline): WS session token + details-pane live-reload refresh

Addresses CI review (Codex/Pi/Claude):

- dev WS: a browser tab could open ws://localhost:<port>/ws and receive the folder's full source (browsers don't enforce same-origin on WS, loopback bind alone doesn't help). Gate the upgrade on an unguessable per-session token carried in the dev-page URL (verifyClient → 401 without it). Verified: no-token/bad-token connections get 401 with no bundle.

- details pane: scriptRes keyed on [workspace, selection, draftScript] didn't re-run on a pipeline dev live-reload (same selection), so the open pane showed stale source. Thread a localScriptsVersion (the pushed bundle) into the key. Verified: editing a selected node's file updates the pane source without reselect.

- docs/pipeline-local-dev.md: refresh the stale 'not yet exercised' status + done TODOs.

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

* fix(pipeline): emit volume: annotation assets in local dev graph

Addresses CI review (Codex P1 / Pi P1): the wasm body parser doesn't surface `// volume: <name>` annotations — the frontend (infer.ts:parseVolumeAnnotations) and backend (asset_inference.rs) parse them separately and merge as rw volume assets. localGraph didn't, so a `# volume: cache` producer had no write edge and showed disconnected from its `// on volume://cache` consumer (and pipeline run --local wouldn't schedule downstream). Mirror the leading-comment-block scan (SQL excluded, matching both reference parsers) and merge into inferScriptAssets. Regression test added; verified producer -> volume://cache -> consumer connects.

Also (Codex P2): docs/pipeline-local-dev.md manual browser URL omitted the new ws_token param — without it the WS upgrade is rejected and the page sits disconnected. Doc now says to copy the URL the CLI prints (carries wm_token + ws_token) and recommends --frontend.

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

* fix(pipeline): runAll excludes event roots + review polish

Addresses CI review (Codex P1, Claude P2/P3):

- pipeline run runAll: derive the whole-pipeline selection from validStarts + descendants instead of all runnables, so an unqualified 'pipeline run <folder>' no longer fires event-trigger roots (kafka/mqtt/…) with empty args/side effects. Verified: a kafka root is excluded from the plan.

- cascadeRun.ts runBoundedCascade: use buildLineageDownstreamMap (read-aware) so a pure-reader runs after its producer, and return cyclic — parity with the route page's bounded run (the file is meant to be THE shared correct primitive).

- PipelineGraphEditor: storedRightPaneSize starts at 0 so the orientation-aware default (55% stacked / 40% side-by-side) actually applies on first open.

- localGraph fallbackParse (go/bash): scan only the leading comment header (no body-comment phantom triggers) and strip key=value options from the asset URI; regression test added.

- docs: reject '..' in the folder arg (it writes files under f/<folder>).

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

* fix(pipeline): route local previews to the // tag worker

Addresses CI review P1: the local graph/bundle dropped the parsed `// tag`, so a node annotated `// tag gpu` ran on the default worker in both `pipeline run --local` and `/pipeline_dev`, while the deployed pipeline routes it to that worker tag. Carry the tag through LocalScript / the pushed bundle / LocalScriptContent and pass it to runScriptPreview at all three launch sites. Verified: a duckdb node tagged `bash` produces a job tagged `bash`; regression test added.

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

* fix(pipeline): add asset partitions/schemas routes to OpenAPI, use generated client

The ducklake asset panels (PartitionStatusGrid, SchemaHistoryPanel) hit /assets/partitions and /assets/asset_schemas via raw fetch with cookie-only auth, because those backend routes were never added to openapi.yaml so the generated client had no methods for them. On /pipeline_dev (token-via-URL, no session cookie) the raw fetches 401'd. Add both GET routes + MaterializedPartition/AssetSchemaVersion schemas to openapi.yaml and call them through AssetService, which injects the bearer token, types, and cancellation automatically. Verified: Partitions + Schema tabs load in /pipeline_dev. (backfill stays a raw fetch — it's an EE-only route not in the OSS spec — with the token added inline.)

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

* chore(cli): regenerate bun.lock for windmill-parser-wasm-asset

package.json / package-lock.json carry windmill-parser-wasm-asset@1.740.0 but the tracked bun.lock (the CLI installs/builds/tests via bun) was stale, so fresh bun installs would resolve a different graph than the committed lock. Regenerated.

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

* fix(pipeline): show asset producer + its runs in the dev-preview panel

Selecting a ducklake/asset node in /pipeline_dev showed 'No producer for this asset' because selectionProducers wasn't passed (it's derived from the deployed graph on the route page, absent here). Compute it from the local graph's w/rw write-edges (incl. the // materialize target) and pass it through, mirroring the route page — so the panel shows the producing script and its (preview) runs, including data-test failures.

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

* fix(pipeline): carry annotation metadata onto local-graph runnables

The local graph emitted only path/usage_kind/in_pipeline/materialize_target per runnable, so /pipeline_dev and pipeline show --local weren't the same surface as the deployed graph for annotated scripts — missing the badges/lineage the shared canvas renders. Map the wasm-parsed partition_kind, freshness, tag, retry, data_tests, column_lineage, and materialize_strategy (derived append/merge/replace) onto each runnable, mirroring the deployed AssetGraphRunnableNode.

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

* fix(pipeline): exclude event handlers that are lineage descendants from runAll

The runAll guarantee ('never fires an event handler with empty args') only held for event ROOTS — validStarts excludes them, but runAll then unions in descendants(dag, start), so a kafka/mqtt/... handler that also reads an upstream pipeline asset (a lineage descendant of a valid start) still landed in the plan. Add eventTriggerScripts() and subtract it from the selection after the descendant union. +unit test.

Also: docs/pipeline-local-dev.md recipe used 'pipeline docs demo_pipeline' without --local (default queries the deployed graph → hits the empty hint); add --local.

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

* fix(pipeline): whole-pipeline run cuts at event handlers (drop their downstream too)

The prior runAll fix subtracted event handlers from the selection but left their downstream: for manual_root → asset_x → kafka_handler → asset_y → consumer, deleting only kafka_handler left consumer selected, and topoOrder then ran it as a root with missing/stale event-derived inputs. Replace the descendant-union+delete with reachableCutting(dag, validStarts, eventHandlers): traverse from valid starts but treat event handlers as cut points, so a node reachable ONLY through an event handler is dropped while one reachable via a non-event path stays. +unit test.

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

* fix(pipeline): recover // tag in the go/bash annotation fallback

The wasm path carries out.tag, but the go/bash fallback (and the wasm-error degradation path) only recovered pipeline + on, so a // tag gpu on a bash/go node — or a temporarily-unparseable ts/py/sql node — silently routed the local preview to the default worker while the deployed pipeline routes to the tag. Scan for // tag in fallbackParse too. +test.

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

* refactor(pipeline): extract shared assetProducers helper

The 'who writes this asset' write-edge derivation was copied verbatim in PipelineDevView and the pipeline route page — two copies that would drift. Extract assetProducers(graph, selection) into graphTraversal.ts and use it from both, keeping the dev view and route page in lockstep.

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

* fix(pipeline): only overwrite AGENTS.md/CLAUDE.md when it's the exact generated pointer

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

* fix(pipeline): wire local-dev runs into the selected-node runs pane

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

* fix(pipeline): exclude data_upload/webhook entrypoints from auto CLI runs

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

* feat(pipeline): --upload binds an object to a data_upload/webhook entry point

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

* feat(pipeline): add "Run + downstream" to the dev preview detail form

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

* fix(pipeline): cut non-autorun triggers on all run paths; multi-binding --upload

Address CI review: apply the data_upload/webhook/event barrier cut to the
single-root and bounded (--from/--to) paths, not just whole-pipeline; accumulate
repeatable --upload bindings per script (were overwritten); scope dev upload keys
by script+param to avoid basename clobbering; drop <script> from help text.

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

* fix(pipeline): reseed dev run form when a local edit changes the script's args

The read-only pane is keyed on script.path only, so in /pipeline_dev the selected
node re-resolves on every WS bundle without remounting; PipelineScriptView cloned
script.schema once, so adding/removing args left the run form on a stale schema
(could run with missing inputs). Extract PipelineRunForm (owns the SchemaForm
clone) and key it on the serialized schema: a real arg change reseeds the form,
an unchanged re-resolve keeps in-progress input.

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

* fix(pipeline): don't cut a scheduled/manual root that also has a non-autorun trigger

Address Codex P1: the barrier set subtracted only --upload-bound scripts, so a
script with both `// on schedule` and `// on data_upload` resolved as the start
yet was also a barrier — reachableCutting skipped it, giving an empty run plan.
Subtract all valid starts (schedule/manual roots + bound handlers) from barriers:
a legitimately-scheduled root runs on its schedule path even if it also carries a
caller-input trigger; pure input-only roots stay cut. Adds a regression test.

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

* fix(pipeline): deployed non-autorun enrichment, s3:// storage, --to cut accounting, tag regex

Address CI review (Codex P1/P1/P2, Pi P2):
- Deployed `pipeline run` recovers marker-only data_upload/webhook/email triggers
  from script bodies (like the `show` path) so input-only entrypoints are cut
  instead of auto-run empty on the deployed graph.
- `--upload s3://<storage>/<key>` keeps the named storage (authority) instead of
  folding it into the key, matching the S3Object round-trip convention.
- Bounded `--to` targets cut by a barrier are reported in droppedEnds (+warning),
  not reachableEnds.
- fallbackParse `// tag` matches a single token (\S+), rejecting multi-word prose.

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

* fix(pipeline): header-only deployed marker scan, fail-closed enrichment, default-storage s3 keys

Address CI review (Codex P2, cubic P1/P1/P2):
- Deployed marker recovery scans the LEADING comment header only (shared
  recoverHeaderMarkers helper, reused by the show enrichment too) so a body
  comment `// on data_upload` can't inject a phantom trigger and over-cut.
- Deployed run enrichment fails CLOSED: a script-body fetch error aborts the run
  instead of silently letting an input-only entrypoint run with empty args.
- Revert `--upload s3://` to default-storage whole-path keys (matching pipeline
  `s3://` asset-URI semantics); named-storage authority-splitting broke nested
  default keys like `s3://raw/2026/events.csv`.

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

* fix(pipeline): reject trailing content on fallback native markers; trim s3:/// key

Address CI review (Codex P2, cubic P3):
- fallbackParse now requires a native marker (`// on data_upload`) to stand alone;
  a line with trailing content (`// on data_upload f/foo`, `# on kafka topic`) is
  rejected, matching the canonical parser and keeping local/deployed parity.
- s3UriKey trims a leading slash so the canonical empty-authority default form
  `s3:///key` doesn't leak a leading slash into the object key.

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

* fix(pipeline): persist dev WS token per-port so reconnect survives a CLI restart

Address Codex P2: the /pipeline_dev auto-reconnect reuses the ws_token from the
page URL, but `pipeline dev` minted a fresh random token each start, so a restart
on the same port left the open page rejected by verifyClient forever. Persist the
token per-port under the user-private config dir (0600) and reuse it on restart,
so an already-open page reconnects — matching the reconnect behavior's intent.

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

* fix(pipeline): scope persisted dev WS token by workspace+folder+port

Address cubic P2: keying the persisted token by port alone let a stale browser
tab from a previous folder's session on the same port reconnect and receive a
different folder's source. Scope the token file by workspace+folder+port so a
same-session restart still reconnects, but a different folder on the same port
gets a distinct token that rejects stale cross-folder tabs.

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

* fix(pipeline): caller args can't override skip-dispatch guard; hash the dev token key

Address CI review (Codex P1, cubic P2):
- makeLaunch / CLI run build args with `_wmill_skip_asset_dispatch` LAST (and drop
  any caller-supplied copy) so a run-form/`--upload` arg can't re-enable backend
  asset dispatch while the client orchestrates the cascade (double-run / running
  deployed subscribers from a local preview). Adds a cascadeRun guard test.
- Dev WS token file key is a sha256 of NUL-delimited workspace+folder+port, so
  different folders (`a/b` vs `a_b`) can't collide onto the same token file.

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

* fix(pipeline): canonical s3://storage/key --upload parsing; scope dev token by remote+root

Address Codex P1/P1:
- Restore canonical S3Object URI parsing for `--upload` s3 sources, matching the
  frontend's `parseS3Object` (`s3://<storage>/<key>`, empty authority ⇒ default,
  `s3:///key`/`s3:///nested/key` for the default store). `s3://secondary/k.csv` →
  `{ s3: "k.csv", storage: "secondary" }` so a named-storage object is read from
  the right store. (This is the canonical convention; the default-storage nested
  key is served by the `s3:///` form.)
- Scope the persisted dev WS token by remote+workspace+root+folder+port (was
  workspace+folder+port), so two profiles on different remotes (or local checkouts)
  with the same workspace/folder/port don't share a token — a stale tab can't
  reconnect across a workspace/remote boundary.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:41:36 +02:00

9.9 KiB
Raw Blame History

Local development for Data Pipelines

Status: draft PR, validated end-to-end. Both the headless CLI paths and the live browser preview (pipeline dev/pipeline_dev) have been exercised against a running EE + MinIO stack — headless pipeline run --local, browser Run / Run + downstream cascades writing real assets, live-reload on save, failure/cascade UX, parameterized run-forms, and the --frontend flag. This document captures the design and the remaining follow-ups.

What & why

Windmill "data pipelines" are folders of scripts marked with a // pipeline comment, wired together by asset annotations (// on <asset-uri>, // partitioned, // schedule). Until now they could only be built/run in the browser at /pipeline/<folder> (the PipelineGraphEditor), and the CLI only inspected/ran the deployed workspace (wmill pipeline list|show|run).

This adds the local edit → preview → run loop, the pipeline analog of wmill dev (flows/scripts) and wmill app dev (raw apps), usable both from a code editor and from an agentic loop — building a pipeline from working-tree files, seeing the same graph the UI shows, and running it, all without deploying.

The key design decision (no backend changes)

Full body inference is obtained in the CLI from the same wasm the frontend uses: windmill-parser-wasm-asset (parse_assets_ts | parse_assets_py | parse_assets_sql). That wasm returns the entire serialized Rust ParseAssetsOutputassets (with r/w/rw access) and the parsed pipeline annotations (in_pipeline, triggers, partition, …) in one call. The CLI already loads sibling wasm parsers via loadParser(); we just added the -asset dep.

Running local content reuses the existing preview API: runScriptPreview({ content, language, path, args: { _wmill_skip_asset_dispatch: true }, temp_script_refs }) per node in topological order. Data flows through real asset storage; _wmill_skip_asset_dispatch makes the client own the whole cascade so the backend dispatcher never double-fires.

⇒ No new backend endpoint, no Rust changes, no TS re-port of the annotation parser.

Surfaces

Headless CLI (agentic loop) — cli/src/commands/pipeline/

  • pipeline show <folder> --local — render the DAG from working-tree files (fully offline).
  • pipeline run <folder> --local [--from/--to/--dry-run/--json] — run the cascade via preview of local content, reusing the boundedCascade.ts topo/lineage engine. Scripts whose only trigger needs caller input or per-event fanout (data_upload/webhook/kafka/…) are skipped by default.
  • pipeline run … --upload <script>[:<param>]=<local-file|s3://storage/key> — bind an object to a data_upload/webhook entry point so it (and its downstream) runs: a local path is uploaded to the workspace store; an s3://<storage>/<key> source binds an existing object (authority = named storage, s3:///key for the default store). The target S3Object arg is inferred when the script declares exactly one (else name it with :<param>). Repeatable.
  • pipeline docs <folder> [--local] — write PIPELINE.md + AGENTS.md/CLAUDE.md pointers (graph + datatable schemas) so an editor/agent has the same context the UI surfaces.

Browser live-preview — pipeline dev + /pipeline_dev

  • pipeline dev [folder] — folder arg or cwd auto-detect; watches the folder, rebuilds the graph on each save, pushes {type:'pipeline', folder, graph, scripts, temp_script_refs} over a WebSocket (direct mode, default port 3201), opens the dev page.
  • /pipeline_dev route → PipelineDevView.svelte renders the same PipelineGraphEditor (mode view) fed by the pushed graph; runs the cascade via preview. Editing stays in the user's editor; each save live-reloads.

Files

New

  • cli/src/commands/pipeline/localGraph.ts — the enabler. Walks f/<folder> scripts, wasm-infers each (parse_assets_<lang>), assembles the /assets/graph-shaped payload (runnables, assets, edges, triggers). Exports buildLocalPipelineGraph, collectScripts, inferScriptAssets, workspaceRoot, and the canonical graph types.
  • cli/src/commands/pipeline/docs.tspipeline docs.
  • cli/src/commands/pipeline/dev.tspipeline dev watcher + WS server.
  • cli/test/pipeline_local_graph_unit.test.ts — unit tests for the builder.
  • frontend/src/lib/components/assets/AssetGraph/cascadeRun.ts — reusable run primitives (makeLaunch, makeWaitJobTerminal, runDownstreamCascade, runBoundedCascade) wrapping cascadeOrchestrator + graphTraversal.
  • frontend/src/lib/components/assets/AssetGraph/PipelineDevView.svelte + frontend/src/routes/pipeline_dev/+page.svelte.

Modified

  • cli/src/commands/pipeline/pipeline.ts--local on show/run; show split into graph acquisition + renderGraph() + deployed-only enrichRootMarkers(); registers docs/dev; graph types moved to localGraph.ts.
  • cli/package.json (+ windmill-parser-wasm-asset), cli/package-lock.json.
  • system_prompts/auto-generated/*, cli/src/guidance/skills.gen.ts — regenerated via python system_prompts/generate.py (required by AGENTS.md for CLI command changes).

Language coverage

inferScriptAssets mirrors frontend/src/lib/infer.ts:inferAssets: ts (bun/deno/nativets), python3, and SQL dialects all get wasm inference (SQL dialects route to parse_assets_sql; its comment-header annotation scan is dialect-independent). go/bash have no wasm asset parser, so they fall back to a minimal // pipeline + // on scan (annotation-only). Note inferred asset paths must match exactly to connect nodes: DuckDB read_csv('s3://x') / COPY ... TO 's3://x' and // on s3://x all yield path x (no leading slash), whereas TS writeS3File({s3:"x"}) yields /x — so an all-DuckDB example connects cleanly; mixing TS-write with annotation-read needs care.

How to test

Run the CLI from source (alias wmilld='bun run /home/rfiszel/windmill/cli/src/main.ts').

Headless (works directly against any remote, e.g. internal.windmill.dev / data-pipelines):

wmilld workspace add internal data-pipelines https://internal.windmill.dev/   # paste a token
# build an example (connected all-DuckDB DAG):
mkdir -p ~/pl-demo/f/demo_pipeline && cd ~/pl-demo && printf 'defaultTs: bun\n' > wmill.yaml
#   ingest.duckdb.sql:    -- pipeline\nCOPY (SELECT 1 id,'a' name) TO 's3://demo/raw.csv';
#   transform.duckdb.sql: -- pipeline\n-- on s3://demo/raw.csv\nCOPY (SELECT * FROM read_csv('s3://demo/raw.csv')) TO 's3://demo/clean.csv';
#   report.duckdb.sql:    -- pipeline\n-- on s3://demo/clean.csv\nSELECT count(*) FROM read_csv('s3://demo/clean.csv');
wmilld pipeline show demo_pipeline --local
wmilld pipeline run  demo_pipeline --local --dry-run
wmilld pipeline run  demo_pipeline --local          # writes real s3 assets; needs object storage
wmilld pipeline docs demo_pipeline --local   # --local: document the working tree (default path queries the deployed graph)

Or pull real pipelines: wmilld sync pull --yes && wmilld pipeline list && wmilld pipeline show <folder> --local.

Browser preview: against a remote whose deployed frontend predates the /pipeline_dev route, the auto-opened <remote>/pipeline_dev?… 404s. Run THIS branch's frontend locally and point the dev page at it with --frontend:

cd frontend && REMOTE=https://internal.windmill.dev npm run dev      # or a local backend
cd ~/pl-demo && wmilld pipeline dev demo_pipeline --frontend http://localhost:3000

pipeline dev then opens (and prints) the full URL — copy that printed URL, don't hand-write it. It carries both wm_token (workspace auth) and ws_token (the per-session WS token); the dev server rejects the WebSocket without ws_token, so the page would sit disconnected:

http://localhost:3000/pipeline_dev?workspace=<WS>&wm_token=<TOK>&folder=demo_pipeline&port=3201&ws_token=<WS_TOK>

Validation status

  • pipeline show --local verified offline against a fixture (renders a connected DAG).
  • localGraph unit tests (7) + the CLI suite pass; CLI tsc clean; frontend npm run check 0 errors on changed files; svelte-autofixer clean.
  • CLI agent docs regenerated.
  • Live pipeline dev/pipeline_dev browser preview exercised against a running EE + MinIO stack: graph render + live-reload on save, single-node Run and Run + downstream cascades writing real assets, parameterized run-forms, failure/cascade UX, responsive layout, and the --frontend flag. Screenshots in the PR body.

TODO for the takeover agent

(Items 12 below are done; left here for context.)

  1. Verify the browser preview end-to-end — done (see Validation status; screenshots in PR).
  2. Dev-page route reachability on remotes — done: added --frontend <origin> so pipeline dev can open the page on a locally-run frontend while the API/token target the remote.
  3. Route-page dedup: optionally refactor frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte to consume cascadeRun.ts (its launchCascadeScript/runDraftAwareCascade/waitJobTerminal are the source of the extraction) — eliminates duplication. Keep behavior identical.
  4. Proxy mode for pipeline dev: currently direct-mode only. Port dev/dev.ts's startProxyServer for embedders that need a localhost origin (e.g. Claude Code preview).
  5. pipeline dev editing: the dev page is view+run only (editing stays in the user's editor). If in-browser editing with file round-trip is wanted, mirror flow-dev's handleFlowRoundTrip.
  6. Asset-path normalization: the TS-write leading-slash vs annotation/DuckDB no-slash mismatch (see Language coverage) is inherited from the wasm/backend inference, not introduced here, but it silently breaks lineage connection across languages — worth normalizing in the parser.

Plan reference

Original plan: /home/rfiszel/.claude/plans/tingly-wandering-whistle.md (local to the author).