Builds on Stable Save/Load State for Dagger Objects
This design stops treating moduleTypes as a live SDK codegen problem.
Instead:
- the engine saves the initialized
Moduletypedef state to a checked-in JSON artifact duringgeneratedContextDirectory - module loading reuses that artifact on the next load
- the SDK
moduleTypeshook remains as a fallback for bootstrap and stale or missing artifacts
New artifact:
<module-source-subpath>/dagger.moduletypes.json
That file contains:
- a version number
- the content-scoped module source digest it was generated from
- the stable saved
Modulestate payload from__saveModuleState
With that file present and current, dagger functions no longer needs to run
SDK typedef codegen at runtime.
Today the engine has no portable representation of moduleTypes.
The current path is:
module load
-> SDK moduleTypes()
-> SDK-specific live introspection / codegen
-> JSON ModuleID
-> loadModuleFromID
For Go, that live typedef generation still happens in
(*goSDK).ModuleTypes, which runs
codegen generate-typedefs inside the SDK container.
That means:
dagger generatedoes not persist the typedef resultdagger functionsstill depends on live SDK work- the engine cannot trust a checked-in artifact
The missing piece is not “another generated Go entrypoint”.
It is “a portable, engine-readable saved Module state”.
Persist the initialized Module state to a tracked JSON file and short-circuit
future moduleTypes loads through that file.
New shape:
dagger generate / generatedContextDirectory
-> load module normally once
-> __saveModuleState(module.id)
-> write dagger.moduletypes.json
later module load
-> if dagger.moduletypes.json exists and digest matches:
__loadModuleFromState(state)
else:
SDK moduleTypes()
This keeps the current SDK contract as fallback, but removes live SDK work from the steady-state path.
New file:
<module-source-subpath>/dagger.moduletypes.json
The file format is:
type moduleTypesArtifactV1 struct {
ArtifactVersion int `json:"artifactVersion"`
ModuleSourceDigest string `json:"moduleSourceDigest"`
State json.RawMessage `json:"state"`
}Rules:
ArtifactVersionstarts at1.ModuleSourceDigestis the content-scoped digest already used during module load ininitializeSDKModule.Stateis the exact JSON string returned by__saveModuleState.
Example:
{
"artifactVersion": 1,
"moduleSourceDigest": "sha256:6f5b...",
"state": {
"type": "Module",
"version": 1,
"state": {
"description": "Test module",
"objects": [
{
"kind": "OBJECT_KIND",
"optional": false,
"asObject": {
"name": "Test",
"originalName": "Test"
}
}
]
}
}
}Write the artifact in runGeneratedContext.
That function already:
- runs SDK codegen
- generates clients
- writes
dagger.json - returns the generated context overlay
Extend it with one more step when the module has both a name and SDK:
- load the module through normal
asModule - call
__saveModuleState - wrap it in
moduleTypesArtifactV1 - write
dagger.moduletypes.jsoninto the generated context directory
Exact insertion point:
after runCodegen / client generation and before the final return from
runGeneratedContext.
Exact operations:
scopedSourceDigest := srcInst.Self().ContentScopedDigest()
var mod dagql.ObjectResult[*core.Module]
err = dag.Select(ctx, srcInst, &mod, dagql.Selector{Field: "asModule"})
var stateJSON string
err = dag.Select(ctx, dag.Root(), &stateJSON,
dagql.Selector{
Field: "__saveModuleState",
Args: []dagql.NamedInput{{
Name: "id",
Value: dagql.NewID[*core.Module](mod.ID()),
}},
},
)
artifactBytes, err := json.MarshalIndent(moduleTypesArtifactV1{
ArtifactVersion: 1,
ModuleSourceDigest: scopedSourceDigest,
State: json.RawMessage(stateJSON),
}, "", " ")
artifactBytes = append(artifactBytes, '\n')
artifactPath := filepath.Join(sourceSubpathOrRoot(srcInst.Self()), "dagger.moduletypes.json")Then write the file with withNewFile.
sourceSubpathOrRoot means:
SourceSubpathif non-empty- otherwise
SourceRootSubpath
The artifact belongs next to the module implementation, not next to the workspace root.
Add the fast path at the start of
runModuleDefInSDK, before calling
the SDK moduleTypes hook.
Current code:
typeDefsImpl, typeDefsEnabled := src.Self().SDKImpl.AsModuleTypes()
if typeDefsEnabled {
resultInst, err = typeDefsImpl.ModuleTypes(ctx, mod.Deps, srcInstContentHashed, mod.ResultID)
...
}Replace that with:
- try to read
dagger.moduletypes.jsonfrom the raw source context - if missing, keep the current path
- if present, decode and validate it
- if the digest matches
srcInstContentHashed.ID().Digest().String(), load the saved state through__loadModuleFromState - only call SDK
moduleTypeswhen the artifact is missing, malformed, or stale
Pseudo-code:
if typeDefsEnabled {
initialized, ok, err = s.tryLoadModuleStateArtifact(ctx, src, srcInstContentHashed)
if err != nil {
return nil, err
}
if !ok {
resultInst, err := typeDefsImpl.ModuleTypes(ctx, mod.Deps, srcInstContentHashed, mod.ResultID)
if err != nil {
return nil, fmt.Errorf("failed to initialize module: %w", err)
}
initialized = resultInst.Self()
}
}New helper in core/schema/modulesource.go:
func (s *moduleSourceSchema) tryLoadModuleStateArtifact(
ctx context.Context,
src dagql.ObjectResult[*core.ModuleSource],
srcInstContentHashed dagql.ObjectResult[*core.ModuleSource],
) (_ *core.Module, ok bool, _ error)Behavior:
ok=false, err=nilwhen the file does not existok=false, err=nilwhen the digest does not matchok=false, err=nilwhenartifactVersionis unsupportedok=false, err=nilwhenstate.type != "Module"orstate.versionis unsupportedok=false, err=nilwhen the file is invalid JSONok=trueonly when a valid current artifact was loaded
This keeps the fast path opportunistic and non-breaking.
Without a digest check, this would silently load stale typedefs after source edits.
That would be worse than the current behavior.
The engine already computes the right digest in
initializeSDKModule:
scopedSourceDigest := src.Self().ContentScopedDigest()
srcInstContentHashed := src.WithObjectDigest(digest.Digest(scopedSourceDigest))That is the digest the artifact must store and validate against.
None for the steady-state design.
The SDK contract stays exactly the same:
moduleTypes(
modSource: ModuleSource!
introspectionJSON: File!
outputFilePath: String!
): Container!Current implementation:
The engine simply stops calling it when a current saved artifact exists.
That means:
- no new SDK codegen target
- no new SDK entrypoint
- no new SDK helper binary
The existing hook is still required:
- for bootstrap, before the artifact exists
- when the artifact is stale
- for SDKs that do not use
generatedContextDirectory
The smaller baseWithCodegen change is still a valid stopgap.
But this design is better because it changes the steady-state cost model:
old steady state:
dagger functions -> SDK live typedef codegen
new steady state:
dagger functions -> read dagger.moduletypes.json
So this actually moves typedef generation out of runtime.
dagql/stable_state.godagql/server.gocore/module_state.gocore/schema/module.gocore/schema/modulesource.go
core/sdk/go_sdk.gostays unchanged in the final designcore/sdk/module_typedefs.gostays unchanged because the fast path happens before it is called
Add tests in three groups.
New integration test in core/integration/module_go_test.go:
generatedContextDirectorywritesdagger.moduletypes.json- the file contains
artifactVersion == 1 - the file contains the current content-scoped source digest
- the file contains a
Modulestate payload
New integration tests in core/integration/module_go_test.go:
- when
dagger.moduletypes.jsonis current,dagger functionsdoes not call the Go SDK live typedef path - when the file is missing, fallback works
- when the digest is stale, fallback works
- when the file is malformed JSON, fallback works
The fallback assertion should be implemented by mutating the artifact or by removing it from the context and observing that the module still loads.
Round-trip parity test:
- load a module through current SDK
moduleTypes - save its state to JSON
- load it back through
__loadModuleFromState - compare:
Description- object names
- function names
- argument defaults
- enum values
+check+generate- source maps
Compare semantic fields, not raw ModuleID values.
- land the generic internal state primitive with
Modulesupport - land artifact write support in
runGeneratedContext - land fast-path artifact load in
runModuleDefInSDK - keep SDK
moduleTypesfallback indefinitely
After that lands, the previous baseWithCodegen patch is no longer the target
design. It remains a useful experiment and fallback idea, but it is not the
preferred steady-state architecture.