diff --git a/.dagger/modules/e2e/fixtures/.dagger-java-sdk-skip-generate b/.dagger/modules/e2e/fixtures/.dagger-java-sdk-skip-generate
deleted file mode 100644
index e69de29..0000000
diff --git a/.dagger/modules/e2e/fixtures/clients/dep/dagger-module.toml b/.dagger/modules/e2e/fixtures/clients/dep/dagger-module.toml
new file mode 100644
index 0000000..5f67e9a
--- /dev/null
+++ b/.dagger/modules/e2e/fixtures/clients/dep/dagger-module.toml
@@ -0,0 +1,5 @@
+name = "client-dep"
+engineVersion = "v1.0.0-0"
+
+[runtime]
+ source = "dang"
diff --git a/.dagger/modules/e2e/fixtures/clients/dep/main.dang b/.dagger/modules/e2e/fixtures/clients/dep/main.dang
new file mode 100644
index 0000000..50f1615
--- /dev/null
+++ b/.dagger/modules/e2e/fixtures/clients/dep/main.dang
@@ -0,0 +1,6 @@
+"""
+A module the e2e checks add as a client.
+"""
+type ClientDep {
+ pub greeting: String! { "hello from the client dependency" }
+}
diff --git a/.dagger/modules/e2e/fixtures/dagger.toml b/.dagger/modules/e2e/fixtures/dagger.toml
deleted file mode 100644
index bc02768..0000000
--- a/.dagger/modules/e2e/fixtures/dagger.toml
+++ /dev/null
@@ -1,21 +0,0 @@
-[modules.java-sdk]
-source = "../../../.."
-check.skip = ["*"]
-
-[modules.java-sdk.as-sdk]
-name = "java"
-
-[[modules.java-sdk.as-sdk.modules]]
-path = "generate/app"
-
-[[modules.java-sdk.as-sdk.modules]]
-path = "lookup/app"
-
-[[modules.java-sdk.as-sdk.modules]]
-path = "deps/app"
-
-[[modules.java-sdk.as-sdk.modules]]
-path = "skip/app"
-
-[[modules.java-sdk.as-sdk.modules]]
-path = "managed-toml/app"
diff --git a/.dagger/modules/e2e/fixtures/deps/app/dagger.json b/.dagger/modules/e2e/fixtures/deps/app/dagger.json
deleted file mode 100644
index adb4387..0000000
--- a/.dagger/modules/e2e/fixtures/deps/app/dagger.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "name": "deps-app",
- "engineVersion": "latest",
- "sdk": {
- "source": "java"
- }
-}
diff --git a/.dagger/modules/e2e/fixtures/generate/app/dagger.json b/.dagger/modules/e2e/fixtures/generate/app/dagger.json
deleted file mode 100644
index 34d9f00..0000000
--- a/.dagger/modules/e2e/fixtures/generate/app/dagger.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "name": "generate-app",
- "engineVersion": "latest",
- "sdk": {
- "source": "java"
- }
-}
diff --git a/.dagger/modules/e2e/fixtures/init-existing/keep.txt b/.dagger/modules/e2e/fixtures/init-existing/keep.txt
deleted file mode 100644
index a7107ad..0000000
--- a/.dagger/modules/e2e/fixtures/init-existing/keep.txt
+++ /dev/null
@@ -1 +0,0 @@
-kept by init
diff --git a/.dagger/modules/e2e/fixtures/lookup/app/pom.xml b/.dagger/modules/e2e/fixtures/lookup/app/pom.xml
new file mode 100644
index 0000000..c34a1f7
--- /dev/null
+++ b/.dagger/modules/e2e/fixtures/lookup/app/pom.xml
@@ -0,0 +1,10 @@
+
+
+
+ 4.0.0
+ io.dagger.modules.lookupapp
+ lookup-app
+ 1.0-SNAPSHOT
+
diff --git a/.dagger/modules/e2e/fixtures/managed-toml/app/dagger-module.toml b/.dagger/modules/e2e/fixtures/managed-toml/app/dagger-module.toml
deleted file mode 100644
index d72815b..0000000
--- a/.dagger/modules/e2e/fixtures/managed-toml/app/dagger-module.toml
+++ /dev/null
@@ -1,5 +0,0 @@
-name = "managed-toml-app"
-engineVersion = "v1.0.0-beta.7"
-
-[runtime]
- source = "java"
diff --git a/.dagger/modules/e2e/fixtures/skip/.dagger-java-sdk-skip-generate b/.dagger/modules/e2e/fixtures/skip/.dagger-java-sdk-skip-generate
deleted file mode 100644
index e69de29..0000000
diff --git a/.dagger/modules/e2e/fixtures/skip/app/dagger.json b/.dagger/modules/e2e/fixtures/skip/app/dagger.json
deleted file mode 100644
index e00513e..0000000
--- a/.dagger/modules/e2e/fixtures/skip/app/dagger.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "name": "skip-app",
- "engineVersion": "latest",
- "sdk": {
- "source": "java"
- }
-}
diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang
index 06234e4..4415161 100644
--- a/.dagger/modules/e2e/main.dang
+++ b/.dagger/modules/e2e/main.dang
@@ -1,19 +1,37 @@
"""
End-to-end checks for the Java SDK helper module.
+
+Every check here calls the java-sdk module, which needs an engine with
+dagger/dagger#13992. The module is deliberately not installed in dagger.toml, so
+a released-engine `dagger check` never reaches it; `engine-e-2-e:dev-sdk-check`
+runs `dagger -m .dagger/modules/e2e check` inside an engine built from that
+change.
"""
type E2e {
+ let fixtureRoot: String! = ".dagger/modules/e2e/fixtures"
+ # Scratch scopes, created in memory. generateScope always generates now, so
+ # every scope a check drives has to be a module Maven can build, not a
+ # config-only stub.
let outputRoot: String! = ".dagger/modules/e2e/out"
- let fixtureRoot: String! = ".dagger/modules/e2e/fixtures"
- let generateModulePath: String! = fixtureRoot + "/generate/app"
+ # The Java identifiers in nullableReturnSource — package
+ # io.dagger.modules.newapp, class NewApp — are derived from this name by hand
+ # and have to be changed with it. Checks that just need *a* module reuse the
+ # name so they share one vendored SDK build: the SDK jars are installed under
+ # a per-module Maven version, so a second name costs a second build.
+ let newModuleName: String! = "new-app"
+ let newModulePath: String! = outputRoot + "/" + newModuleName
+ let clientAppName: String! = "client-app"
+ let clientAppPath: String! = outputRoot + "/" + clientAppName
+
let lookupModulePath: String! = fixtureRoot + "/lookup/app"
let lookupNestedPath: String! = lookupModulePath + "/nested"
- let depsModulePath: String! = fixtureRoot + "/deps/app"
- let skipModulePath: String! = fixtureRoot + "/skip/app"
- # A CLI 1.0 managed module: configured by dagger-module.toml, not dagger.json.
- let managedTomlModulePath: String! = fixtureRoot + "/managed-toml/app"
- # A module using a different SDK; this SDK must never manage it.
+ # A module using a different SDK, with no pom.xml: never a Java client root.
let nonJavaModulePath: String! = fixtureRoot + "/lookup/not-java"
+ let clientDepPath: String! = fixtureRoot + "/clients/dep"
+
+ let vendoredBindings: String! = "sdk/src/main/java/io/dagger/client/Dagger.java"
+ let entrypointPath: String! = "src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java"
"""
Fail the current check when a condition is false.
@@ -45,229 +63,209 @@ type E2e {
}
"""
- New Java modules should target this repository's build/package-only runtime
- rather than a builtin SDK name.
+ Assert that a string contains every substring.
"""
- targetRuntimeCheck: Void @check {
- assert(
- javaSdk.targetRuntime == "github.com/dagger/java-sdk/runtime",
- "targetRuntime should be the in-repo build/package-only runtime",
- )
+ let assertContainsAll(value: String!, wants: [String!]!): Void {
+ wants.each { want => assertContains(value, want, "expected to contain: " + want) }
null
}
"""
- The generate skip marker filename should be stable.
+ Assert that a string does not contain a substring.
"""
- skipGenerateFilenameCheck: Void @check {
- assert(
- javaSdk.skipGenerateFilename == ".dagger-java-sdk-skip-generate",
- "skip marker filename changed",
- )
- null
+ let assertNotContains(value: String!, want: String!, message: String!): Void {
+ assert(value.contains(want) == false, message)
}
"""
- initModule should seed only the SDK-owned template files: a two-pass pom
- defaulting dagger.proc=none and the module sources, with the module name
- substituted into paths and contents. Engine-owned files (dagger-module.toml,
- the legacy dagger.json) are produced by the engine, not by initModule, and no
- existing files are touched.
+ findClientRoot answers with the directory of the nearest pom.xml, as a path
+ relative to the workspace root: the module itself from inside it, the owning
+ module from a subdirectory, the owning module from inside its vendored SDK,
+ and null where no pom.xml is in scope.
"""
- initCheck(ws: Workspace!): Void @check {
- let p = outputRoot + "/init-default"
- let changes = javaSdk.initModule(ws, name: "init-default", path: p)
-
- assertAdded(changes, p + "/pom.xml")
- assertAdded(changes, p + "/src/main/java/io/dagger/modules/initdefault/InitDefault.java")
- assertAdded(changes, p + "/src/main/java/io/dagger/modules/initdefault/package-info.java")
-
+ findClientRootCheck(ws: Workspace!): Void @check {
assert(
- contains(changes.addedPaths, p + "/dagger-module.toml") == false,
- "initModule should not write the engine-owned dagger-module.toml",
+ javaSdk.findClientRoot(ws.withWorkdir(lookupModulePath)) == lookupModulePath,
+ "findClientRoot should answer with the module root itself",
)
assert(
- contains(changes.addedPaths, p + "/dagger.json") == false,
- "initModule should not write the engine-owned dagger.json",
+ javaSdk.findClientRoot(ws.withWorkdir(lookupNestedPath)) == lookupModulePath,
+ "findClientRoot should find the module owning a nested path",
)
- assert(changes.modifiedPaths.length == 0, "init unexpectedly modified existing files")
- assert(changes.removedPaths.length == 0, "init unexpectedly removed files")
- let pom = changes.layer.file(p + "/pom.xml").contents
- assertContains(pom, "none", "pom should default dagger.proc to none")
- assertContains(pom, "init-default", "pom artifactId should be the kebab-case module name")
+ # A module's vendored SDK has no pom.xml of its own, so the module answers
+ # for it; nothing has to lift a hit out of sdk/ the way python-sdk does.
+ let vendored = lookupModulePath + "/sdk/src/main/java"
+ let withVendoredSdk = ws.withNewFile("/" + vendored + "/.keep", "")
+ assert(
+ javaSdk.findClientRoot(withVendoredSdk.withWorkdir(vendored)) == lookupModulePath,
+ "findClientRoot should answer with the module owning a vendored sdk/",
+ )
- let module = changes.layer.file(p + "/src/main/java/io/dagger/modules/initdefault/InitDefault.java").contents
- assertContains(module, "class InitDefault", "module class should be the camel-case module name")
+ assert(
+ javaSdk.findClientRoot(ws.withWorkdir(nonJavaModulePath)) == null,
+ "a module of another SDK, with no pom.xml, is not a Java client root",
+ )
+ assert(
+ javaSdk.findClientRoot(ws.withWorkdir(".")) == null,
+ "the workspace root is not a Java client root",
+ )
null
}
"""
- initModule onto a directory that already holds files should lay the template
- over them and leave them in place: a user running init inside an existing
- project must not lose their work.
- """
- initExistingCheck(ws: Workspace!): Void @check {
- let p = fixtureRoot + "/init-existing"
- let changes = javaSdk.initModule(ws, name: "init-existing", path: p)
-
- assertAdded(changes, p + "/pom.xml")
- assert(changes.removedPaths.length == 0, "init removed a file already in the target directory")
-
- null
- }
-
- let testWS(ws: Workspace!): Workspace! {
- ws.directory("/").withoutFile("dagger.toml").asWorkspace(cwd: ".dagger/modules/e2e/fixtures")
- }
+ generateScope initializes a scope that has no config: the template, a
+ dagger-module.toml from the manifest builder naming this repository's runtime,
+ and the generated bindings and entrypoint, all under the scope, with the
+ workspace cwd and the files already there untouched.
+ Generating the result again changes nothing, so generation is reproducible and
+ a manifest that already says the right thing is not rewritten.
"""
- From the workspace root the whole workspace is in scope, so modules() should
- return every Java SDK module this workspace manages — whether it is configured
- by the legacy dagger.json or the CLI 1.0 dagger-module.toml — and nothing that
- isn't managed by this SDK (e.g. a sibling module using another SDK).
- """
- modulesCheck(ws: Workspace!): Void @check {
- let pathRecords = javaSdk.modules(testWS(ws)).{{rootPath}}
+ generateScopeInitCheck(ws: Workspace!): Void @check {
+ let scoped = ws
+ .withNewFile("/" + newModulePath + "/keep.txt", "keep\n")
+ .withWorkdir(newModulePath)
+ let generated = javaSdk.generateScope(scoped, isModule: true, name: newModuleName, clients: [])
+ let changes = generated.withWorkdir(".").changes(scoped.withWorkdir("."))
+ assert(generated.cwd == scoped.cwd, "generateScope must not change the workspace cwd")
+
+ assertAdded(changes, newModulePath + "/dagger-module.toml")
assert(
- pathRecords.filter { r => r.rootPath == lookupModulePath }.length > 0,
- "lookup Java module should be listed",
- )
- assert(
- pathRecords.filter { r => r.rootPath == skipModulePath }.length > 0,
- "skip-marked Java module should still be listed (skip only affects generate)",
- )
- assert(
- pathRecords.filter { r => r.rootPath == generateModulePath }.length > 0,
- "generate Java module should be listed",
- )
- assert(
- pathRecords.filter { r => r.rootPath == depsModulePath }.length > 0,
- "deps Java module should be listed",
+ contains(changes.addedPaths, newModulePath + "/dagger.json") == false,
+ "initializing a scope must not write a dagger.json",
)
+ assertAdded(changes, newModulePath + "/pom.xml")
+ assertAdded(changes, newModulePath + "/src/main/java/io/dagger/modules/newapp/NewApp.java")
+ assertAdded(changes, newModulePath + "/" + vendoredBindings)
+ assertAdded(changes, newModulePath + "/" + entrypointPath)
assert(
- pathRecords.filter { r => r.rootPath == managedTomlModulePath }.length > 0,
- "a dagger-module.toml (CLI 1.0) managed module should be listed",
+ changes.modifiedPaths.length == 0,
+ "initializing a scope modified existing files: " + changes.modifiedPaths.join(", "),
)
assert(
- pathRecords.filter { r => r.rootPath == nonJavaModulePath }.length == 0,
- "a module not managed by this SDK should be excluded from modules listing",
+ changes.removedPaths.length == 0,
+ "initializing a scope removed existing files: " + changes.removedPaths.join(", "),
)
+ assertContainsAll(changes.after.file(newModulePath + "/dagger-module.toml").contents, [
+ "name = \"" + newModuleName + "\"",
+ "engineVersion = \"",
+ "[runtime]",
+ "source = \"github.com/dagger/java-sdk/runtime\"",
+ ])
+ assertContainsAll(changes.after.file(newModulePath + "/pom.xml").contents, [
+ "none",
+ "" + newModuleName + "",
+ ])
+
+ let again = javaSdk
+ .generateScope(generated, isModule: true, name: newModuleName, clients: [])
+ .withWorkdir(".")
+ .changes(generated.withWorkdir("."))
+ assert(again.isEmpty, "regenerating an unchanged module should change nothing")
+
null
}
"""
- Discovery is anchored at the client's cwd, not the workspace root. Re-anchoring
- the workspace to a subdirectory scopes modules() to the managed modules in that
- cone (walk-down) plus the nearest enclosing one (find-up), and excludes managed
- modules that live outside it.
+ A pre-1.0 module is migrated: the contents of its dagger.json move into a
+ dagger-module.toml, the runtime it already named is preserved rather than
+ replaced by this SDK's own, and the dagger.json is removed so two manifest
+ files cannot disagree.
"""
- modulesCwdCheck(ws: Workspace!): Void @check {
- # A stable snapshot of the workspace, re-anchorable at any cwd. It has to
- # carry the fixture dagger.toml, which is where currentModule.asSDK reads the
- # managed-module list from, and lookup/app/nested, the config-less
- # subdirectory the find-up case is anchored in.
- let root = testWS(ws).directory("/")
-
- # Walk-down: from fixtures/generate only generate/app is in the cone; the
- # sibling managed modules live outside it and must be excluded. Its
- # cwd-relative path is "app" — a directory beneath the cwd.
- let fromGenerate = javaSdk.modules(
- root.asWorkspace(cwd: fixtureRoot + "/generate"),
- )
- let fromGenerateRoots = fromGenerate.{{rootPath}}
- assert(
- fromGenerateRoots.filter { r => r.rootPath == generateModulePath }.length > 0,
- "cwd=generate: the managed module in the cone should be discovered",
- )
- assert(
- fromGenerateRoots.filter { r => r.rootPath == lookupModulePath }.length == 0,
- "cwd=generate: lookup/app is outside the cone and must be excluded",
- )
- assert(
- fromGenerateRoots.filter { r => r.rootPath == depsModulePath }.length == 0,
- "cwd=generate: deps/app is outside the cone and must be excluded",
- )
- assert(
- fromGenerateRoots.filter { r => r.rootPath == skipModulePath }.length == 0,
- "cwd=generate: skip/app is outside the cone and must be excluded",
- )
- assert(fromGenerateRoots.length == 1, "cwd=generate: exactly one managed module is in the cone")
- assert(fromGenerate.{{path}}.filter { r => r.path == "app" }.length > 0, "cwd=generate: the discovered module's path should be cwd-relative (app)")
-
- # Find-up: from inside lookup/app (a nested subdir with no config of its own)
- # the enclosing managed module is discovered; siblings are not. Its
- # cwd-relative path is ".." — an ancestor of the cwd.
- let fromNested = javaSdk.modules(root.asWorkspace(cwd: lookupNestedPath))
- let fromNestedRoots = fromNested.{{rootPath}}
- assert(
- fromNestedRoots.filter { r => r.rootPath == lookupModulePath }.length > 0,
- "cwd=lookup/app/nested: find-up should discover the enclosing lookup/app",
+ generateScopeMigrateCheck(ws: Workspace!): Void @check {
+ # Built from a generated module rather than a committed stub, because
+ # generateScope always generates and so needs a module Maven can build. Same
+ # module name as generateScopeInitCheck, so the vendored SDK build is shared.
+ let scaffolded = javaSdk.generateScope(
+ ws.withWorkdir(newModulePath),
+ isModule: true,
+ name: newModuleName,
+ clients: [],
)
- assert(
- fromNestedRoots.filter { r => r.rootPath == generateModulePath }.length == 0,
- "cwd=lookup/app/nested: generate/app is outside the cone",
- )
- assert(fromNestedRoots.length == 1, "cwd=lookup/app/nested: only the enclosing module should be discovered")
- assert(fromNested.{{path}}.filter { r => r.path == ".." }.length > 0, "cwd=lookup/app/nested: the enclosing module's path should be cwd-relative (..)")
-
- # Root cwd: the whole workspace is in scope, so every managed module — and
- # only the managed ones — is discovered, whether marked by dagger.json or
- # dagger-module.toml.
- # Using the fixture root where the test specific workspace is defined
- let fromRoot = javaSdk.modules(root.asWorkspace(cwd: fixtureRoot)).{{rootPath}}
- assert(
- fromRoot.filter { r => r.rootPath == generateModulePath }.length > 0,
- "cwd=/: generate/app should be listed",
+ let legacy = scaffolded
+ .withoutFile("/" + newModulePath + "/dagger-module.toml")
+ .withNewFile(
+ "/" + newModulePath + "/dagger.json",
+ "{\n \"name\": \"" + newModuleName + "\",\n"
+ + " \"engineVersion\": \"v1.0.0-0\",\n"
+ + " \"sdk\": {\n \"source\": \"java\"\n }\n}\n",
+ )
+ let migrated = javaSdk.generateScope(
+ legacy.withWorkdir(newModulePath),
+ isModule: true,
+ name: newModuleName,
+ clients: [],
)
- assert(fromRoot.filter { r => r.rootPath == lookupModulePath }.length > 0, "cwd=/: lookup/app should be listed")
- assert(fromRoot.filter { r => r.rootPath == depsModulePath }.length > 0, "cwd=/: deps/app should be listed")
- assert(fromRoot.filter { r => r.rootPath == skipModulePath }.length > 0, "cwd=/: skip/app should be listed")
+ let changes = migrated.withWorkdir(".").changes(legacy.withWorkdir("."))
+
+ assertAdded(changes, newModulePath + "/dagger-module.toml")
assert(
- fromRoot.filter { r => r.rootPath == managedTomlModulePath }.length > 0,
- "cwd=/: the dagger-module.toml managed module should be listed",
+ contains(changes.removedPaths, newModulePath + "/dagger.json"),
+ "migration should remove the pre-1.0 dagger.json",
)
- assert(fromRoot.length == 5, "cwd=/: exactly the five managed modules should be listed")
+ assertContainsAll(migrated.file("/" + newModulePath + "/dagger-module.toml").contents, [
+ "name = \"" + newModuleName + "\"",
+ "source = \"java\"",
+ ])
null
}
"""
- Generation from a subdirectory must resolve managed module root paths from
- the workspace root, not relative to the caller's cwd, and must return a
- changeset whose paths are relative to that cwd — the form the engine applies
- a generator's result in.
+ In a module scope the client set becomes the module's dependency set: the
+ client is recorded in the manifest and its types reach the generated bindings,
+ and a client that is no longer requested is dropped from both. A scope without
+ a module is left alone, and standalone clients are refused rather than
+ silently skipped.
"""
- generateCwdCheck(ws: Workspace!): Void @check {
- # Prepare a valid module in memory and remove the fixture-wide skip marker
- # so this check can exercise generation without changing committed fixtures.
- let initialized = javaSdk.initModule(ws, name: "generate-app", path: generateModulePath)
- let root = testWS(ws)
- .directory("/", exclude: [fixtureRoot + "/.dagger-java-sdk-skip-generate"])
- .withDirectory(".", initialized.layer)
-
- # Module-relative paths of the two artifacts generation stages.
- let vendoredClient = "sdk/src/main/java/io/dagger/client/Dagger.java"
- let entrypoint = "src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java"
-
- # From the module's parent the module is one level down, so its changes are
- # rooted at "app" — the same cwd-relative path modulesCwdCheck expects.
- let fromParent = javaSdk.generateAll(
- root.asWorkspace(cwd: fixtureRoot + "/generate"),
+ generateScopeClientsCheck(ws: Workspace!): Void @check {
+ let root = ws.withWorkdir(".")
+ let scoped = ws.withWorkdir(clientAppPath)
+ let manifestPath = clientAppPath + "/dagger-module.toml"
+ let bindingsDir = clientAppPath + "/sdk/src/generated/java/io/dagger/client"
+ let client = ws.moduleSource("/" + clientDepPath)
+
+ let untouched = javaSdk
+ .generateScope(scoped, isModule: false, name: clientAppName, clients: [])
+ .withWorkdir(".")
+ .changes(root)
+ assert(untouched.isEmpty, "a scope without a module should not be generated")
+
+ let refused = javaSdk
+ .generateScope(scoped, isModule: false, name: clientAppName, clients: [client])
+ .cwd rescue "raised"
+ assert(refused == "raised", "generateScope should refuse standalone clients")
+
+ let withClient = javaSdk.generateScope(scoped, isModule: true, name: clientAppName, clients: [client])
+ assertContainsAll(withClient.file("/" + manifestPath).contents, [
+ "[[dependencies]]",
+ "name = \"client-dep\"",
+ "client-dep",
+ ])
+
+ # The payoff: because the client is recorded as a dependency, the module's
+ # introspection schema carries its types and so do the generated bindings.
+ let bindings = withClient.directory("/" + bindingsDir).entries
+ assert(
+ bindings.filter { entry => entry.contains("ClientDep") }.length > 0,
+ "no ClientDep binding under " + bindingsDir + ", only: " + bindings.join(", "),
)
- assertAdded(fromParent, "app/" + vendoredClient)
- assertAdded(fromParent, "app/" + entrypoint)
- # From the module itself the cwd is the module root, so its changes carry no
- # prefix at all.
- let fromModule = javaSdk.generateAll(
- root.asWorkspace(cwd: generateModulePath),
+ let withoutClient = javaSdk.generateScope(withClient, isModule: true, name: clientAppName, clients: [])
+ assertNotContains(
+ withoutClient.file("/" + manifestPath).contents,
+ "client-dep",
+ "removing the last client should drop the dependency",
+ )
+ assert(
+ withoutClient.directory("/" + bindingsDir).entries
+ .filter { entry => entry.contains("ClientDep") }.length == 0,
+ "removing the client should drop its types from the bindings",
)
- assertAdded(fromModule, vendoredClient)
- assertAdded(fromModule, entrypoint)
null
}
@@ -277,34 +275,28 @@ type E2e {
return type — and, since generation compiles the entrypoint it produces,
generating at all proves the entrypoint the annotation processor writes for an
Optional return is valid Java.
-
- Reuses the generate fixture rather than adding a managed module, so the module
- inventory the discovery checks assert stays as it is.
"""
nullableReturnCheck(ws: Workspace!): Void @check {
- let modPath = generateModulePath
- let initialized = javaSdk.initModule(ws, name: "generate-app", path: modPath)
- let root = testWS(ws)
- .directory("/", exclude: [fixtureRoot + "/.dagger-java-sdk-skip-generate"])
- .withDirectory(".", initialized.layer)
- .withNewFile(
- modPath + "/src/main/java/io/dagger/modules/generateapp/GenerateApp.java",
- nullableReturnSource,
- )
-
- let changes = javaSdk.generateAll(root.asWorkspace(cwd: modPath))
- let entrypoint = changes
- .layer
- .file("src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java")
+ # Same scope and module name as generateScopeInitCheck, so both checks share
+ # the vendored SDK build instead of each paying for one of their own.
+ let scoped = ws.withWorkdir(newModulePath)
+ let scaffolded = javaSdk.generateScope(scoped, isModule: true, name: newModuleName, clients: [])
+ let withSource = scaffolded.withNewFile(
+ "/" + newModulePath + "/src/main/java/io/dagger/modules/newapp/NewApp.java",
+ nullableReturnSource,
+ )
+ let generated = javaSdk
+ .generateScope(withSource, isModule: true, name: newModuleName, clients: [])
+ .file("/" + newModulePath + "/" + entrypointPath)
.contents
assertContains(
- entrypoint,
+ generated,
"withObject(\"Directory\").withOptional(true)",
"an Optional return should register an optional Directory return type",
)
assertContains(
- entrypoint,
+ generated,
"res.orElse(null)",
"the Optional return should be unwrapped before serialization",
)
@@ -314,7 +306,7 @@ type E2e {
"""A module whose only function returns a nullable object."""
let nullableReturnSource: String! {
- "package io.dagger.modules.generateapp;\n"
+ "package io.dagger.modules.newapp;\n"
+ "\n"
+ "import static io.dagger.client.Dagger.dag;\n"
+ "\n"
@@ -324,7 +316,7 @@ type E2e {
+ "import java.util.Optional;\n"
+ "\n"
+ "@Object\n"
- + "public class GenerateApp {\n"
+ + "public class NewApp {\n"
+ " @Function\n"
+ " public Optional maybeDirectory(boolean found) {\n"
+ " if (!found) {\n"
diff --git a/.dagger/modules/engine-e2e/dagger-module.toml b/.dagger/modules/engine-e2e/dagger-module.toml
new file mode 100644
index 0000000..8d12349
--- /dev/null
+++ b/.dagger/modules/engine-e2e/dagger-module.toml
@@ -0,0 +1,10 @@
+name = "engine-e2e"
+engineVersion = "v1.0.0-0"
+
+[runtime]
+ source = "dang"
+
+[[dependencies]]
+ name = "engine-dev"
+ source = "github.com/dagger/dagger/.dagger/modules/engine-dev@8fd9b22b5416f8dc7cb420ba37769adef6e874d2"
+ pin = "8fd9b22b5416f8dc7cb420ba37769adef6e874d2"
diff --git a/.dagger/modules/engine-e2e/main.dang b/.dagger/modules/engine-e2e/main.dang
new file mode 100644
index 0000000..4813dfe
--- /dev/null
+++ b/.dagger/modules/engine-e2e/main.dang
@@ -0,0 +1,75 @@
+"""
+Checks the Java SDK against an engine built from dagger/dagger#13992 (branch
+sdk-ux-module-max), at the commit engine-dev is pinned to in dagger-module.toml.
+
+The released engine has none of that change, so every call into the java-sdk
+module fails there. These checks build the engine and run the SDK inside it.
+"""
+type EngineE2e {
+ let engineCommit: String! = "8fd9b22b5416f8dc7cb420ba37769adef6e874d2"
+ let modulePath: String! = ".dagger/modules/sdk-smoke"
+
+ let assert(condition: Boolean!, message: String!): Void {
+ if (condition == false) {
+ raise message
+ }
+ null
+ }
+
+ """
+ This checkout, mounted in an engine built from the pinned commit, as a
+ workspace the CLI inside can act on.
+ """
+ let playground(ws: Workspace!): Container! {
+ let sdkSource = ws.directory("/", exclude: [".git", "**/target/**"])
+ let engineSource = git("https://github.com/dagger/dagger")
+ .ref(engineCommit)
+ .asWorkspace
+ engineDev(ws: engineSource)
+ .playground
+ .withMountedDirectory("./java-sdk", sdkSource)
+ .withWorkdir("./java-sdk")
+ # .git is left out of the mount; workspace detection walks up to a .git to
+ # find the workspace root, and without one there is no workspace at all.
+ .withExec(["git", "init", "--quiet"])
+ }
+
+ """
+ The module-max engine should list this checkout as the java SDK, initialize a
+ Java module from it, and run the module.
+ """
+ pub devSdkCheck(ws: Workspace!): Void @check {
+ let listed = playground(ws).withExec(["dagger", "sdk", "list"])
+ assert(listed.stdout.contains("java"), "the development Java SDK was not listed")
+
+ let initialized = listed
+ .withExec(["dagger", "--auto-apply", "module", "init", "java", "--name", "sdk-smoke", "--path", modulePath])
+ .withExec(["test", "-f", modulePath + "/dagger-module.toml"])
+ .withExec(["test", "!", "-e", modulePath + "/dagger.json"])
+ .withExec(["test", "-f", modulePath + "/pom.xml"])
+ .withExec(["test", "-f", modulePath + "/src/main/java/io/dagger/modules/sdksmoke/SdkSmoke.java"])
+ .withExec(["test", "-f", modulePath + "/sdk/src/main/java/io/dagger/client/Dagger.java"])
+ .withExec(["test", "-f", modulePath + "/src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java"])
+ .withExec(["grep", "-q", "github.com/dagger/java-sdk/runtime", modulePath + "/dagger-module.toml"])
+
+ let release = initialized
+ .withExec(["dagger", "-m", modulePath, "call", "container", "file", "--path", "/etc/alpine-release", "contents"])
+ .stdout
+ assert(release.trimSpace != "", "the generated module did not run")
+
+ null
+ }
+
+ """
+ Run the e2e checks that call the java-sdk module. The module is not installed
+ in dagger.toml, because on a released engine every one of those calls fails,
+ so `-m` loads it by path here instead.
+ """
+ pub sdkContractCheck(ws: Workspace!): Void @check {
+ playground(ws)
+ .withExec(["dagger", "-m", ".dagger/modules/e2e", "check"])
+ .sync
+
+ null
+ }
+}
diff --git a/.dagger/modules/packager/main.dang b/.dagger/modules/packager/main.dang
index b1f497a..2cfb15c 100644
--- a/.dagger/modules/packager/main.dang
+++ b/.dagger/modules/packager/main.dang
@@ -10,6 +10,17 @@ into ~/.m2 instead of recompiling the plugin. Run this explicitly
produce them change.
"""
type Packager {
+ """
+ The local Maven repository, shared with the SDK's own codegen containers.
+
+ Mounted LOCKED because both mutate it: this module installs the codegen plugin
+ into it, and a module generation installs the SDK jars. Two writers at once
+ produce assets that differ from the committed ones, which surfaces as
+ `packager:generate` reporting drift after a build that succeeded. The SDK side
+ is mounted the same way; one volume takes one sharing mode.
+ """
+ let mavenRepo: CacheVolume! { cacheVolume("sdk-java-maven-m2") }
+
"""Maven container used to build the assets (pinned digest, matches the SDK)."""
let mvn: Container! {
container.from("maven:3.9.9-eclipse-temurin-21-alpine@sha256:4cbb8bf76c46b97e028998f2486ed014759a8e932480431039bdb93dffe6813e")
@@ -30,16 +41,23 @@ type Packager {
let codegenPluginRepo(ws: Workspace!): Directory! {
mvn
.withoutEntrypoint
- .withMountedCache("/root/.m2", cacheVolume("sdk-java-maven-m2"))
+ .withMountedCache("/root/.m2", mavenRepo, sharing: CacheSharingMode.LOCKED)
.withDirectory("/dagger-io", sdkSource(ws))
.withWorkdir("/dagger-io")
.withExec(["mvn", "--projects", "dagger-codegen-maven-plugin", "--also-make", "install", "-T1C", "-Dmaven.test.skip=true", "-Dfmt.skip=true", "-Dproject.build.outputTimestamp=2024-01-01T00:00:00Z", "--no-transfer-progress"])
- # Strip Maven's install-time timestamps so the committed repo is
+ # Export the two artifacts this module publishes, named one by one rather
+ # than copying io/dagger wholesale. The local repository is shared with
+ # module generation, which installs io/dagger/dagger-java-sdk/ and
+ # io/dagger/dagger-java-annotation-processor/ into it under a
+ # per-module version; a wholesale copy sweeps those in too, so what landed
+ # under prebuilt/ depended on whether a generation had run first.
+ #
+ # Then strip Maven's install-time timestamps so the committed repo is
# byte-reproducible: drop the comment lines from _remote.repositories
# (keeping the ">=" local-install markers resolution needs) and pin
# . Otherwise every generate re-timestamps these files and
# the check reports perpetual drift.
- .withExec(["sh", "-c", "rm -rf /out && mkdir -p /out/io && cp -r /root/.m2/repository/io/dagger /out/io/dagger && find /out -name _remote.repositories -exec sed -i '/>=/!d' {} ';' && find /out -name maven-metadata-local.xml -exec sed -i 's|[0-9]*|20240101000000|' {} ';'"])
+ .withExec(["sh", "-c", "set -e; rm -rf /out; mkdir -p /out/io/dagger; cd /root/.m2/repository/io/dagger; cp -r dagger-codegen-maven-plugin dagger-sdk-parent maven-metadata-local.xml /out/io/dagger/; find /out -name _remote.repositories -exec sed -i '/>=/!d' {} ';'; find /out -name maven-metadata-local.xml -exec sed -i 's|[0-9]*|20240101000000|' {} ';'"])
.directory("/out")
}
@@ -67,7 +85,7 @@ type Packager {
.introspectionSchemaJSON
mvn
.withoutEntrypoint
- .withMountedCache("/root/.m2", cacheVolume("sdk-java-maven-m2"))
+ .withMountedCache("/root/.m2", mavenRepo, sharing: CacheSharingMode.LOCKED)
.withMountedFile("/schema.json", introspectionJSON)
.withDirectory("/dagger-io", sdkSource(ws))
.withWorkdir("/dagger-io")
diff --git a/README.md b/README.md
index b677c60..1d71876 100644
--- a/README.md
+++ b/README.md
@@ -8,63 +8,155 @@ are committed to version control, and **no code generation runs at module load
time** — the runtime just builds and packages the module.
> This repository owns both halves of the Java SDK. The root Dang module
-> (`main.dang` / `mod.dang`) owns code generation and scaffolding, and runs at
-> `dagger generate` time. The module *runtime* (the SDK contract: building and
-> packaging Java modules) is the build/package-only Go module under `runtime/`;
-> new modules reference it as `github.com/dagger/java-sdk/runtime`.
+> (`main.dang` / `mod.dang`) owns code generation and scaffolding, and runs when
+> the engine asks it to generate a scope. The module *runtime* (the SDK
+> contract: building and packaging Java modules) is the build/package-only Dang
+> module under `runtime/`; new modules reference it as
+> `github.com/dagger/java-sdk/runtime`.
+
+> [!IMPORTANT]
+> This SDK implements the module-scope interface from
+> [dagger/dagger#13992](https://github.com/dagger/dagger/pull/13992) and needs an
+> engine that has it. On the released engine (`v1.0.0-beta.11`) the module loads
+> but every call into it fails.
+>
+> It writes module manifests through
+> [`github.com/dagger/sdk-helpers`](https://github.com/dagger/sdk-helpers), the
+> manifest builder that #13992 moved out of the engine.
## Install
```sh
-dagger workspace install github.com/dagger/java-sdk
+dagger module install github.com/dagger/java-sdk
+```
+
+The engine recognizes the SDK interface and records the module as the `java` SDK
+in `dagger.toml`:
+
+```toml
+[modules.java-sdk]
+source = "github.com/dagger/java-sdk"
+
+[sdks.java]
+module = "java-sdk"
```
## Create a module
```sh
-dagger call java-sdk init --name=my-module
-dagger generate
+dagger module init java --name my-module --path .dagger/modules/my-module
```
-`init` scaffolds a new module:
+The engine records the module scope in `dagger.toml` and calls this SDK's
+`generateScope`, which renders the template, writes `dagger-module.toml`, and
+generates the SDK bindings in one step:
```
/
- dagger.json # sdk.source=java; codegen.automaticGitignore=false
- pom.xml # two-pass build; dagger.proc defaults to "none"
+ dagger-module.toml # [runtime] source = github.com/dagger/java-sdk/runtime
+ pom.xml # two-pass build; dagger.proc defaults to "none"
src/main/java/io/dagger/modules//.java
src/main/java/io/dagger/modules//package-info.java
-```
-
-`dagger generate` (or `dagger call java-sdk generate --path=`) fills in
-the generated, committed sources:
-
-```
src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java # generated entrypoint
sdk/src/main/java/... # vendored SDK library
sdk/src/processor/java/... # vendored annotation processor
sdk/src/generated/java/... # client bindings (from the engine schema)
```
+The SDK settings become typed flags on `dagger module init java` and are
+persisted on the scope:
+
+```sh
+dagger module init java --name my-module --template empty
+```
+
+`--template` picks a starter under `templates/`: `default` (a small working
+module), `empty` (a bare object class), or `legacy`.
+
Because everything is committed and the pom defaults `dagger.proc=none`, the
module builds with a plain `mvn package` (no annotation processor at build time)
— in an IDE or CI, without Dagger.
-## How generation works
+## Generate
-`generate` runs Maven in containers it controls: it builds the vendored codegen
-plugin, generates the client bindings from the engine's introspection schema,
-vendors the SDK library and annotation processor as source, and runs the
+```sh
+dagger generate
+```
+
+This regenerates every module scope recorded in `dagger.toml`. The engine
+narrows the set to the scopes containing your current directory, so running it
+inside a module regenerates that module.
+
+Generation runs Maven in containers this SDK controls: it builds the vendored
+codegen plugin, generates the client bindings from the engine's introspection
+schema, vendors the SDK library and annotation processor as source, and runs the
processor once to produce the entrypoint. It does not delegate code generation
back to the engine.
-## The codegen flag
+## Module scopes
+
+A Java scope is a directory with a `pom.xml`. `findClientRoot` answers with the
+nearest one at or above your current directory, which is how
+`dagger module client add` and friends find the module you are standing in. A
+project built with anything but Maven has no `pom.xml`, so this SDK reports no
+scope for it.
+
+## Module clients
-`init` sets, in the module's `dagger.json`:
+Module dependencies are replaced by generated module clients:
-- `codegen.automaticGitignore: false`
+```sh
+dagger module client add java
+```
+
+In a module scope the client set becomes the module's dependency set. Each
+client is recorded in the manifest the module has — `dagger-module.toml`, or the
+`dagger.json` of a pre-1.0 module — and its types are part of the generated
+bindings; a client that is removed is dropped from both.
+
+Standalone clients — in a scope that has no Java module — are not generated yet.
+Adding one is refused and the workspace is left unchanged.
+
+> [!WARNING]
+> The client set is the *whole* dependency set. A module that recorded
+> dependencies before this interface existed has no clients recorded for them,
+> so the first `dagger generate` drops them. Re-register each one first:
+>
+> ```sh
+> dagger module client add java
+> ```
+>
+> Then check that each one landed in `dagger.toml` before you generate. On the
+> `sdk-ux-module-max` engine builds this SDK currently needs,
+> `dagger module client add` reports success and writes nothing.
+
+## Pre-1.0 modules
+
+A module configured by `dagger.json` is migrated the first time it is generated:
+its contents move into a `dagger-module.toml` and the `dagger.json` is removed,
+so the two cannot disagree. The runtime it already names is preserved — a module
+on the engine's builtin `java` runtime stays there, and does not silently move
+onto this repository's.
+
+## Test
+
+```sh
+dagger check
+```
-This single flag carries two meanings: the generated files are committed (not
-git-ignored), and — because the committed files can be trusted — the builtin
-runtime skips codegen at module load and only builds/packages the committed
-sources.
+Checks run against two engines:
+
+- On the released engine, everything that does not call this module: the SDK
+ library's unit tests (`packager:unit-tests`), the prebuilt assets, and the
+ templates.
+- On an engine built from dagger/dagger#13992, the SDK interface itself.
+ `engine-e-2-e:dev-sdk-check` builds that engine from the commit pinned in
+ `.dagger/modules/engine-e2e` (the `engine-dev` dependency and `engineCommit`),
+ installs this checkout as the `java` SDK, initializes a Java module, and calls
+ it. `engine-e-2-e:sdk-contract-check` runs the `e-2-e:*` checks inside the same
+ engine. Bump both pins to follow the branch.
+
+The `e2e` module is deliberately not installed in `dagger.toml`. Every check in
+it calls this module, which a released engine cannot run, so
+`engine-e-2-e:sdk-contract-check` loads it by path — `dagger -m
+.dagger/modules/e2e check` — inside an engine that can.
diff --git a/dagger-module.toml b/dagger-module.toml
index 94d56da..c067ae8 100644
--- a/dagger-module.toml
+++ b/dagger-module.toml
@@ -3,3 +3,8 @@ engineVersion = "v1.0.0-beta.11"
[runtime]
source = "dang"
+
+[[dependencies]]
+ name = "sdk-helpers"
+ source = "github.com/dagger/sdk-helpers"
+ pin = "64645f1967d3dba6fce951dd61ae4acd8d9b0861"
diff --git a/dagger.json b/dagger.json
index 25cec9c..9673fd8 100644
--- a/dagger.json
+++ b/dagger.json
@@ -3,5 +3,12 @@
"engineVersion": "v1.0.0-beta.11",
"sdk": {
"source": "dang"
- }
+ },
+ "dependencies": [
+ {
+ "name": "sdk-helpers",
+ "source": "github.com/dagger/sdk-helpers",
+ "pin": "64645f1967d3dba6fce951dd61ae4acd8d9b0861"
+ }
+ ]
}
diff --git a/dagger.lock b/dagger.lock
index 6528205..fa2eb6e 100644
--- a/dagger.lock
+++ b/dagger.lock
@@ -1,9 +1,13 @@
[["version","2"]]
["","git-latest",["https://github.com/dagger/dang-sdk"],"refs/heads/main"]
-["","git-latest",["https://github.com/dagger/sdk-sdk"],"refs/heads/main"]
+["","git-latest",["https://github.com/dagger/sdk-helpers"],"refs/heads/main"]
+["","git-sha",["https://github.com/containernetworking/plugins","refs/tags/v1.9.0"],"9b3772e1a7abf93cbb7c6526a28bc0d27b830e02"]
["","git-sha",["https://github.com/dagger/dagger","refs/tags/v1.0.0-beta.11"],"a4e1e4ff663e5e51c2b96c2c0772f3d2f00cfb94"]
-["","git-sha",["https://github.com/dagger/dang-sdk","refs/heads/main"],"c724eec4270870aae489daa9f3cb8cbacfb44680"]
-["","git-sha",["https://github.com/dagger/sdk-sdk","refs/heads/main"],"00bb06748bcf22d724ed467f2298d31f1fb49be0"]
+["","git-sha",["https://github.com/dagger/dang-sdk","refs/heads/main"],"9c8e5c6f1927b9adc325846d47e66e697991797d"]
+["","git-sha",["https://github.com/dagger/sdk-helpers","refs/heads/main"],"64645f1967d3dba6fce951dd61ae4acd8d9b0861"]
+["","git-sha",["https://github.com/libfuse/sshfs.git","refs/tags/sshfs-3.7.6"],"7a2d988775446ebe7af9b01c99b3b8e86bddb05a"]
+["","git-sha",["https://github.com/opencontainers/runc","refs/tags/v1.4.2"],"c241c0bb5e60a8e8c1b2e53d4eca8d0068d8d57e"]
["","oci-sha",["docker.io/library/alpine:3.22"],"sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"]
["","oci-sha",["docker.io/library/golang:1.25-alpine"],"sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59"]
-["","oci-sha",["docker.io/library/golang:1.26-alpine"],"sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468"]
\ No newline at end of file
+["","oci-sha",["docker.io/library/golang:1.26-alpine"],"sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628"]
+["","oci-sha",["docker.io/tonistiigi/xx:1.2.1"],"sha256:8879a398dedf0aadaacfbd332b29ff2f84bc39ae6d4e9c0a1109db27ac5ba012"]
\ No newline at end of file
diff --git a/dagger.toml b/dagger.toml
index a3b65d4..2bf887a 100644
--- a/dagger.toml
+++ b/dagger.toml
@@ -1,5 +1,10 @@
-[modules.e2e]
-source = ".dagger/modules/e2e"
+# Dagger workspace configuration
+# Install modules with: dagger module install
+# Example:
+# dagger module install github.com/dagger/dagger/modules/wolfi
+
+[modules.engine-e2e]
+source = ".dagger/modules/engine-e2e"
[modules.packager]
source = ".dagger/modules/packager"
@@ -7,17 +12,26 @@ source = ".dagger/modules/packager"
[modules.dagger-dang-sdk]
source = "github.com/dagger/dang-sdk"
+# dagger/dagger#13992 removes as-sdk, and the module-max engine ignores this
+# table. It stays because dang-sdk has not adopted the replacement yet
+# (dagger/dang-sdk#13) and its generator now fails outright — "current module is
+# not installed as an SDK in this workspace" — when it finds no registration.
[modules.dagger-dang-sdk.as-sdk]
name = "dang"
-[modules.templates]
-source = ".dagger/modules/templates"
-
[[modules.dagger-dang-sdk.as-sdk.modules]]
path = ".dagger/modules/templates"
[[modules.dagger-dang-sdk.as-sdk.modules]]
path = "."
-[modules.sdk-sdk]
-source = "github.com/dagger/sdk-sdk"
+[modules.templates]
+source = ".dagger/modules/templates"
+
+[modules.java-sdk]
+source = "."
+check.skip = ["*"]
+
+[sdks.java]
+module = "java-sdk"
+
diff --git a/hack/designs/2026-09-04-sdk-module-interface.md b/hack/designs/2026-09-04-sdk-module-interface.md
new file mode 100644
index 0000000..2c2449f
--- /dev/null
+++ b/hack/designs/2026-09-04-sdk-module-interface.md
@@ -0,0 +1,655 @@
+# Adopt the module-max SDK interface
+
+Status: proposed
+Date: 2026-09-04
+
+## Reviewed baselines
+
+Every claim in this document was checked against these exact revisions.
+
+| What | Revision |
+| --- | --- |
+| This repository (`dagger/java-sdk`), base of the change | `be18cc2d64951628a79ae7da626ab2427b6a2436` |
+| The engine change, `dagger/dagger#13992`, branch `sdk-ux-module-max` | `8fd9b22b5416f8dc7cb420ba37769adef6e874d2` |
+| The manifest builder, `github.com/dagger/sdk-helpers`, `main` | `fec4ded81c0565ede1d3bc319d60c985ef95ee84` |
+| The reference SDK, `dagger/python-sdk#26` ("Update the Python SDK scope interface"), head | `39550254e0152949cb1d5bcb1dff403110c4b00d` (open) |
+| The earlier merged Python adoption, `dagger/python-sdk#25`, head | `c05426e0fbef5d758184667d62ddc406591b8192` (merged as `d8f8eca33c75c1113ba8412b3d3ad626a0c9b0ef`) |
+| The released engine and CLI this repository's CI runs | `v1.0.0-beta.11` |
+
+`sdk-ux-module-max` is force-pushed regularly, and it has been force-pushed
+twice during this work. Every reference to it below means the commit in this
+table, not the branch head at the time of reading. "Tracking the branch" records
+what each rewrite changed.
+
+## Problem
+
+`dagger/dagger#13992` changes the contract between the engine and an SDK module.
+It ships no compatibility adapter. On an engine built from that change, this
+repository's root module cannot serve as the Java SDK at all.
+
+Three removals break it:
+
+- `CurrentModule.asSDK` is gone. `JavaSdk.modules` selects it, so module
+ discovery has no source.
+- The beta SDK-module interface — `initModule`, `targetRuntime`, and the
+ `@generate` hook — is gone. `JavaSdk.initModule`, `JavaSdk.targetRuntime`, and
+ `JavaSdk.generateAll` implement exactly that interface.
+- `ModuleSource.generateLocalDependencies` is gone. `Mod.generateModule` selects
+ it to stage a module's local dependencies before code generation.
+
+The replacement is two required functions and one optional one, declared in
+`core/sdkmodule/provider.go`:
+
+- `findClientRoot(ws: Workspace!): String` returns the workspace-relative path
+ of the nearest client root that contains the workspace cwd, or null when there
+ is none. The result is nullable, and the engine reads it through
+ `dagql.Nullable`: an SDK reports "no root here" with null, not with the empty
+ string. The empty string would name a root at the workspace root.
+- `generateScope(ws: Workspace!, isModule: Boolean!, name: String!, clients: [ModuleSource!]!): Workspace!`
+ receives a workspace whose cwd is already the scope, and returns the complete
+ scope: the starter template and a module manifest when the scope is new, and
+ freshly generated bindings always. The `clients` list becomes the scope's
+ dependency set.
+- `defaultModulePath(ws: Workspace!, name: String!): String!` is optional. See
+ Non-goals.
+
+The engine validates the names, order, and types of those arguments exactly, so
+they are not negotiable.
+
+Registration moves with the interface. An SDK is recorded as
+`[sdks.] module = ""` in `dagger.toml`, and each
+managed scope as `[sdks..scopes.""]`.
+
+Three sibling SDKs have adopted the same interface the same way:
+`dagger/python-sdk#25` and its follow-up `dagger/python-sdk#26`,
+`dagger/go-sdk#37`, and `dagger/dang-sdk#13`.
+
+## Goals
+
+1. Implement `findClientRoot` and `generateScope`, and delete the beta interface
+ they replace.
+2. Write a module's `dagger-module.toml` through the shared manifest builder, so
+ dependency editing stays out of this repository.
+3. Turn a module scope's client list into the module's dependency set, so the
+ generated Java bindings carry each client's types.
+4. Re-register this SDK under `[sdks.java]`.
+5. Migrate a pre-1.0 `dagger.json` module onto `dagger-module.toml`, preserving
+ the runtime it already names.
+6. Prove the result against a real engine built from `sdk-ux-module-max`, in CI.
+7. Leave the pull request's CI green, rather than merging it red as
+ `dagger/python-sdk#25` did.
+
+## Non-goals
+
+- **Standalone clients.** A scope with clients but no module (`isModule: false`)
+ is refused with an error. This SDK has no mechanism to serve a client outside
+ a Java module: every generated binding is vendored under a module's `sdk/`
+ directory and compiled by that module's `pom.xml`. `dagger/python-sdk#26`
+ refuses the same case for the same reason. Serving standalone clients needs a
+ separate design — where the generated code goes, what builds it, what depends
+ on it — not a branch in this change.
+- **`defaultModulePath`.** The engine's own default for a `dagger module init`
+ with no `--path` is `/.dagger/modules/`
+ (`core/schema/workspace_sdk_module.go`). That is the same convention this
+ repository already used, so implementing the hook would only restate it.
+- **A public single-module generate entry point.** `dagger generate` regenerates
+ the recorded scopes, and the engine narrows that set to the scopes containing
+ the caller's cwd, so running it inside a module regenerates that module. A
+ separate `dagger call java-sdk mod --path … generate` command shape would be
+ new public surface with its own CLI contract to document and test, and the
+ engine interface does not need it. `Mod` stays internal.
+- **Manifest v2 and generated entrypoints** (`dagger/dagger#14038`). That is a
+ different engine change, prototyped separately in `dagger/java-sdk#19`.
+- **The unified-clients redesign** (`dagger/java-sdk#17`), which replaces module
+ dependencies with generated clients throughout the Java SDK. This change
+ adopts one engine interface; it does not redesign the Java client model.
+- **Keeping the SDK usable on the released engine.** See "Alternatives
+ considered".
+- **Changing what generation produces.** The vendored SDK sources, the generated
+ bindings, and the generated entrypoint keep their current layout and build.
+
+## Proposed approach
+
+### `findClientRoot`
+
+A Java module always has a `pom.xml` at its root: the starter template writes
+one, and the module's build needs one. Nothing else in a generated module is a
+project marker. The vendored SDK under `/sdk` is added to the module's
+build as extra source roots and carries no `pom.xml` of its own; the optional
+committed SDK jar under `/sdk/repo` is accompanied by a `*.pom` file,
+which is not named `pom.xml` and so is not a marker either.
+
+So `findClientRoot` answers with the directory of the nearest `pom.xml` at or
+above the workspace cwd, as a path relative to the workspace root, and with null
+when there is none.
+
+This is the direct analogue of python-sdk's rule — the nearest `pyproject.toml` —
+minus the correction python needs. A Python module's vendored client library is
+itself an installable Python project with its own `pyproject.toml`, so
+python-sdk must lift a hit inside `sdk/` back to the owning module. Java has no
+such hit to lift, and `find-client-root-check` pins that: it plants a file
+inside a module's `sdk/` tree and asserts the module itself still answers.
+
+Consequences worth stating:
+
+- In a Maven multi-module project, the nearest `pom.xml` wins, so a Dagger
+ module nested inside an aggregator resolves to itself, not to the aggregator.
+- A Gradle project has no `pom.xml`, so `findClientRoot` returns null and the
+ engine reports that client generation is unavailable there. This SDK builds
+ modules with Maven; that is the correct answer, and the README says so.
+
+### `generateScope`
+
+```mermaid
+flowchart TD
+ A["generateScope(ws, isModule, name, clients)"] --> B{isModule}
+ B -- "no, and clients is empty" --> C["return ws unchanged"]
+ B -- "no, and clients is not empty" --> D["raise: standalone clients unsupported"]
+ B -- yes --> E["move the cwd to the workspace root"]
+ E --> F{"scope already has\ndagger-module.toml\nor dagger.json?"}
+ F -- no --> G["overlay the starter template"]
+ F -- yes --> H["keep the scope's files"]
+ G --> I{"which manifest\nto build from?"}
+ H --> I
+ I -- "dagger-module.toml" --> J["load it"]
+ I -- "dagger.json" --> K["load it"]
+ I -- neither --> L["load a seed naming\ngithub.com/dagger/java-sdk/runtime"]
+ J --> M["set the name, clear the dependencies,\nadd one entry per client"]
+ K --> M
+ L --> M
+ M --> N["write dagger-module.toml"]
+ N --> O{"was there\na dagger.json?"}
+ O -- yes --> P["delete it"]
+ O -- no --> Q["vendor the SDK, generate\nthe bindings and the entrypoint"]
+ P --> Q
+ Q --> R["restore the scope cwd, return the workspace"]
+```
+
+`dagger generate` never reaches the `isModule: false, clients empty` branch: the
+engine's scope planner skips a scope that is neither a module nor a client
+holder (`core/schema/workspace_sdk_generator.go`). The branch exists because
+`generateScope` is also callable directly, which is how the end-to-end checks
+drive it.
+
+Five properties of that flow are worth stating separately.
+
+**Every module scope is generated.** There is no opt-out. An earlier revision of
+this design carried one — a `.dagger-java-sdk-skip-generate` marker file that
+held an existing module out of bulk regeneration — and it is gone: the marker,
+the `skipGenerateFilename` setting, `Mod.skipGenerate`, the marker fixtures, and
+the README section that described it. It existed to keep the end-to-end fixtures
+out of `dagger generate`, and the fixtures no longer need it, because no fixture
+is registered as a scope any more. Removing it also removes the question of what
+a half-generated module means: after `generateScope` returns, a module's
+manifest, vendored SDK, and entrypoint are always the ones this run produced.
+
+**The manifest comes from `github.com/dagger/sdk-helpers`.** That module is the
+manifest builder, extracted from the engine by #13992; the SDK depends on it,
+recorded in both `dagger-module.toml` and `dagger.json` at the repository root,
+and reaches it as `sdkHelpers.moduleManifest(loadToml:)` and
+`sdkHelpers.moduleManifest(loadJson:)`. The builder parses and serializes both
+manifest formats (`tomlFile`, `legacyJSONFile`) and edits dependency entries
+(`withLegacyRuntimeDependency`, `withoutLegacyRuntimeDependency`,
+`withoutLegacyRuntimeDependencies`). Dependency editing is the part this
+repository would otherwise have to implement itself, and the part it must not:
+rewriting an existing TOML manifest by hand means parsing and re-emitting a
+format the engine owns.
+
+Depending on `sdk-helpers` does not raise this repository's engine floor. That
+module declares `engineVersion = "v0.21.9"`, which the released engine
+`v1.0.0-beta.11` satisfies, so the dependency does not stop the SDK module from
+loading there. This was checked against the module's own `dagger.json`, not
+assumed.
+
+**A new module's runtime is named by a seed manifest.** The builder's runtime
+setters are one per builtin runtime (`withLegacyJavaRuntime`,
+`withLegacyGoRuntime`, …) and they write the builtin short name, so
+`withLegacyJavaRuntime` writes `source = "java"` — the engine's own Java runtime.
+This SDK targets its own repository's build-and-package-only runtime,
+`github.com/dagger/java-sdk/runtime`, for which the builder has no setter.
+`sdk-helpers` kept the engine's rule: a non-builtin runtime is rejected on a
+manifest built from nothing and accepted on one loaded from a config file. So
+`seedManifestFile` renders a three-key `dagger-module.toml` — the module name,
+the live engine version, and the runtime source — as a `File`, and
+`scopeManifest` loads that file through the builder. What lands on disk is
+therefore the builder's own rendering, not the seed bytes.
+
+**Clients become dependencies.** In a module scope, the complete client set
+replaces the module's dependency list: the manifest's dependencies are cleared
+structurally with `withoutLegacyRuntimeDependencies`, then one entry is added
+per client — a git client by its ref as is, a local client by its path relative
+to the module, computed with `Path(...).relativeTo(...)`. Clearing by name would
+not do. `withoutLegacyRuntimeDependency` matches an unnamed dependency on its
+*source*, and reading the recorded names means selecting
+`ModuleSource.dependencies`, which resolves every one of them, so a single stale
+or unreachable entry would fail generation instead of being dropped. Because the
+Java bindings are generated from the module's introspection schema, and that
+schema includes its dependencies' types, this is all it takes for a client's
+types to appear in the generated bindings.
+
+**A module with dependencies and no clients loses those dependencies.** That is
+the contrapositive of the rule above, it is deliberate, and it is the module-max
+model: the client set *is* the dependency set. It is also a real migration
+hazard, because the engine's own config migration records only `is-module` and
+`name` on a scope and never seeds `clients` from an existing dependency list
+(`core/workspace/migrate.go`). A module that has dependencies today therefore
+needs each of them re-registered as a client before the first `dagger generate`
+under the new interface. The README says so, and `generate-scope-clients-check`
+pins the behaviour.
+
+**Generation runs with the cwd at the workspace root.** The engine resolves a
+module's local dependency to a workspace-root-relative path and then reads it
+relative to `Workspace.cwd` (`ResolveDepToSource` in `core/modulesource.go`).
+With the cwd at the scope, as it is on entry to `generateScope`, a dependency
+`../../dep` of `mods/app` is looked up under `mods/app/../../dep` resolved from
+`mods/app` — the wrong place. Moving the cwd to the workspace root for the work
+and restoring the scope cwd on the result avoids it. The engine requires the
+restore in any case: it rejects a `generateScope` result whose cwd is not the
+scope.
+
+### Migrating a pre-1.0 module
+
+A module configured by `dagger.json` is migrated the first time it is generated.
+Its `dagger.json` is loaded through the builder, the resulting manifest is
+written as `dagger-module.toml`, and the `dagger.json` is then deleted. The two
+files never coexist, so they cannot disagree.
+
+The migration preserves the runtime the module already names. A module on the
+engine's builtin `java` runtime stays on it and does not silently move onto
+`github.com/dagger/java-sdk/runtime`; the seed manifest is used only when the
+scope has no manifest at all. `generate-scope-migrate-check` asserts exactly
+that: the `dagger-module.toml` it produces still carries `source = "java"`.
+
+### What survives from `mod.dang`
+
+`Mod` holds everything that is not part of the engine interface: the Maven
+codegen containers, the vendored SDK build, the entrypoint compilation, and the
+module-relative path arithmetic. None of it is touched by #13992 and all of it is
+kept.
+
+The changes needed there:
+
+- `Mod.generateModule` selects the removed `ModuleSource.generateLocalDependencies`
+ to stage local dependencies before reading the module's introspection schema.
+ The engine now generates scopes in dependency order itself, so the staging
+ step is removed rather than replaced.
+- `generateScope` must return a `Workspace`, not a `Changeset`. `Mod` gains
+ `generated: Workspace!` — the workspace with this module's generated files
+ merged in, mirroring python-sdk — and `generate: Changeset!` is deleted rather
+ than rewritten, because nothing else called it. `Mod.path` goes with it.
+- `Mod` took `ws` both as a constructor field and as an argument to `generate`.
+ The argument goes away, so `Mod` has one workspace.
+- `skipGenerateFilename` and `skipGenerate` are deleted with the skip marker.
+- The unused `defaultModulePath` and `cleanModulePath` helpers, both left over
+ from the beta init contract, are deleted.
+- The Maven repository cache volume is mounted `LOCKED` rather than shared.
+ Seeding it from the committed prebuilt repository is a plain recursive copy,
+ and two generations running at once race on it —
+ `cp: cannot create directory ...: File exists`. Generating two Java modules
+ concurrently is ordinary, so the mount serializes rather than the callers.
+
+`generateScope` constructs `Mod` directly from the scope the engine handed it.
+Nothing needs to read the registered scope list, so this SDK never selects
+`Workspace.sdk`. python-sdk does, because it keeps a public `mod` that resolves
+a module by path; the corresponding fragility — the lookup keys on the SDK's
+*install* name in `dagger.toml`, not on its SDK name — does not arise here.
+
+### Registration
+
+`dagger.toml` at the repository root gains:
+
+```toml
+[modules.java-sdk]
+source = "."
+check.skip = ["*"]
+
+[sdks.java]
+module = "java-sdk"
+```
+
+`[modules.java-sdk]` is required, not decorative: the engine rejects a
+`[sdks.]` entry whose `module` is not an installed module. `check.skip`
+keeps a released-engine `dagger check` from calling into a module that engine
+cannot serve.
+
+No fixture is registered as a scope. The end-to-end fixtures used to carry their
+own nested workspace config, `.dagger/modules/e2e/fixtures/dagger.toml`, and an
+intermediate revision of this change moved them into
+`[sdks.java.scopes.""]` blocks in the root `dagger.toml`. Both are gone.
+The checks build the scopes they drive in memory instead, so there is nothing
+for `dagger generate` to walk into and nothing to hold out of it.
+
+Two registrations go away:
+
+- `[modules.sdk-sdk]` and the checks it contributes. `github.com/dagger/sdk-sdk`
+ validates the beta contract this change removes: it asserts that `initModule`
+ seeds files without writing config, that `dagger sdk install` writes an
+ `as-sdk` marker, that `dagger module deps list` works. Every one of those
+ statements is false after this change. `dagger/python-sdk#25` dropped the same
+ dependency.
+- `[modules.e2e]`. The end-to-end module is no longer installed at all; see
+ "Two engines, two check sets".
+
+One registration deliberately stays: `[modules.dagger-dang-sdk.as-sdk]`, which
+registers this repository's own Dang modules (the root module and
+`.dagger/modules/templates`) with the Dang SDK.
+
+An earlier revision of this design removed it, reasoning that `as-sdk` is
+removed by #13992, that dang-sdk has not adopted the replacement
+(`dagger/dang-sdk#13` is open), and that silently ignored configuration is worse
+than none. That was wrong, and CI said so. dang-sdk's generator fails outright
+when it finds no registration — `current module is not installed as an SDK in
+this workspace` — rather than reporting an empty module set, so removing the
+table turns `dagger-dang-sdk:generate` red. The module-max engine ignores the
+table, because its config parser ignores unknown keys, so keeping it costs
+nothing there. It comes out when dang-sdk adopts the new interface and can be
+registered under `[sdks.dang]` instead.
+
+## Testing
+
+### What the released engine can and cannot do
+
+Every check in `.dagger/modules/e2e` calls into this SDK module, and this SDK
+module implements an interface the released engine `v1.0.0-beta.11` does not
+have. Those checks therefore run only in an engine built from
+`sdk-ux-module-max`.
+
+Which symbol stops it has moved as the branch moved, so it is worth naming what
+was measured rather than repeating an older cause. On `v1.0.0-beta.11`,
+`dagger call java-sdk find-client-root --ws .` fails with
+`field "withFile" not found in Dagger.Workspace`: `generateScope` writes the
+manifest through `Workspace.withFile` and removes a migrated `dagger.json`
+through `Workspace.withoutFile`, and that engine has neither. An earlier
+revision of this design named the engine's builtin `moduleManifest` instead;
+that symbol is no longer selected at all, because the builder moved to
+`sdk-helpers`, which the released engine loads happily.
+
+An earlier revision of this design gated them with `check.skip` instead, and
+measured that the gate was sufficient. On `v1.0.0-beta.11`, in a scratch
+workspace with two Dang modules where module `ok` depends on module `bad`, and
+`bad` has one function selecting a symbol the engine does not have:
+
+- the workspace loads and `dagger check` enumerates every check;
+- `ok:independent`, which does not touch `bad`, passes;
+- `ok:touches-bad`, which selects one unrelated field of `bad`, fails;
+- with `check.skip = ["*"]` on the module that owns a failing check, the run is
+ green.
+
+Two conclusions from that measurement still hold. A failing selection anywhere
+in a Dang module poisons every call into it, because Dang infers a whole program
+on each call. And `check.skip` suppresses only the `checks` resolver, so
+`dagger call` is unaffected by it — which is why `[modules.java-sdk]` can carry
+`check.skip = ["*"]` and still serve as the `java` SDK.
+
+The `e2e` module went further and is not installed in `dagger.toml` at all. An
+uninstalled module is never enumerated, so no skip list has to keep up with the
+checks it holds, and no future check can be added outside the gate by accident.
+`engine-e-2-e:sdk-contract-check` loads it by path instead.
+
+`dagger/python-sdk#25` had no equivalent gate. Its merged head
+(`c05426e0fbef5d758184667d62ddc406591b8192`) carries 19 commit statuses, of
+which 11 are red: every `e-2-e:*` check that calls the python-sdk module. It has
+one green development-engine check, `engine-e-2-e:dev-sdk-check`, an
+initialization smoke test. Its remaining new-interface checks were run by hand
+in a development engine and are not covered by CI at all.
+
+### Two engines, two check sets
+
+| Where | Engine | What it covers |
+| --- | --- | --- |
+| `packager:*`, `templates:generate`, `dagger-dang-sdk:generate` | released, `v1.0.0-beta.11` | the SDK library build, its unit tests, the prebuilt assets, the templates |
+| `engine-e-2-e:*` | built from `sdk-ux-module-max` at `8fd9b22b5416f8dc7cb420ba37769adef6e874d2` | the whole `findClientRoot` / `generateScope` contract |
+
+`.dagger/modules/engine-e2e` depends on
+`github.com/dagger/dagger/.dagger/modules/engine-dev`, builds the engine from
+the pinned commit, and runs it as a playground container with this checkout
+mounted inside.
+
+`engine-e-2-e:dev-sdk-check` is the deliverable, and mirrors python-sdk's:
+
+1. `dagger sdk list` reports `java`. This proves the registration parses; it
+ loads no module, so it proves nothing more.
+2. `dagger module init java --name … --path …` succeeds. This is the check that
+ proves the interface: it loads the SDK module, validates its function
+ signatures against `core/sdkmodule/provider.go`, and calls `generateScope`.
+ It then asserts the files that call produced — the `dagger-module.toml`, the
+ absence of a `dagger.json`, the `pom.xml`, the module class, the vendored
+ bindings, and the generated entrypoint — and that the manifest names
+ `github.com/dagger/java-sdk/runtime`.
+3. `dagger call` against the initialized module proves the generated module
+ builds with Maven and serves its API.
+
+`engine-e-2-e:sdk-contract-check` runs the end-to-end checks inside the same
+playground, as a single `dagger -m .dagger/modules/e2e check`. Running the whole
+module in one invocation, rather than a list of per-check `dagger call`
+invocations, means a check added to `.dagger/modules/e2e` is covered without
+touching `engine-e2e`. This is coverage `dagger/python-sdk#25` does not have. It
+is also the expensive part: the playground engine starts with cold Maven caches,
+and this SDK installs its jars under a per-module Maven version on purpose, so
+two scopes with different module names share no build.
+
+The end-to-end checks are:
+
+| Check | Asserts |
+| --- | --- |
+| `find-client-root-check` | the nearest `pom.xml` wins; a nested directory resolves to its module; a directory inside a module's vendored `sdk/` resolves to that module; a module of another SDK with no `pom.xml` gives null; the workspace root gives null |
+| `generate-scope-init-check` | a config-less scope gets the template, a `dagger-module.toml` naming this repository's runtime, the vendored bindings, and the entrypoint; no `dagger.json` is written; existing files survive; nothing is modified or removed; the cwd is unchanged; regenerating the result changes nothing |
+| `generate-scope-migrate-check` | a module whose manifest is a pre-1.0 `dagger.json` gets a `dagger-module.toml` carrying the runtime it already named, and the `dagger.json` is removed |
+| `generate-scope-clients-check` | a client is recorded as a dependency in the manifest and its type appears in the generated bindings; dropping the client removes it from both; a scope with no module is untouched; a standalone client raises |
+| `nullable-return-check` | a module function returning `Optional` registers an optional return type and unwraps the `Optional` before serialization |
+
+Those checks build their scopes in memory from the starter template rather than
+from committed stubs. With generation always on, a scope a check drives has to
+be a module Maven can build, and a config-only stub is not one. They also share
+a single module name wherever they need only *a* module, because a second name
+costs a second vendored SDK build.
+
+That cost was measured rather than guessed, on a developer machine with a warm
+outer engine. `engine-e-2-e:dev-sdk-check` passes against
+`8fd9b22b5416f8dc7cb420ba37769adef6e874d2` in 2m27s with warm caches; earlier
+cold runs of the same check took 5–8 minutes.
+`engine-e-2-e:sdk-contract-check` was last measured against an earlier pin and
+an earlier check set, at 7m16s on a first full pass and 3m29s once the vendored
+SDK build was cached; it has not been re-measured against the check set above.
+CI starts colder than any of these figures and will be slower; they bound the
+shape of the cost, not its exact value.
+
+Client handling stays inside Dang throughout: the checks call
+`javaSdk.generateScope(...)` and diff the result, and never go through
+`dagger module client add`. That CLI command is broken on `sdk-ux-module-max` —
+it loses the workspace overlay on reload and silently writes nothing, on every
+SDK — and the fault is in the CLI (`internal/cmd/dagger/module_sdk.go`), not in
+any SDK's `generateScope`. python-sdk's checks avoid it the same way.
+
+### Known gaps
+
+One behaviour ships unchecked, deliberately: `dependencySource`'s `GIT_SOURCE`
+arm. Recording a git client by its ref as is is not exercised by any check,
+because a git `ModuleSource` needs a real remote, which no check here can
+produce hermetically.
+
+An earlier revision of this design listed a second gap — no full generation of a
+pre-1.0 `dagger.json` module — because the fixture that drove the `loadJson`
+manifest branch sat under the skip marker and never reached Maven.
+`generate-scope-migrate-check` closes it: it generates a real module, replaces
+its manifest with a `dagger.json`, and generates again.
+
+### The engine pin
+
+Both the `engine-dev` dependency and the engine source name a commit of
+`sdk-ux-module-max`, so CI does not float with a branch that force-pushes.
+Following the branch means bumping both, plus `dagger.lock`.
+
+`github.com/dagger/sdk-helpers` is depended on without a pin, so it resolves to
+whatever its `main` is when the SDK module is loaded.
+
+## Tracking the branch
+
+`sdk-ux-module-max` was force-pushed twice while this change was being written,
+and each rewrite moved the interface under it. A reader should treat the pinned
+commit as a moment in time, not as the definition of the contract.
+
+**`78c241b6` → `7e6fc93c`.** Two renames, not a re-pin. `detectScope` became
+`findClientRoot`, and its result became nullable: the engine reads it through
+`dagql.Nullable` and treats an invalid result as absent, so the previous
+convention of reporting "no root here" with the empty string would have named a
+root at the workspace root. The manifest builder's dependency verbs became
+explicit about whose dependencies they are: `withDependency`,
+`withoutDependency`, and `withoutDependencies` became
+`withLegacyRuntimeDependency`, `withoutLegacyRuntimeDependency`, and
+`withoutLegacyRuntimeDependencies`, matching the `LegacyRuntimeDependencies`
+field they write. `generateScope`'s signature and the `[sdks..scopes]`
+config shape were unchanged.
+
+**`7e6fc93c` → `8fd9b22b`.** The manifest builder left the engine. `moduleManifest`
+is no longer a builtin; it lives in a separate Dang module,
+`github.com/dagger/sdk-helpers`, which every SDK adopting this interface now
+depends on. The builder's API is otherwise the same, including the rule that a
+non-builtin runtime is accepted only on a manifest loaded from a config file.
+`generateScope`'s signature and the `[sdks..scopes]` config shape were
+again unchanged.
+
+## Alternatives considered
+
+**Keep the SDK usable on the released engine.** `generateScope` could render
+`dagger-module.toml` as a string instead of going through the manifest builder,
+and the module would keep working on `v1.0.0-beta.11`. Rejected: replacing the
+complete dependency set of an *existing* manifest means parsing and rewriting
+TOML, which Dang cannot do and which would put manifest editing back into this
+repository. Seeding a manifest is a different matter — the seed for a *new*
+module is hand-rendered TOML, because the builder has no setter for this SDK's
+runtime — but a fixed three-key seed is not a TOML editor. The other three SDKs
+all take the builder.
+
+**Regenerate the manifest from scratch instead of loading it.** `dagger/dang-sdk#13`
+builds each manifest from a fresh builder and does not merge existing content,
+for deterministic output. Rejected here: a Java module's manifest can carry
+`include` paths and settings that this SDK did not write and has no business
+dropping.
+
+**Leave a pre-1.0 `dagger.json` in place next to the new `dagger-module.toml`.**
+Rejected: two manifest files for one module can disagree, and nothing would say
+which one wins. Migrating the contents across and deleting the `dagger.json`
+makes the module single-sourced again in one step.
+
+**Keep a skip marker for opting a module out of generation.** Rejected: its only
+consumer was the end-to-end fixture tree, and that tree is no longer registered
+as scopes, so the marker protected nothing while adding a second way for a
+module's generated state to be stale.
+
+**Migrate the Dang SDK registration to `[sdks.dang]` at the same time.** It
+would replace the `as-sdk` table this change keeps. Rejected: it names dang-sdk
+as the provider of an interface dang-sdk does not implement yet, so it would
+fail on the very engine it is meant to serve. Removing the table outright was
+tried and rejected too — see Registration.
+
+**Use `withLegacyJavaRuntime` and accept the engine's builtin `java` runtime.**
+Rejected: it would silently move every newly created module off this
+repository's runtime and onto the engine's, undoing the self-contained layout
+that is the point of this SDK.
+
+**Gate the end-to-end checks with `check.skip` rather than uninstalling the
+module.** Rejected: the module has no check that a released engine can run, so
+the skip list would always be `["*"]`, and an uninstalled module says the same
+thing without a list to maintain. This repository has no CI configuration of its
+own, so an explicit include list (`dagger check packager:* templates:*`) was not
+available either — the `dagger check` invocation is not ours to change.
+
+## Affected components
+
+| Path | Change |
+| --- | --- |
+| `main.dang`, `main.dang.tmpl` | `findClientRoot`, `generateScope`, `scopeManifest`, `seedManifestFile`, `dependencySource`; `initModule`, `targetRuntime`, `modules`, `generateAll`, `skipGenerateFilename` removed |
+| `mod.dang` | `generated: Workspace!`; local-dependency staging removed; single workspace field; skip-marker and dead init helpers removed; `LOCKED` Maven cache mount |
+| `dagger-module.toml`, `dagger.json` | the `github.com/dagger/sdk-helpers` dependency |
+| `dagger.toml` | `[modules.java-sdk]`, `[sdks.java]`, `[modules.engine-e2e]`; `[modules.sdk-sdk]` and `[modules.e2e]` removed, the dang-sdk `as-sdk` table kept |
+| `dagger.lock` | the `engine-dev` dependency closure |
+| `.dagger/modules/e2e/main.dang` | checks rewritten against the new interface, driving scopes built in memory |
+| `.dagger/modules/e2e/fixtures/**` | `lookup/app` (with `pom.xml` and `nested/`), `lookup/not-java`, `clients/dep`; the config-only stubs and the skip markers removed |
+| `.dagger/modules/engine-e2e/` | new: builds the branch engine and checks against it |
+| `README.md` | new command shapes, the module-scope model, the dependency migration step, the pre-1.0 migration |
+
+## Risks
+
+- **Existing modules lose their dependencies on the first generate.** Described
+ under "Clients become dependencies" above. Mitigated by a README migration
+ step and a check, not by code: re-deriving clients from an existing dependency
+ list is the engine's migration to make, not this SDK's.
+- **The branch moves.** `sdk-ux-module-max` force-pushes, twice already during
+ this work. The pin makes CI reproducible, but it also means the checks
+ validate a commit, not the branch head. A later engine change can break this
+ SDK without CI noticing until the pin is bumped.
+- **`sdk-helpers` moves too, and is not pinned.** The dependency records no
+ `pin`, and `dagger.lock` records no commit for it, so a change to its `main`
+ reaches this SDK without a commit here. Pinning it is the obvious mitigation
+ and is not done.
+- **Nested Java builds are slow.** The `engine-e2e` checks run Maven inside a
+ development engine inside the outer engine, with cold caches. Java code
+ generation is the heaviest operation this repository has. A check added later
+ that generates under a *new module name* pays for a whole vendored SDK build
+ of its own, because this SDK installs its jars under a per-module Maven
+ version on purpose; re-measure when one is added.
+- **The seed-manifest path depends on a validation detail.** Loading a config
+ file is what lets a non-builtin runtime through the builder's validation. If
+ `sdk-helpers` later rejects non-builtin runtimes outright, new Java modules can
+ no longer name `github.com/dagger/java-sdk/runtime`, and this SDK needs a
+ builder API for an arbitrary runtime source. That is worth raising on #13992
+ independently of this change.
+- **`dagger module client add` is broken on the branch.** Client handling is
+ therefore verified at the API level only. When the CLI is fixed, the
+ playground checks should drive it end to end.
+- **Standalone clients are refused.** A user who runs `dagger module client add`
+ from a directory that is not a Java module gets an error rather than a
+ generated client. This matches python-sdk, and is the honest answer while the
+ Java SDK has nowhere to put such a client.
+
+## What shipped
+
+Four commits on top of `be18cc2d64951628a79ae7da626ab2427b6a2436`, then two
+rounds of tracking the branch. Each commit leaves the tree in a state that
+loads, and none of them registers a module whose source does not yet exist.
+
+1. **`java-sdk: implement the module-max SDK interface`.** The interface cutover
+ is one commit because its parts cannot be separated: the moment `main.dang`
+ drops `initModule`, the end-to-end module that calls it stops compiling.
+ `main.dang` and `main.dang.tmpl` — kept identical apart from the template
+ placeholder — lose `targetRuntime`, `initModule`, `modules`, and
+ `generateAll`, and gain `findClientRoot`, `generateScope`, the
+ `template: String! = "default"` constructor setting that replaces
+ `initModule`'s `template` argument, and the private manifest helpers.
+ `mod.dang` gains `generated: Workspace!` and loses the local-dependency
+ staging, the duplicated workspace argument, and the dead init helpers. The
+ root `dagger.toml` gains `[modules.java-sdk]` and `[sdks.java]` and loses
+ `[modules.sdk-sdk]`. The end-to-end checks are rewritten against the new
+ interface.
+2. **`e2e: check the SDK against an engine built from sdk-ux-module-max`.** Adds
+ `.dagger/modules/engine-e2e`, its `[modules.engine-e2e]` registration, and the
+ regenerated `dagger.lock`.
+3. **`README: document the module-scope model`.** Rewrites the install, create,
+ generate, and client sections around `dagger module install`,
+ `dagger module init java --name … --path …`, and `dagger generate`, and states
+ the migration obligations.
+4. **`workspace: keep the dang-sdk as-sdk registration`.** Restores the
+ `[modules.dagger-dang-sdk.as-sdk]` table an earlier revision removed, after
+ `dagger-dang-sdk:generate` failed without it.
+
+Then, tracking `sdk-ux-module-max`:
+
+5. **`java-sdk: track sdk-ux-module-max after its force-push`.** Adopts
+ `findClientRoot` and its nullable result, and the renamed dependency verbs;
+ re-pins the engine to `7e6fc93c`.
+6. **The move to `8fd9b22b`.** Adopts the `github.com/dagger/sdk-helpers`
+ dependency in place of the removed engine builtin, always writes the manifest
+ and migrates a pre-1.0 `dagger.json`, deletes the skip marker and everything
+ that referenced it, replaces the hand-rolled path arithmetic with
+ `Path(...).relativeTo(...)`, uninstalls the `e2e` module in favour of
+ `dagger -m .dagger/modules/e2e check`, and rebuilds the fixtures and the check
+ set around always-on generation.
+
+Verification: `dagger check` on the released engine for the ungated checks,
+`engine-e-2-e:dev-sdk-check` and `engine-e-2-e:sdk-contract-check` against the
+pinned engine. This document moves to `hack/designs/done/` once that run is
+green, together with `hack/designs/2026-08-17-nullable-object-returns.md`, which
+is implemented but still reads as proposed and still describes verification
+through `generateAll` and `sdk-sdk:*` — both of which this change removes, and
+both of which `nullable-return-check` replaces.
diff --git a/main.dang b/main.dang
index 061cc6a..b432ef6 100644
--- a/main.dang
+++ b/main.dang
@@ -1,23 +1,18 @@
"""
Manage Dagger modules that use the Java SDK (new self-contained code organisation).
-Modules created by `init` use the self-contained layout: the Java SDK is vendored
-as source and code is generated into the module (see `generate`), and the module
-runtime is this repository's build/package-only runtime
+Modules created by this SDK use the self-contained layout: the Java SDK is
+vendored as source and code is generated into the module, and the module runtime
+is this repository's build/package-only runtime
(`github.com/dagger/java-sdk/runtime`). Because the generated files are committed,
the runtime skips codegen at module load and just builds and packages the
committed sources.
"""
type JavaSdk {
"""
- Runtime source written into the dagger-module.toml of new Java modules.
- """
- targetRuntime: String! { "github.com/dagger/java-sdk/runtime" }
-
- """
- Marker filename that skips generate when found at or above a Java SDK module root.
+ Starter under templates/ for a module generated without source: default*, empty, legacy
"""
- skipGenerateFilename: String! = ".dagger-java-sdk-skip-generate"
+ template: String! = "default"
"""
Commit the compiled Dagger Java SDK as a jar into each generated module so its
@@ -29,37 +24,173 @@ type JavaSdk {
vendorSdkJar: Boolean! = false
"""
- Initialize Java-owned files for a new Dagger module.
+ Runtime source written into the dagger-module.toml of new Java modules.
+ """
+ let targetRuntime: String! = "github.com/dagger/java-sdk/runtime"
+
+ """
+ The Java client root containing the workspace cwd: the directory of the
+ nearest pom.xml, relative to the workspace root. Every Java module has one at
+ its root, and the SDK vendored under a module carries none of its own, so the
+ nearest hit is always the owning module. Null when there is none — a project
+ built with anything but Maven is not a root this SDK can serve.
+ """
+ findClientRoot(ws: Workspace!): String {
+ let found = ws.findUp("pom.xml")
+ if (found == null) {
+ null
+ } else {
+ normalizePath(found.trimSuffix("pom.xml"))
+ }
+ }
+
+ """
+ Generate one SDK scope: the module at the workspace cwd, when the scope has
+ one. A scope with no config is scaffolded from the configured template first.
+ Every module then gets a dagger-module.toml from the manifest builder and is
+ generated; a pre-1.0 dagger.json is migrated into it and removed, so two
+ manifest files cannot disagree. The scope's module clients become the module's
+ dependencies, so the generated bindings include their types. Standalone
+ clients, in a scope without a module, are not generated yet.
"""
- initModule(
+ generateScope(
ws: Workspace!,
+ isModule: Boolean!,
name: String!,
- path: String!,
- """
- Template to use to init the module: default*, empty, legacy
- """
- template: String! = "default",
- ): Changeset! {
- let rawPath = path.trimPrefix("./").trimPrefix("/")
- let modPath = if (rawPath == "" or rawPath == ".") {
- "."
- } else if (rawPath == ".." or rawPath.trimPrefix("../") != rawPath) {
- raise "path escapes workspace: " + rawPath
+ clients: [ModuleSource!]!,
+ ): Workspace! {
+ if (isModule == false) {
+ if (clients.length > 0) {
+ raise "java-sdk does not generate standalone module clients yet"
+ } else {
+ ws
+ }
} else {
- rawPath.trimSuffix("/")
+ let scope = normalizePath(ws.cwd)
+ # The rest works with the cwd at the workspace root: the engine resolves a
+ # module's local dependencies to workspace-root-relative paths and then
+ # reads them relative to Workspace.cwd (dagger/dagger ResolveDepToSource),
+ # so at the scope's cwd a dependency is looked up under the module itself.
+ # The scope cwd is restored on the way out because the engine rejects a
+ # generateScope result whose cwd is not the scope.
+ let rooted = ws.withWorkdir(".")
+ let initialized = if (hasModuleConfig(ws, scope)) {
+ rooted
+ } else {
+ rooted.withDirectory(scopeRef(scope), moduleTemplate(name))
+ }
+ let configured = scopeManifest(initialized, scope, name, clients)
+ Mod(
+ rootPath: scope,
+ ws: configured,
+ vendorSdkJar: vendorSdkJar,
+ ).generated.withWorkdir(scope)
}
+ }
- let selectedTemplate = if (template == "") { "default" } else { template }
- # `path` is workspace-root-relative, so anchor it: relative workspace paths
- # resolve from ws.cwd and would be prefixed again when initModule is called
- # directly from a subdirectory instead of being driven by the engine.
- let target = if (modPath == ".") { "/" } else { "/" + modPath }
- # Init must never remove files already in the target directory.
- ws.withDirectory(target, renderedTemplate(name, selectedTemplate)).changes(ws)
+ """
+ Write the module's dagger-module.toml from the manifest it already has and the
+ complete client set, and drop a pre-1.0 dagger.json once its contents have
+ moved across. Fields this SDK does not own are carried over by loading the
+ existing manifest rather than building one from nothing.
+ """
+ let scopeManifest(
+ ws: Workspace!,
+ scope: String!,
+ name: String!,
+ clients: [ModuleSource!]!,
+ ): Workspace! {
+ let hasToml = scopeHasFile(ws, scope, "dagger-module.toml")
+ let hasJson = scopeHasFile(ws, scope, "dagger.json")
+ let base = if (hasToml) {
+ sdkHelpers.moduleManifest(loadToml: ws.file(scopeRef(scope, "dagger-module.toml")))
+ } else if (hasJson) {
+ sdkHelpers.moduleManifest(loadJson: ws.file(scopeRef(scope, "dagger.json")))
+ } else {
+ sdkHelpers.moduleManifest(loadToml: seedManifestFile(name))
+ }
+ # Cleared structurally rather than by name: withoutLegacyRuntimeDependency
+ # matches an unnamed dependency on its source, not on the module name it
+ # resolves to, and resolving the recorded dependencies to read their names
+ # would fail generation on one stale entry instead of dropping it.
+ let configured = clients.{{moduleName, kind, sourceRootSubpath, asString, pin}}
+ .reduce(base.withName(name: name).withoutLegacyRuntimeDependencies) { manifest, client =>
+ manifest.withLegacyRuntimeDependency(
+ source: dependencySource(scope, client.kind, client.sourceRootSubpath, client.asString),
+ name: client.moduleName,
+ pin: client.pin,
+ )
+ }
+ let written = ws.withFile(scopeRef(scope, "dagger-module.toml"), configured.tomlFile)
+
+ if (hasJson) {
+ written.withoutFile(scopeRef(scope, "dagger.json"))
+ } else {
+ written
+ }
}
"""
- Render a Java init template, substituting the requested module name.
+ The dagger-module.toml a new module starts from.
+
+ The builder has one runtime setter per builtin runtime, and this SDK targets
+ its own repository's runtime, so the runtime is named by loading a seed file:
+ the builder accepts a non-builtin runtime on a manifest loaded from a config
+ file and rejects it on one built from nothing. The caller loads this, so what
+ finally lands is the builder's own rendering, not these bytes.
+ """
+ let seedManifestFile(name: String!): File! {
+ let seed = "name = \"" + name + "\"\n"
+ + "engineVersion = \"" + engineVersion + "\"\n"
+ + "\n"
+ + "[runtime]\n"
+ + " source = \"" + targetRuntime + "\"\n"
+ directory.withNewFile("dagger-module.toml", seed).file("dagger-module.toml")
+ }
+
+ """
+ The live engine version, without the build metadata that changes on every
+ engine build.
+ """
+ let engineVersion: String! { version.split("+")[0] ?? version }
+
+ """
+ The dependency source a client is recorded as: a git ref as is, a local module
+ by its path relative to the module.
+ """
+ let dependencySource(
+ scope: String!,
+ kind: ModuleSourceKind!,
+ sourceRootSubpath: String!,
+ ref: String!,
+ ): String! {
+ if (kind == ModuleSourceKind.GIT_SOURCE) {
+ ref
+ } else {
+ let relative = Path(normalizePath(sourceRootSubpath)).relativeTo(Path(scope))
+ if (relative == null) {
+ raise "cannot resolve client module path " + sourceRootSubpath + " from scope " + scope
+ } else {
+ relative.string
+ }
+ }
+ }
+
+ """
+ The files of a new module: the configured template rendered for the module
+ name. An empty template name selects the default.
+ """
+ let moduleTemplate(name: String!): Directory! {
+ let selected = if (template == "") { "default" } else { template }
+ if (currentModule.source.exists("templates/" + selected) == false) {
+ raise "unknown template: " + template
+ } else {
+ renderedTemplate(name, selected)
+ }
+ }
+
+ """
+ Render a Java template, substituting the requested module name.
"""
let renderedTemplate(name: String!, template: String!): Directory! {
container
@@ -87,40 +218,33 @@ type JavaSdk {
}
"""
- Return every Java SDK module this workspace manages that is visible from the
- client's current location.
-
- The engine owns both the list and the cwd policy: currentModule.asSDK returns
- the modules registered to this SDK that sit at or below the client's cwd, plus
- the nearest enclosing one when the cwd is not itself a module. So running from
- a subdirectory acts on the project you're in — and the projects beneath it —
- not the whole workspace. Paths come back relative to the workspace root, the
- currency Mod.rootPath uses.
- """
- modules(ws: Workspace!): [Mod!]! {
- currentModule
- .asSDK(workspace: ws)
- .modules
- .{{path}}
- .map { module => Mod(
- rootPath: module.path,
- ws: ws,
- skipGenerateFilename: skipGenerateFilename,
- vendorSdkJar: vendorSdkJar,
- ) }
+ Whether a module config, dagger-module.toml or the pre-1.0 dagger.json, exists
+ at a workspace-root-relative scope.
+ """
+ let hasModuleConfig(ws: Workspace!, scope: String!): Boolean! {
+ scopeHasFile(ws, scope, "dagger-module.toml") or scopeHasFile(ws, scope, "dagger.json")
+ }
+
+ let scopeHasFile(ws: Workspace!, scope: String!, filename: String!): Boolean! {
+ let path = if (scope == ".") { filename } else { scope + "/" + filename }
+ ws.directory("/", include: [path]).exists(path)
}
"""
- Generate every managed Java SDK module visible from the client's current
- location (runs at `dagger generate`). Discovery goes through modules(ws), so
- running from a subdirectory generates only the project you're in and the
- projects beneath it.
+ A scope path, or a file under it, as a workspace-root-absolute path — the form
+ Workspace resolves from the workspace root rather than from the client's cwd.
"""
- generateAll(ws: Workspace!): Changeset! @generate {
- changeset.withChangesets(
- modules(ws)
- .filter { mod => mod.skipGenerate(ws) == false }
- .map { mod => mod.generate(ws) },
- )
+ let scopeRef(scope: String!, sub: String! = ""): String! {
+ let root = if (scope == ".") { "" } else { "/" + scope }
+ if (sub == "") {
+ if (root == "") { "/" } else { root }
+ } else {
+ root + "/" + sub
+ }
+ }
+
+ let normalizePath(path: String!): String! {
+ let normalized = path.trimPrefix("./").trimPrefix("/").trimSuffix("/")
+ if (normalized == "") { "." } else { normalized }
}
}
diff --git a/main.dang.tmpl b/main.dang.tmpl
index 0a14dde..ae08e50 100644
--- a/main.dang.tmpl
+++ b/main.dang.tmpl
@@ -1,23 +1,18 @@
"""
Manage Dagger modules that use the Java SDK (new self-contained code organisation).
-Modules created by `init` use the self-contained layout: the Java SDK is vendored
-as source and code is generated into the module (see `generate`), and the module
-runtime is this repository's build/package-only runtime
+Modules created by this SDK use the self-contained layout: the Java SDK is
+vendored as source and code is generated into the module, and the module runtime
+is this repository's build/package-only runtime
(`github.com/dagger/java-sdk/runtime`). Because the generated files are committed,
the runtime skips codegen at module load and just builds and packages the
committed sources.
"""
type JavaSdk {
"""
- Runtime source written into the dagger-module.toml of new Java modules.
- """
- targetRuntime: String! { "github.com/dagger/java-sdk/runtime" }
-
- """
- Marker filename that skips generate when found at or above a Java SDK module root.
+ Starter under templates/ for a module generated without source:
"""
- skipGenerateFilename: String! = ".dagger-java-sdk-skip-generate"
+ template: String! = "default"
"""
Commit the compiled Dagger Java SDK as a jar into each generated module so its
@@ -29,37 +24,173 @@ type JavaSdk {
vendorSdkJar: Boolean! = false
"""
- Initialize Java-owned files for a new Dagger module.
+ Runtime source written into the dagger-module.toml of new Java modules.
+ """
+ let targetRuntime: String! = "github.com/dagger/java-sdk/runtime"
+
+ """
+ The Java client root containing the workspace cwd: the directory of the
+ nearest pom.xml, relative to the workspace root. Every Java module has one at
+ its root, and the SDK vendored under a module carries none of its own, so the
+ nearest hit is always the owning module. Null when there is none — a project
+ built with anything but Maven is not a root this SDK can serve.
+ """
+ findClientRoot(ws: Workspace!): String {
+ let found = ws.findUp("pom.xml")
+ if (found == null) {
+ null
+ } else {
+ normalizePath(found.trimSuffix("pom.xml"))
+ }
+ }
+
+ """
+ Generate one SDK scope: the module at the workspace cwd, when the scope has
+ one. A scope with no config is scaffolded from the configured template first.
+ Every module then gets a dagger-module.toml from the manifest builder and is
+ generated; a pre-1.0 dagger.json is migrated into it and removed, so two
+ manifest files cannot disagree. The scope's module clients become the module's
+ dependencies, so the generated bindings include their types. Standalone
+ clients, in a scope without a module, are not generated yet.
"""
- initModule(
+ generateScope(
ws: Workspace!,
+ isModule: Boolean!,
name: String!,
- path: String!,
- """
- Template to use to init the module:
- """
- template: String! = "default",
- ): Changeset! {
- let rawPath = path.trimPrefix("./").trimPrefix("/")
- let modPath = if (rawPath == "" or rawPath == ".") {
- "."
- } else if (rawPath == ".." or rawPath.trimPrefix("../") != rawPath) {
- raise "path escapes workspace: " + rawPath
+ clients: [ModuleSource!]!,
+ ): Workspace! {
+ if (isModule == false) {
+ if (clients.length > 0) {
+ raise "java-sdk does not generate standalone module clients yet"
+ } else {
+ ws
+ }
} else {
- rawPath.trimSuffix("/")
+ let scope = normalizePath(ws.cwd)
+ # The rest works with the cwd at the workspace root: the engine resolves a
+ # module's local dependencies to workspace-root-relative paths and then
+ # reads them relative to Workspace.cwd (dagger/dagger ResolveDepToSource),
+ # so at the scope's cwd a dependency is looked up under the module itself.
+ # The scope cwd is restored on the way out because the engine rejects a
+ # generateScope result whose cwd is not the scope.
+ let rooted = ws.withWorkdir(".")
+ let initialized = if (hasModuleConfig(ws, scope)) {
+ rooted
+ } else {
+ rooted.withDirectory(scopeRef(scope), moduleTemplate(name))
+ }
+ let configured = scopeManifest(initialized, scope, name, clients)
+ Mod(
+ rootPath: scope,
+ ws: configured,
+ vendorSdkJar: vendorSdkJar,
+ ).generated.withWorkdir(scope)
}
+ }
- let selectedTemplate = if (template == "") { "default" } else { template }
- # `path` is workspace-root-relative, so anchor it: relative workspace paths
- # resolve from ws.cwd and would be prefixed again when initModule is called
- # directly from a subdirectory instead of being driven by the engine.
- let target = if (modPath == ".") { "/" } else { "/" + modPath }
- # Init must never remove files already in the target directory.
- ws.withDirectory(target, renderedTemplate(name, selectedTemplate)).changes(ws)
+ """
+ Write the module's dagger-module.toml from the manifest it already has and the
+ complete client set, and drop a pre-1.0 dagger.json once its contents have
+ moved across. Fields this SDK does not own are carried over by loading the
+ existing manifest rather than building one from nothing.
+ """
+ let scopeManifest(
+ ws: Workspace!,
+ scope: String!,
+ name: String!,
+ clients: [ModuleSource!]!,
+ ): Workspace! {
+ let hasToml = scopeHasFile(ws, scope, "dagger-module.toml")
+ let hasJson = scopeHasFile(ws, scope, "dagger.json")
+ let base = if (hasToml) {
+ sdkHelpers.moduleManifest(loadToml: ws.file(scopeRef(scope, "dagger-module.toml")))
+ } else if (hasJson) {
+ sdkHelpers.moduleManifest(loadJson: ws.file(scopeRef(scope, "dagger.json")))
+ } else {
+ sdkHelpers.moduleManifest(loadToml: seedManifestFile(name))
+ }
+ # Cleared structurally rather than by name: withoutLegacyRuntimeDependency
+ # matches an unnamed dependency on its source, not on the module name it
+ # resolves to, and resolving the recorded dependencies to read their names
+ # would fail generation on one stale entry instead of dropping it.
+ let configured = clients.{{moduleName, kind, sourceRootSubpath, asString, pin}}
+ .reduce(base.withName(name: name).withoutLegacyRuntimeDependencies) { manifest, client =>
+ manifest.withLegacyRuntimeDependency(
+ source: dependencySource(scope, client.kind, client.sourceRootSubpath, client.asString),
+ name: client.moduleName,
+ pin: client.pin,
+ )
+ }
+ let written = ws.withFile(scopeRef(scope, "dagger-module.toml"), configured.tomlFile)
+
+ if (hasJson) {
+ written.withoutFile(scopeRef(scope, "dagger.json"))
+ } else {
+ written
+ }
}
"""
- Render a Java init template, substituting the requested module name.
+ The dagger-module.toml a new module starts from.
+
+ The builder has one runtime setter per builtin runtime, and this SDK targets
+ its own repository's runtime, so the runtime is named by loading a seed file:
+ the builder accepts a non-builtin runtime on a manifest loaded from a config
+ file and rejects it on one built from nothing. The caller loads this, so what
+ finally lands is the builder's own rendering, not these bytes.
+ """
+ let seedManifestFile(name: String!): File! {
+ let seed = "name = \"" + name + "\"\n"
+ + "engineVersion = \"" + engineVersion + "\"\n"
+ + "\n"
+ + "[runtime]\n"
+ + " source = \"" + targetRuntime + "\"\n"
+ directory.withNewFile("dagger-module.toml", seed).file("dagger-module.toml")
+ }
+
+ """
+ The live engine version, without the build metadata that changes on every
+ engine build.
+ """
+ let engineVersion: String! { version.split("+")[0] ?? version }
+
+ """
+ The dependency source a client is recorded as: a git ref as is, a local module
+ by its path relative to the module.
+ """
+ let dependencySource(
+ scope: String!,
+ kind: ModuleSourceKind!,
+ sourceRootSubpath: String!,
+ ref: String!,
+ ): String! {
+ if (kind == ModuleSourceKind.GIT_SOURCE) {
+ ref
+ } else {
+ let relative = Path(normalizePath(sourceRootSubpath)).relativeTo(Path(scope))
+ if (relative == null) {
+ raise "cannot resolve client module path " + sourceRootSubpath + " from scope " + scope
+ } else {
+ relative.string
+ }
+ }
+ }
+
+ """
+ The files of a new module: the configured template rendered for the module
+ name. An empty template name selects the default.
+ """
+ let moduleTemplate(name: String!): Directory! {
+ let selected = if (template == "") { "default" } else { template }
+ if (currentModule.source.exists("templates/" + selected) == false) {
+ raise "unknown template: " + template
+ } else {
+ renderedTemplate(name, selected)
+ }
+ }
+
+ """
+ Render a Java template, substituting the requested module name.
"""
let renderedTemplate(name: String!, template: String!): Directory! {
container
@@ -87,40 +218,33 @@ type JavaSdk {
}
"""
- Return every Java SDK module this workspace manages that is visible from the
- client's current location.
-
- The engine owns both the list and the cwd policy: currentModule.asSDK returns
- the modules registered to this SDK that sit at or below the client's cwd, plus
- the nearest enclosing one when the cwd is not itself a module. So running from
- a subdirectory acts on the project you're in — and the projects beneath it —
- not the whole workspace. Paths come back relative to the workspace root, the
- currency Mod.rootPath uses.
- """
- modules(ws: Workspace!): [Mod!]! {
- currentModule
- .asSDK(workspace: ws)
- .modules
- .{{path}}
- .map { module => Mod(
- rootPath: module.path,
- ws: ws,
- skipGenerateFilename: skipGenerateFilename,
- vendorSdkJar: vendorSdkJar,
- ) }
+ Whether a module config, dagger-module.toml or the pre-1.0 dagger.json, exists
+ at a workspace-root-relative scope.
+ """
+ let hasModuleConfig(ws: Workspace!, scope: String!): Boolean! {
+ scopeHasFile(ws, scope, "dagger-module.toml") or scopeHasFile(ws, scope, "dagger.json")
+ }
+
+ let scopeHasFile(ws: Workspace!, scope: String!, filename: String!): Boolean! {
+ let path = if (scope == ".") { filename } else { scope + "/" + filename }
+ ws.directory("/", include: [path]).exists(path)
}
"""
- Generate every managed Java SDK module visible from the client's current
- location (runs at `dagger generate`). Discovery goes through modules(ws), so
- running from a subdirectory generates only the project you're in and the
- projects beneath it.
+ A scope path, or a file under it, as a workspace-root-absolute path — the form
+ Workspace resolves from the workspace root rather than from the client's cwd.
"""
- generateAll(ws: Workspace!): Changeset! @generate {
- changeset.withChangesets(
- modules(ws)
- .filter { mod => mod.skipGenerate(ws) == false }
- .map { mod => mod.generate(ws) },
- )
+ let scopeRef(scope: String!, sub: String! = ""): String! {
+ let root = if (scope == ".") { "" } else { "/" + scope }
+ if (sub == "") {
+ if (root == "") { "/" } else { root }
+ } else {
+ root + "/" + sub
+ }
+ }
+
+ let normalizePath(path: String!): String! {
+ let normalized = path.trimPrefix("./").trimPrefix("/").trimSuffix("/")
+ if (normalized == "") { "." } else { normalized }
}
}
diff --git a/mod.dang b/mod.dang
index 5d2da06..ddaf342 100644
--- a/mod.dang
+++ b/mod.dang
@@ -13,63 +13,20 @@ type Mod {
"""
let ws: Workspace!
- """
- Marker filename that skips generate when found at or above this module root.
- """
- let skipGenerateFilename: String!
-
"""
Whether to commit the compiled SDK jar into the module (see JavaSdk.vendorSdkJar).
"""
let vendorSdkJar: Boolean!
"""
- This module root relative to the client's current location: "." when the cwd is
- the module itself, "sub/mod" for a module beneath it, ".." for an enclosing one.
- A cwd-relative view of rootPath, so listings read the way a shell user would.
- """
- path: String! {
- let cwd = ws.cwd.trimPrefix("/").trimSuffix("/")
- if (rootPath == cwd) {
- "."
- } else if (cwd == "") {
- rootPath
- } else if (rootPath.trimPrefix(cwd + "/") != rootPath) {
- rootPath.trimPrefix(cwd + "/")
- } else if (rootPath == "." or cwd.trimPrefix(rootPath + "/") != cwd) {
- let rootDepth = if (rootPath == ".") { 0 } else { rootPath.split("/").length }
- cwd.split("/").dropFirst(rootDepth).map { segment => ".." }.join("/")
- } else {
- rootPath
- }
- }
-
- """
- Whether this module or an ancestor contains the configured generate skip marker.
- """
- skipGenerate(ws: Workspace!): Boolean! {
- hasMarker(ws, skipGenerateFilename)
- }
-
- """
- Whether this module root or an ancestor containes a marker filename.
- """
- let hasMarker(ws: Workspace!, filename: String!): Boolean! {
- let markerPath = ws.findUp(name: filename, from: rootPath)
- markerPath != null
- }
+ The shared local Maven repository.
+ Mounted LOCKED, not shared: seeding it from the committed prebuilt repository
+ is a plain recursive copy, and two generations running at once race on it —
+ `cp: cannot create directory ...: File exists`. Generating two Java modules
+ concurrently is ordinary, so the mount serializes rather than the callers.
"""
- Generate this module.
- If the generate skip marker is present, the changeset is empty.
- """
- generate(ws: Workspace!): Changeset! {
- if (skipGenerate(ws)) {
- changeset
- } else {
- generateModule(ws)
- }
- }
+ let mavenRepo: CacheVolume! { cacheVolume("sdk-java-maven-m2") }
"""
Maven container used for codegen (pinned digest, matches the builtin runtime).
@@ -106,7 +63,7 @@ type Mod {
let codegenBase(introspectionJSON: File!): Container! {
let base = mvn
.withoutEntrypoint
- .withMountedCache("/root/.m2", cacheVolume("sdk-java-maven-m2"))
+ .withMountedCache("/root/.m2", mavenRepo, sharing: CacheSharingMode.LOCKED)
.withMountedFile("/schema.json", introspectionJSON)
.withDirectory("/dagger-io", sdkSourceDir)
.withWorkdir("/dagger-io")
@@ -200,7 +157,7 @@ type Mod {
let generatedEntrypoint(moduleDir: Directory!, name: String!): Directory! {
mvn
.withoutEntrypoint
- .withMountedCache("/root/.m2", cacheVolume("sdk-java-maven-m2"))
+ .withMountedCache("/root/.m2", mavenRepo, sharing: CacheSharingMode.LOCKED)
.withDirectory("/module", moduleDir)
.withWorkdir("/module")
.withEnvVariable("_DAGGER_JAVA_SDK_MODULE_NAME", name)
@@ -209,24 +166,11 @@ type Mod {
}
"""
- Stage the vendored SDK + generated entrypoint for one module.
-
- The returned changeset is relative to the client's cwd, the form the engine
- applies a generator's result in: past v1.0.0-beta.10 every Workspace.changes
- result is measured from the workspace cwd, so staging onto the workspace value
- and diffing it against the baseline the caller already holds is all this needs.
- """
- let generateModule(ws: Workspace!): Changeset! {
- # A module that depends on another by local path cannot resolve its own
- # schema until that dependency has been generated, so overlay the generated
- # closure onto the workspace first. The engine skips remote dependencies —
- # assumed committed, as it does itself — and returns an empty changeset when
- # there is nothing to stage.
- let wsWithDeps = ws.withChanges(
- ws.moduleSource(workspaceRef(rootPath)).generateLocalDependencies(ws),
- )
-
- let modSource = wsWithDeps.moduleSource(workspaceRef(rootPath))
+ The workspace with this module's vendored SDK and generated entrypoint merged
+ in.
+ """
+ generated: Workspace! {
+ let modSource = ws.moduleSource(workspaceRef(rootPath))
let name = modSource.moduleName
let introspectionJSON = modSource.introspectionSchemaJSON
let vendored = vendoredSdk(introspectionJSON, name)
@@ -234,17 +178,17 @@ type Mod {
# the module as committed, with the whole committed sdk/ dropped (source and
# any previously vendored jar) and the freshly vendored SDK sources overlaid,
# plus any stale generated entrypoint dropped so the processor regenerates it
- let baseDir = moduleDir(wsWithDeps, rootPath)
+ let baseDir = moduleDir(ws, rootPath)
.withoutDirectory("sdk")
.withDirectory("sdk", vendored)
.withoutDirectory("src/generated")
let entrypoint = generatedEntrypoint(baseDir, name)
- let staged = wsWithDeps
+ let staged = ws
.withNewDirectory(workspaceRef(joinPath(rootPath, "sdk")), vendored)
.withNewDirectory(workspaceRef(joinPath(rootPath, "src/generated/java")), entrypoint)
- let after = if (vendorSdkJar) {
+ if (vendorSdkJar) {
staged.withNewDirectory(
workspaceRef(joinPath(rootPath, "sdk/repo")),
vendoredSdkJar(introspectionJSON, name),
@@ -252,10 +196,6 @@ type Mod {
} else {
staged
}
-
- # Baseline on the staged-dependency workspace, not the one handed in: the
- # dependencies' generated code belongs to their own SDKs, not this changeset.
- after.changes(wsWithDeps)
}
"""
@@ -290,34 +230,4 @@ type Mod {
let joinPath(modPathArg: String!, sub: String!): String! {
if (modPathArg == ".") { sub } else { modPathArg + "/" + sub }
}
-
- """
- Return the default new module path for `init`.
- """
- let defaultModulePath(ws: Workspace!, name: String!): String! {
- let daggerDir = ws.findUp(".dagger", ".")
- if (daggerDir == null) {
- ".dagger/modules/" + name
- } else {
- daggerDir.trimPrefix("/") + "/modules/" + name
- }
- }
-
- """
- Return a valid module path relative to the workspace.
- """
- let cleanModulePath(path: String!): String! {
- let rawPath = path.trimPrefix("./").trimPrefix("/")
-
- if (rawPath == "" or rawPath == ".") {
- "."
- } else if (rawPath == ".."
- or rawPath.trimPrefix("../") != rawPath
- or rawPath.contains("/../")
- or rawPath.trimSuffix("/..") != rawPath) {
- raise "path escapes workspace: " + rawPath
- } else {
- rawPath.trimSuffix("/")
- }
- }
}