eliminate the workspace-module-generate helper - #6
Conversation
TomChv
left a comment
There was a problem hiding this comment.
Overall this is a good deletion and the mechanism is right — PolyfillDirectoryDiff really was a hand-rolled core.Changeset, and asking the engine directly removes a container build, a privileged nested session, two exports, two re-imports and a redundant diff per module. Commits are cleanly separated (baseline migration isolated from behavior) and the generate comment explains why rather than what.
Two things I'd want resolved before merge — a silent revert of #5, and an under-declared engine requirement. Details inline; the cross-cutting points are below.
Breaking API change is unflagged
generate now returns Changeset! instead of PolyfillWorkspaceFork!, so callers lose .merge for composing several modules' generation with other workspace edits. The body mentions verifying dagger-dang-sdk:generate-all end to end but doesn't link the companion PR — worth linking so they land in lockstep, and worth confirming generate-all can still combine results the way it needs now that it's a bare changeset.
Modules above the cwd: is it a drop or a crash?
The body calls this a silent drop. Worth confirming — if the enclosing module's generated context doesn't span the cwd subtree, generated.after.directory(cwd) may error with "no such file or directory" rather than dropping.
Either way, silent is the worse outcome: the user runs dagger generate, sees "nothing to do", and the module never regenerates. A raise until the policy decision is made costs one line and turns a wrong answer into a legible one.
Not concerns
- Dropping the 141 lines of Go unit tests is correct — that logic moved into the engine and shouldn't be re-tested here.
withDirectoryDiffsurvives with an e2e consumer (.dagger/modules/e2e/workspace.dang:79), so it isn't orphaned by thegeneraterewrite.- The baseline commit's noise (
pubremoval,toJSON→JSON.encode,JSON!→Dagger.JSON!,.{x}→.{{x}}) is a mechanical language migration, cleanly isolated.
| generate: Changeset! { | ||
| let generated = core.generatedContextChangeset | ||
|
|
||
| PolyfillWorkspaceFork( | ||
| ws: ws, | ||
| cwd: cwd, | ||
| before: directory, | ||
| after: directory, | ||
| ).withDirectoryDiff(path, generated.before, generated.after) | ||
| # The engine roots the generated context at the workspace, but a returned | ||
| # changeset is applied relative to the caller cwd, so the two have to be | ||
| # reconciled or every path ends up nested under the cwd a second time. | ||
| # Selecting a subdirectory is metadata only, so this costs nothing. | ||
| if (cwd == ".") { | ||
| generated | ||
| } else { | ||
| generated.after.directory(cwd).changes(generated.before.directory(cwd)) | ||
| } |
There was a problem hiding this comment.
This reverts the scoping fix from #5, and the body doesn't mention it.
Rooting at cwd means that at cwd == "." this returns core.generatedContextChangeset unfiltered — the whole workspace-rooted generated context. That is exactly what 1662706 (#5, three commits back) changed away from:
Root the generated context at the module's own path and stage it on an empty fork base, so each module's changeset contains only its own files.
…because cwd-rooting at the workspace root "captured the whole workspace — including any staged dependency codegen — [so] the staged dependencies leak across modules: they cancel out (a dependency's codegen goes missing) or conflict on apply ('already exists')".
The root: path argument, the empty-fork base, and the comment explaining all of it are deleted here.
The mechanism isn't identical, to be fair — #5's failure ran through the fork's before snapshot, and there's no fork base now, so before comes straight from the engine. But two sibling modules sharing a local dep still each carry that dep's generated files in their own changeset, so the overlap on merge still needs explaining away.
Could you either state why #5's failure mode no longer applies, or restore module-path scoping? Restoring it means keeping the module subtree selection and the cwd-relative prefix — which is what the withDirectoryDiff staging was providing.
There was a problem hiding this comment.
🤖 ⚔ 🤖 : I went and checked this against the engine rather than argue from the diff, and the failure mode from #5 can't reach this code — the PR deletes the machinery it lived in, not just the guard.
The #5 leak needed two ingredients, both fork-pipeline artifacts:
- A
beforethat disagreed with the engine's base. The helper'saftercarried staged dependency codegen while the fork's base was the pristine workspace, so each module's changeset contained its dependencies' generated files. - Snapshot merging.
PolyfillWorkspaceFork.mergewasbefore.withDirectory(".", other.before)(and the same forafter) — a full-tree, last-wins overlay. That's how one module's dep-stagedbeforeleaked into the merged base and canceled the dependency's own additions, or conflicted on apply.
Neither exists now:
generatedContextChangesetisgeneratedDir.changes(from: contextDir), where both sides share the module's own context as base (runGeneratedContextincore/schema/modulesource.go). Codegen runs for this module only — dependencies are loaded just to feed their schemas to it — so the delta only ever contains this module's own files. Dependency codegen is either absent from both sides or staged into both; either way it cancels before it can enter the changeset. For a Dang module the delta is literally its owndagger.json.- Merging is delta-level now:
Changeset.withChangesetsconverts each changeset to a patch and octopus-merges the patches onto a common base (identicalbeforedirectories are deduped by content digest), failing on overlap instead of silently last-winning (core/changeset.go).
So sibling modules sharing a local dep each carry only their own files. That's pinned by generate-merge-check as of e1c2a5a: two sibling Go modules sharing a local dep produce changesets that each stay inside their own subtree, and the merge is asserted to be the exact union of the parts — which would catch both leak symptoms (a conflict, or cancellation dropping paths from the union).
The fair part of this comment: none of that was written down. dc6da86 states the invariant on generate, and the PR body now has a "Why this doesn't revert #5" section. The cwd-relative-prefix half of what withDirectoryDiff provided is what the re-root does.
| generateFromSubdirCheck: Void @check { | ||
| let ws = currentModule.source | ||
| .directory("fixtures/generate") | ||
| .asWorkspace(cwd: "app") | ||
|
|
||
| let changes = polyfill.workspace(ws).moduleSource("/app").generate | ||
|
|
||
| Asserts.added(changes, "dagger.gen.go") | ||
| Asserts.added(changes, "go.mod") | ||
|
|
||
| null | ||
| } |
There was a problem hiding this comment.
Nothing here covers the case #5 fixed. fixtures/generate is a single module (app) with no local dependency and no sibling, so dagger check passing says nothing about multi-module merge.
Compounding it: Asserts.added (utils.dang:38) is membership-only. Neither this check nor generateCheck asserts exhaustiveness, so extra leaked paths pass silently — including the nesting bug this check is meant to pin down, if it ever regressed partially. Asserts.paths already does exact-set matching and looks like the right tool here.
Suggested: a fixture with two sibling modules sharing a local dep, generate both, merge, assert the exact path set.
Separately, line 39 uses .moduleSource("/app"). The path users actually hit from a module directory is the cwd-relative "." — both resolve to path = "app", but only the relative form exercises moduleSourcePath's cwd-joining branch.
There was a problem hiding this comment.
Added in e1c2a5a: fixtures/generate-merge holds two sibling Go modules sharing a local dep. The check generates all three, asserts each changeset stays inside its own subtree, and asserts the octopus merge is the exact union of the parts. Asserts.paths does the exact-set comparison as suggested — against the union of the parts rather than a hardcoded list, since the Go SDK's generated file manifest changes across versions.
The subdir check now addresses the module as ., exercising moduleSourcePath's cwd-joining branch, and asserts no changed path carries the workspace-root prefix — the doubled-path signature, exhaustively this time.
| { | ||
| "name": "polyfill", | ||
| "engineVersion": "v0.20.8", | ||
| "engineVersion": "v1.0.0-0", |
There was a problem hiding this comment.
This under-declares the requirement. dagger/dagger#13745 is still open, and the outerEnvFile commit is a hard prerequisite. A user on exactly v1.0.0-0 doesn't get a version error — they get failed to get requester session: context deadline exceeded after a full context-deadline hang.
Bump to whichever release carries #13745, and hold the merge on it landing.
Side note: .dagger/modules/e2e/dagger-module.toml requires v1.0.0-beta.7, which by semver prerelease ordering is newer than v1.0.0-0 — so the root module is now the loosest declaration in the repo. Pre-existing, but the two should probably agree.
There was a problem hiding this comment.
Root engineVersion is now v1.0.0-beta.7 (7e811bd), matching the e2e module. Agreed on holding the merge on dagger/dagger#13745 — that's the "Blocked on" in the body; there's no released version carrying it to declare yet, so the real bump happens once that release exists.
| let path = ws.cwd | ||
| if (path == "/") { "." } else { path.trimPrefix("/") } |
There was a problem hiding this comment.
The old version handled null, "/", and the prefix trim; this drops to two cases. If ws.cwd can ever return "", the result is "", which fails the cwd == "." guard in PolyfillModuleSource.generate and the cwd == "." branch in PolyfillWorkspace.moduleSourcePath — both then silently take the wrong branch.
Fine if the schema types it String! and guarantees a leading /. Can you confirm?
There was a problem hiding this comment.
🤖 ⚔ 🤖 : Confirmed — the schema types it String! and the engine can't produce "": Workspace.cwd goes through workspaceAPIPath (core/schema/workspace.go), which maps "" and "." to "/" and everything else to a leading-slash path. The two cases here are exhaustive, so the cwd == "." guards downstream can't be skipped.
| Return the module source root path inside the workspace view. | ||
| """ | ||
| pub sourceRootPath: String! { | ||
| sourceRootPath: String! { |
There was a problem hiding this comment.
The doc says "inside the workspace view", but workspaceView is removed in this PR. sourceRootPath also has no in-repo consumers — the same reasoning used to justify removing workspaceView. Drop it too, or reword the comment.
There was a problem hiding this comment.
Dropped in 9839b2a — it fails the same "no consumers" test that removed workspaceView, and path is still there if a consumer ever needs the raw value.
There was a problem hiding this comment.
Walking that back: both sourceRootPath and workspaceView are restored in 5256266. They're public API consumed together outside this repo — sdk-sdk's mod-test contract is target(workspaceView, sourceRootPath), mounting the view at /work and running the module at /work/<sourceRootPath> — so "no in-repo consumers" is the wrong test for either of them, including the workspaceView removal this PR originally shipped. workspaceView is now served from ModuleSource.contextDirectory (same workspace-rooted layout, no longer filtered to the include set), the doc on sourceRootPath is accurate again since the view exists, and a new workspace-view-check pins the pair's contract.
|
Confirmed this will fix the migration issue I ran into with |
Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
The engine roots a module's generated context at the workspace, because that is the module source's context directory. A returned changeset is applied relative to the caller cwd, so generating from a subdirectory wrote every path nested under that cwd a second time: running from a module directory produced the whole workspace-root path beneath it rather than editing the module's own files. Re-root the changeset onto the cwd when the two differ. Selecting a subdirectory is metadata only, so this does not copy anything or reintroduce staging. Modules above the cwd remain unhandled: their files fall outside the cwd subtree and are dropped, which cwd-relative changesets cannot express in the first place. Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
The cwd was recovered by searching for "." from "." with findUp, working around engines that exposed no way to ask directly. Workspace.cwd exists as of v1.0.0-0, which this module already requires, so ask for it. Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
The generate docstring still described paths as workspace-root-relative, which stopped being true when the changeset was re-rooted onto the caller cwd. It also never said why the module-path scoping from #5 is gone: the engine diffs the generated context against the same base it generated from, so the changeset only ever carries this module's own generated files — staged dependency codegen appears on both sides and cancels out before it can leak. State both, since the old fork pipeline's leak is exactly what a reader of #5 will worry about here. Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
Re-rooting the generated changeset onto the caller cwd silently dropped any change outside the cwd subtree — notably a module discovered above the cwd, whose generated files then never land anywhere: the user runs generate, sees nothing to do, and the module stays ungenerated. A cwd-relative changeset cannot express such paths at all, so this needs a policy decision (exclude those modules from discovery, or refuse them); until that lands, raise with the offending paths instead of returning a quietly incomplete changeset. Checking the changed paths rather than the module root also catches a generator that writes outside its own subtree. Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
Its doc referred to "the workspace view", which no longer exists, and it has no consumers in this repo or dang-sdk — the same standard that removed workspaceView. The path field it merely renamed is still there if a consumer ever needs it. Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
Semver prerelease ordering puts v1.0.0-0 below v1.0.0-beta.7, so the root module declared a looser requirement than its own e2e module. Match the e2e declaration. This still under-declares the real requirement — loading a dir-kind module source needs the outerEnvFile change from dagger/dagger#13745 — but that has no released version to point at yet, which is why the PR stays blocked on it landing; bump again once the release carrying it exists. Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
The generate checks covered a single module with no dependencies, so they said nothing about the multi-module merge that #5 was about, and their membership-only assertions would pass over leaked extra paths. Add a fixture with two sibling modules sharing a local dependency, generate all three, and assert each changeset stays inside its own subtree and that the octopus merge is the exact union of the parts — the two ways dependency leakage would show up (conflict, or cancellation dropping paths from the union). Also address the subdir check's module the way a caller standing in it does — a cwd-relative ref, exercising moduleSourcePath's cwd-joining branch — and assert no path carries the workspace-root prefix, the signature of the doubled-path bug. Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
|
🤖 ⚔ 🤖 : On the two cross-cutting points: Breaking API / lost Drop or crash: it's a drop, not a crash — a dir-kind module source keeps the full workspace overlay rootfs as its context ( (The #5 question is answered on the inline thread; short version: the engine diffs against the same base it generates from and merges at the delta level, so dependency codegen can't enter a sibling's changeset — now pinned by |
Both are public API with consumers outside this repo — sdk-sdk's mod-test contract is target(workspaceView, sourceRootPath), mounting the view at /work and running the module at /work/<sourceRootPath> — so "no in-repo consumers" was the wrong test to delete them by. Serve the view from ModuleSource.contextDirectory, which for a dir-kind source is the workspace overlay rootfs: the same workspace-rooted layout the helper produced, minus the include-set filtering, which was an optimization rather than part of the contract. Pin the pair's contract with a check. This restores what 9839b2a and part of 50c8120 removed. Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
Generation used to run in a nested Go helper: build a container, start a privileged nested session, ask the engine for the module's generated-context changeset, then throw that changeset away and export its two sides as loose directories, so the Dang side could re-import them, re-stage them into a fork, and re-derive the diff the engine had already computed.
PolyfillDirectoryDiff— the type the helper's output was read back into — was{before, after}, which is exactlycore.Changeset. It was a hand-rolled reimplementation of the type being discarded.The engine can now be asked directly:
Workspace.moduleSourcealso removes the need to compute the module's include set, since it resolves the workspace overlay rootfs itself. That takes with it the config parsing for bothdagger-module.tomland legacydagger.json, and the recursive walk over local directory dependencies.The helper's other two entry points also fall away without their public API changing:
coreis now a directWorkspace.moduleSourcecall, andworkspaceViewis served fromModuleSource.contextDirectory— the same workspace-rooted layout, no longer filtered to the module's include set.workspaceViewandsourceRootPathstay because they are public API consumed together outside this repo (sdk-sdk's mod-test contract istarget(workspaceView, sourceRootPath)); an earlier revision of this branch dropped them on a "no in-repo consumers" test, which is the wrong test for public API.Net: 1023 lines deleted, and per module a container build, a privileged nested session, two full directory exports, two re-imports, two
withDirectorycopies, and a redundant diff all stop happening.Companion PR
dagger/dang-sdk#9 adapts
generate-allto the bareChangeset!return: per-module changesets are merged withChangeset.withChangesetsinstead of folding workspace forks throughfork.merge. The two land in lockstep; the dang-sdk side pinspolyfill@fix-generateuntil this merges.Why this doesn't revert #5
#5's module-path scoping guarded the fork pipeline, and this PR deletes the hazard rather than just the guard:
aftercarried staged dependency codegen while the fork'sbeforewas the pristine workspace, so each module's changeset contained its dependencies' generated files.fork.mergethen overlaid full before/after snapshots (withDirectory(".", other), last-wins), which is how one module's stagedbeforeleaked into the merged base and canceled the dependency's own additions — or conflicted on apply.generatedDir.changes(from: contextDir)). Dependency codegen is either absent from both sides or staged into both — either way it cancels before it can enter the delta, which only ever carries this module's own generated files. For a Dang module the delta is literally its owndagger.json.Changeset.withChangesetsapplies each changeset as a patch onto a merged base and fails on overlap instead of silently last-winning.generate-merge-checkpins this: two sibling Go modules sharing a local dependency produce changesets that each stay inside their own subtree and whose octopus merge is the exact union of the parts.Requires an engine change
Blocked on dagger/dagger#13745, specifically the
outerEnvFilecommit.Workspace.moduleSourceproduces a dir-kind module source. Loading one used to reach forhost.findUp(".env")unconditionally, which module code has no session for, so it blocked until the context deadline and then failed withfailed to get requester session: context deadline exceeded. That is what the "temporary workaround" comment on the helper was about — the nested privileged session existed to borrow a host session, not for any module-source semantics.Caller cwd
The engine roots a module's generated context at the workspace, but a returned changeset is applied relative to the caller cwd (
changeset.Export(ctx, ".")). Generating from a subdirectory therefore wrote every path nested under that cwd a second time — running from a module directory produced the whole workspace-root path beneath it instead of editing the module's own files. The changeset is now re-rooted onto the cwd when the two differ; selecting a subdirectory is metadata only, so this does not copy anything or reintroduce staging.Workspace.cwdexists as of v1.0.0-0, so thefindUp(".", from: ".")workaround for reading the cwd is gone too.Known gap: module discovery includes the nearest enclosing module, which may sit above the cwd. Its generated files then fall outside the cwd subtree, which cwd-relative changesets cannot express at all.
generatenow raises with the offending paths when that happens, so the gap is loud rather than a silent no-op; the remaining policy decision is whether discovery should exclude such modules or keep refusing them.Testing
dagger checkpasses — all 18 checks, including a newgenerate-from-subdir-checkthat builds a synthetic workspace entered at a subdirectory and asserts the changeset carries cwd-relative paths (via a cwd-relative module ref, with no workspace-root-prefixed leftovers), and a newgenerate-merge-checkcovering two sibling modules sharing a local dependency. The fourmodule-configfailures previously noted here no longer reproduce on the current engine build.Verified end to end against a real workspace:
dagger generate dagger-dang-sdk:generate-all -yrun fromcore/integration/testdata/modules/dang/counterin the dagger repo now rewrites that module's owndagger.jsonand touches nothing else.🤖 Generated with Claude Code