A data table backed by the instance database resolved to exactly one Postgres connection,
`custom_instance_user`, for everyone who could reach it at all. There was no way to say
this job reads, that one writes, this one never sees the salaries table.
A data table role is now a real Postgres login on the cluster, defined once for the
instance by a superadmin and named exactly as they named it. A script that declares
`-- role analytics` connects as `analytics`, and Postgres decides what it may touch —
grants are ordinary SQL. Windmill answers only "may this caller ask for this role", from
the tenant lists on the data table entry: `u/alice`, `g/analysts`, `f/finance` or `*`.
A data table with no `permissions` block behaves exactly as before.
Everything that opens a connection on someone's behalf goes through one chokepoint,
`get_datatable_resource_from_db`, which takes the identity explicitly and fails closed when
there is none. The role logs in as itself — never `SET ROLE`, which a script could
`RESET ROLE` its way out of.
A fork's data table entry becomes a pointer at the workspace that governs it rather than a
copy of it. The settings clone used to hand a fork a byte-identical entry naming the
parent's database, which a fork admin could edit to grant themselves `admin` there; a
pointer has nothing local to edit, and its tenants are evaluated as a member of the
governing workspace, by email. `permissions` is stripped from the workspace export and
ignored on import: tenants name principals of one workspace, and a settings push is not
where an access decision should be made.
Operations that see the whole database whatever the roles grant stay with the governing
workspace's admins: editing the roles, a migration that declares none, and opening a
replication stream for a Postgres trigger or capture.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* feat: column-level lineage for dbt from the engine's parquet index
`manifest.json` carries no column-to-column edges, which is why decision 14
recorded column lineage as unavailable. The edges live in a different artifact:
`dbt compile --static-analysis strict --write-index` writes `target/index/`,
whose `dbt.column_lineage.parquet` holds them and whose
`dbt.node_columns.parquet` holds every column of every node, typed and ordered
rather than only the ones an author documented.
Strict analysis rejects SQL the default accepts, so this is a separate compile
with its own `--target-path`, opt-in per project via `column_lineage: true`, and
best-effort throughout: a project it cannot analyze keeps exactly the graph it
had, with the engine's own diagnostics in the job log.
Storage mirrors `dbt_edge`: `dbt_column_edge` keyed by (path, version, job) with
the same composite FK to `script` and the same sweeps. The typed column list
lands in `dbt_node.column_schema`, beside `columns` rather than merged into it,
so `columns` stays what the author declared.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ
* fix: address review findings on the dbt column-lineage pass
- The workspace fork copied every other dbt sidecar table and not this one, so
a fork lost its column lineage silently and could not recover it: the cloned
digest covers the column edges, so a dynamic run in the fork matched it and
stored nothing.
- The parquet was collected whole before the edge cap applied, which is exactly
the input the cap exists for — a project whose `scan` lineage is quadratic in
its widest model could take the worker process down. Decoded a row at a time
with the bound enforced during the decode.
- The pass swallowed every error from the runner, including the job poller's
cancellation and deadline, so a run that blew its timeout inside an optional
annotation could still publish a graph and report success. `run_captured`
now carries the exit status in its value, so only a failed COMPILE is
downgraded, and the pass may spend at most half the remaining wall clock so
it cannot starve the build that follows it.
- `scan` edges are stored but no longer served: they are most of a project's
lineage, nothing renders them, and the graph endpoint is polled by the run
page. They are also the first thing the storage cap gives up now, rather than
evicting the direct edges the trace draws.
- `column_schema` and the column edges take the same gate as the model's SQL. A
column-level view is the shape of what the author wrote, one level finer than
the `ref()` graph, which is ungated only because it draws relations the
caller already sees.
- `graph_digest` hashes the new section only when it has edges, so a project
that never asked for the pass keeps the digest it has instead of
re-snapshotting on every dynamic run until it is redeployed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ
* fix: the editor buffer's column lineage, and three bounds that were wrong
Round-2 review found four defects, all of them introduced by the round-1 fixes.
- The `script_visible` gate on the column edges was copied from the node query
without its `script_hash IS NULL` arm. `= NULL` is never true, so every
version-less row was filtered out and an editor buffer's parse rendered its
typed columns and none of their lineage — the one place the feature is meant
to be used. Pinned by an assertion in `dbt_pinned_graph.rs`, which is where
this class of bug already had a home.
- The phase budget was handed to the poller, whose expiry is an `Err`
indistinguishable from a cancellation or the job's own deadline, so a slow
but valid analysis aborted the build it exists to annotate. The runner gets
the full deadline again — those two must still fail the job — and the budget
is a race around the whole pass, where expiring is this budget and nothing
else.
- The decode cap counted parquet ROWS, so `scan` and out-of-graph rows could
spend it before a single drawn edge was read. It now counts what is kept,
takes direct kinds in a first pass, and is handed the graph's own nodes so
the budget cannot go on rows that could never be stored.
- Hashing the new digest section conditionally did not preserve old digests,
because an absent `column_schema` still serialized as `null` inside the
nodes. It is skipped when absent instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ
* refactor: split the lineage pass by error contract, and read it in one query
Round 3's findings were all consequences of round 1 and 2's fixes, clustered in
the same two files, so this reshapes those two seams rather than patching again.
The worker pass was one function being three things at once — a subprocess
runner with job-lifecycle error semantics, a bounded decoder, and a best-effort
degrader — which is why each fix to one perturbed another. It is now
`compile_index`, which owns the JOB's semantics (only a cancellation or the
job's deadline can `Err`; a non-zero exit, the output ceiling and the phase
budget are outcomes), and `read_index`, which owns the ARTIFACT's and knows
nothing about the job. The budget wraps the compile alone, so a decode can no
longer outlive the timeout that reported the build would get the rest. The
output ceiling likewise becomes a value rather than a job error, for the caller
that can carry on without the tail of a compile's stdout.
The column edges were read by a fourth hand-written copy of the `live`/`chosen`
CTEs and the version/editor-buffer join conditions, and copying them is what
dropped the `script_hash IS NULL` arm and hid every buffer parse's lineage. Both
kinds of edge now come from ONE statement over a `UNION ALL`'d edge source, so
those conditions exist once. The union is at the source rather than a join
because column lineage can name a node pair `dbt_edge` has no row for: a model
reading `{{ this }}` gets edges from itself to itself, and `parent_map` has no
self-loop.
The cap on the column half now sits after the scope filter, the visibility
check and the graph joins — the scope moved into SQL via the existing
`ScopePathFilter` — so a row the caller may not read can no longer spend it and
leave an allowed project's trace short.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRtsPQ3Ck69Fu9DNr7bMcJ
* refactor: serve dbt column lineage from its own endpoint
The column edges rode on the folder-wide asset graph, which a run page polls,
while the trace is drawn for one selected relation. That needed a cap, and a cap
has to be applied after every filter that can drop a row.
Keyed to the asset there is no cap: `assets/column_lineage` answers for one
relation, and the caller's `scripts:read` scope and the project's visibility are
decided once, for the script that owns it. Pinning to a run's snapshot or the
editor's parse of its buffer costs the job-read gate, so that form is
`jobs/dbt_column_lineage/{id}` — the same shape `jobs/dbt_graph/{id}` has.
The worker's decode now bounds work and memory separately, and a compile stopped
by the output ceiling reports as truncated rather than complete.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: resolve the owning dbt version the way the graph does
The unpinned arm picked the newest live version at the path without narrowing to
dbt, so a path since redeployed in another language answered with no lineage
while the graph beside it still drew that project's stale nodes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: gate pinned column lineage on reading the project, and answer the component
Four things round 5 found, three of them in code this branch rewrote:
- The pinned arm resolved the version from the job and stopped there, so a
share-link viewer entitled to a run got the project's column names and edges
while the graph beside it still redacted `raw_code` and `column_schema`.
Resolving WHICH version answers is not deciding whether the caller may read
it; the version-less editor buffer keeps its exemption, having no `script` row
to ask.
- The answer was the whole owning project's edges. The canvas lays out the
connected component of the selected relation's columns, so the rest was
unrenderable weight; a recursive walk over both directions returns exactly
what is drawn, and the project key travels with it so a `unique_id` two
projects share cannot walk from one graph into the other.
- The decode had no exit but the 4M-row backstop once its buckets were full,
spending wall clock the build below does not get.
- An unreadable index was reported as a missing one, sending the reader to look
at their engine rather than at the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: stitch the two column graphs, and walk the component in Rust
Round 6's two findings, both regressions this branch introduced:
- The decode returned `Continue` on the edge that FILLED the direct-edge
budget, so a `scan`-only tail after it decoded to the 4M-row backstop with
nowhere to put anything. The read now ends on that edge.
- Seam 3 made the pipeline page choose between the dbt graph and the producer
one. They share node ids — `// column total <- dbt://wh/analytics/orders.amount`
mints the same `(dbt, path, column)` node dbt's own lineage does — so choosing
ended a trace at the boundary in both directions. They are merged again, and
a ducklake selection asks about the dbt relation its producers name so the
chain continues past it. The dbt editor gets the same merge.
Also: the component is walked in Rust rather than by a recursive CTE. A CTE has
no index, so the recursive term rescanned the doubled edge set once per level —
1243ms against 59ms for the query alone on a 3000-model project, 11.7M rows in
the plan. Same answers, same tests; end to end 1.48s to 0.73s there and 1.60s to
0.26s on a 1000-deep chain. The client stops re-asking for a component it
already holds, which is most clicks within one project.
The four doc sites that described a whole-project answer are rewritten around
what it now is, rather than edited where they disagreed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: expand every dbt boundary a selection reaches, and only skip what was asked
Round 7's findings, all in the frontend seam this branch added:
- A ducklake selection seeded the dbt fetch from the FIRST boundary relation it
found, so a table derived from two unconnected dbt relations expanded one and
left the other a leaf — the same "stops at the boundary" symptom the round-6
fix removed, one hop further along. Every distinct boundary is fetched now and
the components merged.
- The component cache skipped a relation merely PRESENT in the graph in hand.
A relation two projects describe has an owner row in each, and a component
fetched for one carries it as an endpoint without the other's half, so that
skipped the request that would have resolved the second owner. Only a relation
actually asked about under this pin is skipped.
- A comment still called the producer graph gated to ducklake selections after
it was widened to dbt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: land dbt column lineage as storage and ingest only
The API surface that draws a column trace moves to a follow-up PR, on
`dbt-column-lineage-surface`. It kept generating findings — a client cache
whose premise was wrong for a two-owner relation, then staleness and a lost
retry from tightening it, and a seed walk that stopped at the first boundary —
and the fix for the last of them is a transitive owner expansion, which has to
re-apply the caller's gate to every newly discovered project. That is the same
shape as the leak four reviewers caught in the pinned arm, and it wants its own
review rather than being the fourth fix at the end of this one.
What lands here stands on its own: the analysis pass, `dbt_column_edge`,
`dbt_node.column_schema`, the engine gating and the error-contract split — plus
the one user-visible half, the typed and ordered column list, which rides the
asset graph the details pane already fetches and replaces a panel that could
only show the columns an author had documented.
Also fixes a real bug in the pass, found in review: it compiled without the
build's `--full-refresh`. `is_incremental()` branches on that flag, so an
incremental model reading `{{ this }}` compiles its self-join — and any `ref()`
inside that branch — only when the flag is absent, and the pass was storing
lineage for SQL a full-refresh run never executed. The flag now comes from one
place shared with the build, and a run that overrides it gets its own graph
rather than standing as the version's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: say why direct kinds get the budget without naming a view
The bucketing comments explained the priority by what a trace draws, which is
a forward reference now that the surface moved out. The reason stands on its
own: `copy`/`mod` say the value travelled, `scan` says the column was read to
produce the row and so reaches every output column of its model.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: round-9 findings on the descoped PR
- The `full_refresh` helper was inserted between `selection_is_overridden` and
its doc comment, so thirteen lines about `select`/`exclude` echoes documented
the wrong function and the one they were written for had none. Moved below it.
- The parse path ran the analysis compile and the parquet decode BEFORE the
guard that returns when there is no warehouse identity, paying for both and
dropping the result. Moved after it.
- Three sites still described a `/column_lineage` endpoint this branch no longer
has, and two user-facing strings promised a column trace it no longer renders:
the panel's hint and the descriptor template now say what the flag actually
buys, which is the typed column schema.
- Dropped test scaffolding the removed suite left behind: a `raw_orders` node
and `dbt_edge` whose only assertion re-tested pre-existing graph behaviour,
and a second editor-buffer node nothing asserts on.
Documented rather than fixed: an incremental model has two shapes, and which one
the index holds depends on whether the target existed when the pass ran.
`is_incremental()` is false with no target as well as under `--full-refresh`, and
dbt has no mode that emits both — so a version's graph describes the compile that
produced it, and only a re-ingesting run describes its own run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep lineage_kind in the edge key, and one answer for --full-refresh
- Both unique indexes omitted `lineage_kind`, so a column that is projected AND
used as a predicate for the same output column — an ordinary shape — had its
`copy` and `scan` edges collapse under `ON CONFLICT DO NOTHING`, while the
digest counted both. The kind is part of the fact, so it is part of the key.
Edited in the migration rather than added as a second one: it has not landed.
- `full_refresh` was shared between the build and the analysis pass without the
`command != "test"` condition that sat at the build's call site, so the two
disagreed for exactly the runs that build nothing. The condition moved inside
the function, which is the point of sharing it, and the command is threaded to
the pass.
- The "what a trace draws" rewrite missed the copy in `dbt_manifest.rs`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: drop the unreachable full_refresh threading, test the uniqueness key
`DBT_COMMANDS` is `["build", "retry", "show", "parse"]` and `default_command`
returns `build` in every arm, so `command == "test"` cannot happen — the guard
the last commit moved into `full_refresh` was already inert where it came from.
Threading the command through five signatures to preserve it bought nothing, so
it is gone; the build and the pass call one function of the descriptor and the
invocation, which is what the sharing was for.
The uniqueness-key fix now has a test: a column projected AND used as a
predicate for the same output column stores both its `copy` and its `scan` row.
Verified against the old key, where it returns 1 instead of 2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: restore the dbt test --full-refresh guard I removed on a wrong premise
The previous commit removed it after reading `DBT_COMMANDS` and concluding
`"test"` was unreachable. That is only true of the command a CALLER can name:
`run_dbt` is invoked with `"test"` directly for the `after_all` test phase, so
an `after_all` project with `full_refresh: true` reached it — and dbt rejects
`--full-refresh` on `test`, failing the phase. Both reviewers caught it.
The guard is back inside the shared function, where the build and the pass get
one answer, and its doc now records why reading the allowlist alone is
misleading. The test covering the `test` case is restored with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: notice a job that ended during the decode, and name truncation as the cause
- The parquet decode runs on a blocking thread with no poller watching it, so a
cancellation or an expired deadline during it was invisible: `dbt_dep` went on
to publish the graph and the job returned success. The job's state is checked
once the decode returns, before the caller publishes anything, and an ended
job `Err`s — which this module may always do for the job's own semantics.
- A compile stopped by the output ceiling could leave no artifact, and the log
then blamed the engine's capability, sending the reader to check their adapter
rather than the ceiling. Truncation now names itself in the missing and
unreadable branches too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: read cancellation from the DB after the decode, not from a poller's field
`ctx.canceled_by` is only ever written by a poller, and no poller runs during
the blocking decode — which is the exact window the check was added for. So the
guard caught only a cancellation already observed before it, and the comment
beside it claimed more than it did. It now queries `v2_job_queue` directly, the
same probe `worker_lockfiles` uses before it overwrites a flow.
A failed probe answers "still running": this decides whether to discard work
already done, so an unreachable database must not be the reason a healthy deploy
loses its graph.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: reuse job_is_canceled rather than a second copy of it
The probe added last round was `job_is_canceled` from the same file, retyped —
same query, same `Connection::Http` behaviour. Reused instead.
Its doc said a non-database connection was "a failed probe", which reads as an
error path. It is not: it is the agent worker, and on one there is no database
to ask, so only the deadline answers and a cancel issued during the decode is
not observable. The retry path avoids that by refusing to run on an agent worker
at all — which an optional annotation has no business doing — so the gap is
recorded at both ends instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: close the agent-worker cancellation gap instead of documenting it
The previous commit said a cancel issued during the decode is not observable on
an agent worker. It is: `ping_job_status` returns `canceled_by` over both
connection kinds, and is how the poller itself notices one there. So the check
asks through the ping rather than querying `v2_job_queue` directly, and holds on
an agent worker, where a direct query reaches no database at all.
`job_is_canceled` goes back to private and its doc to what it said before — the
retry that calls it still refuses to run on an agent worker for its own reasons.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: decode the index under the job poller instead of checking after it
Two findings with one cause: the decode was the only phase of this pass with no
subprocess behind it, so nothing heartbeated while it ran. A large index left
the worker silent for as long as it took, which the zombie sweep reads as a dead
job and restarts — and the cancellation check bolted on afterwards could only
ever report what had already happened, while dropping the ping's
`already_completed`, so a force-cancelled deploy still published its graph.
Running it under `run_future_with_polling_update_job_poller` answers all of it:
the poller pings throughout, and ends the phase with an `Err` on cancellation,
`AlreadyCompleted` or the phase timeout. The bespoke probe is gone with it.
Verified on a live deploy: 32 edges and 4 typed schemas ingested through the
polled decode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: stop a cancelled decode, and say what the read phase can now do
Putting the decode under the poller heartbeats it and ends the phase when the
job does, but dropping a `JoinHandle` detaches a blocking task rather than
cancelling it — so a cancelled job left a thread decoding up to four million
rows for a job that was over. The row loop reads an abandonment flag that a drop
guard on the awaiting future sets, so the decode stops at its next row.
That same change made the read phase able to `Err`, and three places still said
it could not — decision 14 in as many words. The distinction that holds is
narrower: nothing the ARTIFACT does or fails to do can fail a job, so absent,
unreadable and partial are all values; the JOB can still end the phase the read
runs in. Stated that way in the module doc, the `Artifact` doc, `MAX_INDEX_ROWS`
and the decision.
Verified on a live deploy: 32 edges and 4 typed schemas.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: share AbortOnDrop, and stop citing a hazard that is now handled
`Abandon` was `ansible_executor`'s `AbortOnDrop` retyped — same struct, same
reason, same `spawn_blocking` shape. Moved to `common` and used from both.
The paragraph explaining why the phase budget wraps the compile alone gave as
its reason "a decode still running on a blocking thread", which is exactly what
the abandonment flag now prevents. The reason that survives is the one that was
always the point: the budget exists to leave the build its share of the clock,
and only the compile can spend that share unboundedly. The decode's end is the
job's, through the poller it runs under.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: put both doc comments back on the items they describe
Moving AbortOnDrop orphaned a doc at each end: it landed between
`raw_to_string`'s doc and `raw_to_string`, and the doc of the struct it replaced
stayed behind to prefix `fetch_repo_archive`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: name the binding the row loop actually reads
`Abandoned` was neither the type nor the binding; the flag is `abandoned`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: durable dbt state per environment, and `--defer` onto it
`dbt retry` worked off two artifacts and only one was durable: `dbt_run_state`
holds `run_results.json` keyed by principal, and the manifest lived on
worker-local disk under a four-generation cache. That is enough to resume the
last run and nothing else — the next run of a project usually lands on a worker
holding neither artifact — so deferral had nothing to read.
Adds `dbt_environment_state`: one row per (workspace, script path, environment),
holding `manifest.json` and `run_results.json` from the last successful run, with
the blob inline under `DBT_STATE_INLINE_MAX_BYTES` and in the workspace's object
storage above it. Environment is the warehouse, the target, and the database and
schema they resolve to, so a repointed warehouse or a moved schema reads as an
environment nothing has published rather than as state whose relation names no
longer fit.
A run publishes it when its graph becomes what the script owns and it succeeded
— the same condition, and the same reason: an invocation that scoped its own
model set describes where the caller put those relations, not where the
project's models live.
`defer` is a `build` command-block field defaulting to the descriptor's own, and
the state is materialised into the job directory for `--defer --state`. The
retry path already did that materialisation for `dbt retry`; both go through one
`write_state_dir` now.
`--state` is also where `dbt retry` reads the run it resumes, so a retry on
dbt-core 1.x takes `--defer-state` instead, and one on an engine without that
flag is refused before the build rather than rebuilding its nodes with every
unbuilt `ref()` resolving into the schema this run writes into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ned2pmRJwB3GpenEcrA9TF
* fix: address the local review of the dbt environment state
The oversized-artifact home moves from the workspace's object storage to the
instance's, where every other internal worker artifact already lives. The
workspace bucket is the one members read and write through `job_helpers/*` with
a caller-supplied key and only `volumes/` is reserved there, so a manifest under
it is one any member could replace — and the next deferring run would hand dbt
an attacker-chosen `defer_relation` for every unbuilt `ref()` while holding the
script's warehouse credentials.
The environment key takes the target dbt actually runs rather than the
descriptor's `profile.target`, which is absent whenever the target is inherited
from the workspace warehouse or the project's own `profiles.yml` — filing every
inherited target under one empty name, while a `target.name` macro decides where
a model is built. `write_profiles` returns a named struct now that it resolves
one more thing.
Publishing takes the row's lock before uploading, so two publishers of one
environment cannot interleave their uploads and leave one run's manifest beside
another's results, and carries the live-dbt-script guard the retry state already
had, so a job finishing after its script was renamed, archived or deleted cannot
recreate state at a path for whatever is created there next.
A rename now clears the environment state instead of moving it: an oversized
artifact's key is derived from the path, so a moved row would keep pointing at a
key a script created at the old path publishes over.
A build recovered by the automatic in-job node retry publishes its manifest
without results — `run_results.json` is then the retry's, naming only the nodes
it redid — and the refusal for an environment with nothing published names the
runs that cannot publish rather than suggesting a run that would not help.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: serialize dbt state publishers on an advisory lock
The row lock only serializes publishers once a row exists, and the first
publish of an environment — two runs of a newly deployed script — is exactly
when two of them are most likely to race and interleave their uploads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: make dbt state publication atomic and bind it to the version that ran
Every publication now writes its own object keys and the row switches to them in
one statement, so an upload never overwrites an artifact the committed row still
names: a run failing between its two uploads, or between them and its row, leaves
the state pointing at the pair it already had. The objects a commit displaces are
dropped afterwards — never before, since a reader that has already read the row
is about to fetch them — and a reader that loses that race re-reads the row once
rather than reporting a state that is there. What a publication uploaded and then
could not commit is dropped on the way out.
The write's guard names the VERSION rather than the path: the live dbt script
there must be the one this job ran, or a later version of it. "Some live dbt
script is here" is also satisfied by a script created at a path this one was
renamed away from, and this job's manifest would then become that project's
deferral state. A preview names no version and so publishes nothing.
A `show` defers too. It compiles the model it previews, so a model whose upstream
this environment built and this run did not is exactly the case a deferral exists
for, and every engine takes the flags on it.
Three comments said "the workspace's object storage" where the code deliberately
uses the instance's, which is the whole security argument; `mib()` labelled MiB
values MB.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: hold the script row across a dbt state publication, and let a rename move it
The version guard read `script` without a lock, so lifecycle cleanup could find
no environment row to clear, finish, and leave this transaction to commit state
at a path a new script goes on to occupy. It now holds that row (`FOR SHARE`) for
the rest of the publication — taken before the sidecar, the order every other dbt
writer takes — and the artifacts are uploaded before the transaction, so the lock
covers the row work rather than a network round trip.
A commit that reports an error may still have committed: what was lost can be the
acknowledgement. Dropping this run's objects then leaves the committed row naming
objects that are gone, so an orphan is the cheaper side to take.
A failed second upload left the manifest it had already written behind; it is
dropped now.
Per-publication keys retired the reason a rename cleared the environment state
rather than moving it: the path is only a prefix, and the row is what names an
artifact, so a script created at the old path can no longer publish over a moved
row. The rename moves both halves again.
`dbt ls` gets the deferral flags too, without which a `result:` selector — which
reads `run_results.json` out of the state directory, and which `select` passes to
dbt verbatim — fails before the build that would have honoured it.
Also: the migration was the last site describing the workspace's object storage
rather than the instance's, `publication_lock` folded 32 bits where it claimed
64, and `ResolvedProfile` had taken `write_profiles`'s doc block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: a deferring dbt run never publishes the state it read
`publishes_ownership` reads the CALLER's overrides, so a descriptor that already
narrows `select` needs none and a run of it with `defer: true` published. A
deferring run built some of the relations its manifest names and resolved the
rest out of the state it read, so recording that manifest claims relations
nothing built — and a model renamed since is recorded under a name only a full
build creates, breaking every later deferral until one repairs it.
Also: `publication_lock` parsed 16 hex digits as `i64`, which overflows for every
digest with the top bit set — half of them — collapsing those environments onto
one advisory key; and a failure to open the transaction returned without dropping
the objects already uploaded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: only a deployed dbt run publishes state, and key its objects per execution
A preview carries a caller-supplied `script_hash` into `runnable_id`
(`run_preview_script`), so the version guard alone let anyone who may run a job
publish arbitrary content as a deployed script's deferral state. The job's KIND
is checked beside it now. Verified: a preview submitted with the deployed path
and hash builds and leaves the row untouched.
Object keys carry a per-execution nonce. Zombie recovery re-runs a job under its
own id, so keyed on that alone a second attempt overwrote the objects the first
attempt's committed row still named, then read those same keys back as displaced
and dropped them — leaving the row unreadable. The displaced set is also filtered
against this publication's own keys, so the invariant is stated rather than
re-derived from the key format.
A project-owned `profiles.yml` that templates its schema or database is refused a
deferral: dbt renders those and Windmill does not, so two renderings resolve to
one `relation_root` and would share one environment key. Plainly absent is left
alone — that is the adapter's default, which does not move.
The deferral log line now says the run publishes no state of its own, which was
otherwise invisible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: a templated profile location publishes no dbt state either, on every path
A `dbt_profile` resource is one block of the user's own `profiles.yml` copied
through unchanged, and `profile.schema` is written as given, so either can carry
a template dbt renders and this runtime does not — exactly as a project-owned
file can. Only the project-owned path detected it.
And the refusal now covers publication as well as deferral: a published template
would sit under a key a literal profile shares, so de-templating later would make
that stale manifest readable as the new location's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: recognise Jinja statement blocks as a rendered dbt profile location
dbt renders a profile through Jinja, so `{% if env_var('ENV') == 'prod' %}…{% endif %}`
moves a schema exactly as an `env_var()` substitution does — and only `{{` was
detected, so such a profile published and deferred under one environment key for
every rendering. One predicate now serves both profile paths, with a test for
each delimiter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: a dbt state read outruns successive publications rather than one
The loader re-read once, which answers a single publication overtaking it: a
reader takes no lock and the advisory lock is released before the displaced
objects are dropped, so back-to-back publications could each overtake the same
read and the second was reported as a missing object. It now re-reads for as long
as the row keeps MOVING, bounded, and reports only when an unmoved row's objects
are genuinely gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: a dbt state read outruns successive publications, not one
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: name both ways a dbt state read can fail
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: length-prefix the dbt environment key's components
A dbt target name and a schema are both the user's own strings, so joining them
on `|` let one component spell another tuple's key: `prod|analytics` + `scratch`
and `prod` + `analytics|scratch` were one environment, and a profile moving
between them read as the same one rather than as one nothing has published — the
collision the key exists to prevent. The schema and database are also taken apart
now rather than through `relation_root`'s own join, so neither can absorb the
other's delimiter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: name the dbt environment in words where a message shows it
The key is length-prefixed for storage, which is not something to put in front of
a caller: the "nothing published yet" refusal now reads "warehouse `main`, target
`prod`, relations in `dbt_wh_defer.analytics`". The worked example of the encoding
also miscounted a component.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: delete a script version in the transaction that cleans up after it
`delete_script_by_hash` soft-deleted through the pool, committing before the
cleanup that follows it in `tx`. In that window the path has no live version, so
a concurrent deploy can take it — and `clear_dbt_script_state_if_path_retired`
then finds that new script live, keeps the deleted project's dbt state, and
leaves the replacement able to defer through its manifest. The update moves into
the same transaction, which is what `archive_script_by_hash` beside it already
does.
The retirement guard itself was pinned by nothing: the existing test moved the
only row away before calling the conditional clear, so it could not fail.
`state_goes_only_once_no_live_version_is_left` covers both directions — a second
live version keeps the state, the last one leaving takes it — and fails if the
predicate is inverted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: archive a script by path in the transaction that cleans up after it
The last of the four routes still writing outside its own cleanup transaction.
Archived on its own, a cleanup that then fails leaves dbt state at a path no live
version occupies, and whatever is created there next can defer through it. The
by-hash archive and both deletes already take their write in `tx`; this makes the
set uniform.
Two comments beside those clears still called the state the RETRY state alone,
which the rename made false — they cover both halves now — and the merged
verification list had two `11.`, main's #10978 having inserted an item above it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: refuse a dbt state selector the engines resolve inconsistently
`state:`, `result:` and `source_status:` selectors resolve against the
artifacts in `--state`, which only a deferring run is handed. The engines
disagree about what happens without one, and two of the three disagree
silently: dbt-core 1.x raises, but dbt-sa-cli 2.x and fusion read a missing
state as an empty one and exit 0, so `state:modified` builds nothing and
`state:new` builds the whole project, each reporting success.
Refuse them up front instead, naming `defer`. From the descriptor they are
refused outright, since that selection also decides which nodes the script
owns and the deploy resolves it with no state at all.
`source_status:` is refused under any setting: it compares `sources.json`,
which no run publishes here.
A caller's selection is now allowed to match nothing, which is what
`state:modified+` returns when nothing changed since the published state. It
is stored as that run's own snapshot and never becomes what the script owns,
so the ownership-wipe the refusal guarded against cannot happen. The
descriptor's selection still may not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: refuse a dbt result selector the published state cannot answer
Round 18 findings.
Codex P1: `defer` alone was enough to allow a `result:` selector, but a build
recovered by node retry publishes a manifest with no `run_results.json` — the
only file such a selector reads. dbt-core then raises an internal error and the
Rust engines match nothing and exit 0. The deferral now reports whether the
state carries results, and a `result:` selection against one that does not is
refused, naming the run that published it.
Claude P2: a `parse` returns before `defer` is read, so its deferral is always
absent and "turn `defer` on" was advice that led nowhere. The check now
distinguishes a run that could defer from a command that never does, and the
parse path says so.
Codex P2 / Claude P2: the roadmap still listed `state:modified` as out of scope
while the same file documented it as working. Narrowed both that line and the
scope list to the slim-CI work that genuinely remains.
Also pins the invariant the relaxed empty-selection guard rests on: an
overridden selection must not publish ownership, or an empty caller selection
would wipe the script's graph.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: exempt an empty dbt selection by method, not by who chose it
Round 19 findings.
Codex P1: the empty-selection exemption keyed on whether the caller overrode
the selection, so a misspelled model name resolved to nothing, passed the guard
and reported a build that did its work. Key it on the selector instead: only a
`state:` or `result:` method may match nothing, its empty answer being a real
one. Every other selection matching nothing is refused again, from a run as
from the descriptor, each with the message that applies to it.
Claude P2: the spec still described a node-retry-recovered publication as one
where `result:` selectors merely lose their input, which the previous commit
stopped being true, and the section stating the selector rules recorded neither
the `result:`-without-results refusal nor the `parse` one. Both written down.
Also drops the refusal's claim that the publishing run WAS recovered by node
retry: an unreadable file reaches the same absent-results state, and the remedy
is the same either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: record why an exempted empty dbt selection cannot wipe the graph
The safety argument left with the origin-based condition it justified. Under
the method-based one it is a consequence of the descriptor refusal in
check_state_selectors, two hops from this site, so state it here: relaxing that
refusal would let a descriptor-narrowed `state:modified+` reach the exemption
and be ingested as owning nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: let `// materialize` declare a `dbt://` warehouse-relation write
`// materialize manual dbt://<warehouse>/<schema>/<name>` lets an ingestion
script in any language declare that it writes a warehouse relation, so it and
the dbt model reading that relation land on one asset node instead of two
disconnected pictures. `manual` is the only mode a warehouse target has —
nothing generates warehouse DDL — and the non-`manual` spelling is refused
rather than silently degraded. The `<warehouse>` segment is resolved against
the workspace's configured warehouses, like a descriptor's `profile.warehouse`.
The run records the same `materialized_partition` row a DuckLake target does,
from the generic job path rather than an executor: the DuckLake write engine is
DuckDB's, this declaration is anyone's.
With a non-dbt producer now possible, the blanket deploy-time refusal of
`# on dbt://<relation>` narrows to the shape that still cannot fire — every
writer of the relation being a dbt script, since a dbt run does not dispatch.
"Nothing produces it yet" stays accepted, as for every other asset kind, so
deploy order does not matter. A dbt script may not subscribe at all: its graph
ingest clears its own `dbt://` trigger rows. The one ordering the deploy cannot
catch — a subscription accepted before any producer, then claimed by a dbt
project — is named in that project's deploy log.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rw1WrKeRRzyYHjfkuB83ek
* fix: address review — preview stamping, stale producer set, public doc
Three findings from the local review round:
- Record the warehouse write only for a DEPLOYED script job. The annotation is
a deploy-time contract (`manual`, three segments, a configured warehouse)
checked where write access to the path is also required; honouring it in a
preview, hub or inline-flow body let `jobs:run` alone restamp any relation's
last writer from a script that never touched it.
- Exclude the deploying script's own rows from the producer set. Read
committed, they describe the version being replaced, so a script dropping its
`// materialize` while adding a subscription counted itself as the producer
that would wake it and committed a dormant edge. It could not be that
producer anyway — the dispatcher skips self-loops.
- `AssetKind::Dbt`'s doc no longer claims dbt is the exclusive producer of a
warehouse relation, on both the types and the parser enum.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: review round 1 — dbt-script materialize, set-form rule, doc
- Refuse `// materialize` on a dbt script, the producer half of the rule the
trigger loop already applies to `// on`: the graph ingest republishes that
path's asset rows wholesale, so a declared write is wiped by the deploy that
accepted it while its runs keep stamping the relation.
- `dormant_dbt_subscriptions` now spells the same predicate its singular sibling
does: the producer set has to be non-empty (nothing produces it yet is deploy
order, not a dormant edge) and excludes the subscriber's own path (a script
never wakes itself). Both divergences are pinned by tests.
- The docs no longer claim the dbt deploy log covers a native producer that drops
its `// materialize`; it does not, and nothing else reports that case.
- An integration test over the deploy contract, since only a real deploy proves
the handler feeds `sole_dbt_producer` the canonical key `asset.path` holds —
the spelling that has to agree across the materialize target, the `// on` ref
and the refusal that joins them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: qualify the any-language claim, and pin the dbt-script refusal
`AssetKind::Dbt`'s contract (both enums), the two runtime guides and the deploy
comment said a script of any language may declare a `dbt://` write, which the
dbt-script refusal added last round contradicts. They now say "any language but
dbt's own", with the reason: a project's writes are read from its manifest.
The deploy-contract integration test covers that refusal for both annotations.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: teach the pipeline AI guidance the warehouse-relation target
The pipeline prompt (both sources, plus the regenerated bundle) told the model
`// materialize` is DuckDB-only and rejected on any other target, which now
steers users away from the very thing this PR adds. It distinguishes the managed
DuckLake write, still DuckDB-only, from the warehouse-relation declaration any
language but dbt's own may make.
`dbt_manifest.rs`'s module doc carried the same "the only thing that creates one"
overclaim the other four sites lost last commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: draw an explicit dbt:// subscription on the canvas
The editor suppressed every `// on dbt://…` overlay, which was right while the
deploy refused all of them. It now refuses only a relation dbt alone builds, so
the suppression hid the author's own annotation for exactly the case this PR
adds — a subscription woken by a native `// materialize manual dbt://…`
producer. The deploy stays the gate.
Also the two stale claims round 4 named: the live pipeline prompt dropped the
dbt-script exception the base prompt carries, and the doc's e2e requirements
still said every `dbt://` subscription is refused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: refuse `// data_test` beside a `dbt://` materialize target
`// data_test` checks are verifier probes the DuckDB executor splices around a
managed write. A warehouse relation is written by the script itself, in any
language, so nothing would run them — and unlike the DuckLake `manual` case,
which at least fails loudly in that executor, a declarer in another language
deployed green with its data-quality assertions silently skipped.
Covered in the deploy-contract test and documented beside the annotation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: exclude a renamed producer from the sole-dbt producer set
The producer set already excluded the deploying script's own path, because its
committed rows describe the version being replaced. Under a rename the write
sits at the OLD path — still committed, and removed by the same uncommitted
transaction — so a producer renamed while it drops its `// materialize` and adds
`// on dbt://…` still counted as the producer that would wake it, and committed
a dormant edge.
The deploy-contract test covers it: without the exclusion the rename deploys
201 instead of being refused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: take the rename test's parent hash from the create response
`format!("{:x}", …)` over the stored i64 drops leading zeros, while
`ScriptHash`'s deserializer hex-decodes and demands 8 bytes — so a hash below
2^60 would 422 the request instead of reaching the refusal it asserts on, on
roughly one in sixteen spellings of that script body. The create response
already carries the zero-padded form, as the rest of the suite uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: state the concurrent-ingest interleaving honestly
`sole_dbt_producer`'s doc claimed the concurrent-deploy race only ever resolves
toward refusing. It does when the uncommitted producer is native; when it is the
dbt ingest, the check sees an empty producer set and accepts, and if that ingest
then commits and runs its warning query before the subscriber's trigger row
lands, neither side reports the dormant edge.
Not serialized: the two would have to share a per-relation lock, and the ingest
takes `script … FOR UPDATE` before its own advisory lock, so a deploy holding
relation locks first inverts that order into a cross-subsystem deadlock — a worse
failure than the cosmetic edge. Recorded beside the other orphaning the deploy
cannot catch, with the bound both share: the next deploy of that project warns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: refuse a `dbt://` subscription that is not a whole relation
`# on dbt://main/analytics` deployed and persisted a trigger row. Every producer
spells `<warehouse>/<schema>/<name>` — the manifest ingest derives it from
`relation_name`, a `// materialize` target is checked against it — so a partial
one is an edge nothing can ever wake, which is what the dbt-only refusal exists
to prevent.
The shape now has one definition (`is_full_relation_path`) that both halves of
the deploy ask, rather than a segment count spelled twice: a subscription and a
write that disagreed would refuse and accept the same string.
Also rewrites the canvas test's comment as a current constraint per AGENTS.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: hold both halves of the deploy to one `dbt://` relation validator
A subscription checked the relation's shape but not its warehouse, so
`# on dbt://<unconfigured>/<schema>/<name>` deployed and persisted a trigger row
for something no producer can ever write: the write side refuses that exact
string, and a dbt project's `profile.warehouse` resolves against the same config,
so no later deploy fixes it and the dormant-edge warning cannot report it either.
The shape rule and the warehouse rule now live in one `validate_dbt_relation`
that both halves call, rather than being spelled per site — the previous two
rounds each closed one half of one rule, which is the drift that invites.
Also moves the parser test out from between a comment and the test it documents,
and names both refusals in the doc's list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: drop the subscription-only clause from the shared refusal message
"so nothing can produce it" reads backwards on the `// materialize` side, which
is the producer. The remaining sentence says what is wrong on both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: bound a `dbt://` relation by the asset-path column in the shared validator
`asset.path` is VARCHAR(255) and the manifest ingest drops a relation that
outgrows it rather than failing the whole graph, so past the column no producer
row can exist on either side. `script_trigger.trigger_ref` is unbounded text, so
an overlong subscription deployed and stayed dormant for good; an overlong write
reached Postgres and failed the deploy on a `value too long` instead of a message.
Both now refuse in the validator the two halves share, against the ingest's own
constant. The integration case computes the ref from that constant so it cannot
drift back under the bound.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: report a warehouse-lookup failure as the failure it is, and correct the boundary
`dbt_warehouse_exists` fails three ways — no such warehouse, the query itself,
and a setting with no `resource_path` — and all three became a 400 blaming the
user's warehouse name. A pool timeout mid-deploy told a retrying sync that a
transient server error was a permanent client one. Only `NotFound` is the
annotation's fault now.
The known-boundary paragraph claimed a flow-runner run still cascades. It does
not: it is routed by `flow_step_id`, which `is_eligible_kind` rejects, as
`asset_trigger_dispatch.rs` pins. Recording and cascading are decided separately,
so the paragraph now names all three routes rather than merging two of them — and
the row it omitted, an ordinary flow step, which records and never cascades.
E2E item 7 said "deployable" where the rule is "wakeable": with only the dbt
project reading the relation the producer set is empty, which deploys fine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: correct two rationales the last commit got wrong
`Error::SqlErr` already maps to 400 in this codebase, so the query case's status
was never the thing at stake. What the `NotFound` match earns is that a query
failure and a malformed setting stop being described as an unconfigured warehouse
name, and that the malformed-setting `InternalErr` reaches its own 500 instead of
being flattened.
And a flow step is two shapes, not one: a step running a deployed script is a
`Script` job that records and never cascades, while a step with an inline body is
`FlowScript`, which the recording guard excludes along with previews.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: warn about dormant subscriptions from the run that publishes ownership too
A run whose static descriptor finds its profile moved re-ingests the version's
graph and republishes path ownership, exactly as a deploy does — so it can be
what leaves a subscription accepted while the relation had no producer with dbt
as its only one. That path discarded `persist_ingest`'s result and emitted no
warning, which also made the doc's enumeration of unreported orphanings wrong.
Both ownership-publishing points warn now. An agent worker still cannot: it
reaches these tables only through the API and its ingest publishes without
reading back, which the doc now says.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: an agent run publishes no ownership, and the warning has two callers
The agent-worker sentence called it an exception that publishes ownership without
warning. It publishes none: `Connection::Http` forces per-run models, and
`publishes_ownership()` is the negation of that, so an agent stores a job-pinned
snapshot and leaves workspace ownership with the deployed graph — it cannot orphan
a subscription at all.
`warn_dormant_subscribers`' own doc still named the deploy log as the only place
the warning shows, one commit after it gained its second caller.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: stop the managed-write rule from contradicting the dbt:// target
The sentence after the warehouse-relation paragraph says `// materialize` means
the runtime writes the table for you and the body is a bare SELECT. That is the
managed DuckLake rule, written before a `dbt://` target existed, and unqualified
it tells the model the opposite of what the paragraph above it just said — a
model following the more prominent one emits a SELECT for a warehouse relation,
which deploys and then writes nothing.
Both prompt sources now scope it, and both name the `// data_test` refusal beside
a `dbt://` target, which the badge list advertised without the caveat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(parser-py): keep first param when def main( line has trailing comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump windmill-parser-wasm-py to 1.782.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>