From 6de3f75a4488efe5023616016479b26f042b303e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:35:42 -0500 Subject: [PATCH 1/2] fix(orchestration): name the agent behind a "requires host resolution" refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codefly deploy gitops render … --env blue` on a workspace that imports another by `source:` + `version:` fails with cannot bind interface dependencies: resources.ApplyInterfaceBindings: cannot load the workspace that binds the service's interfaces: LoadFromDir: workspace "platform-core" at obin-ai/platform-core@5a8d5ffa… requires host resolution and every operator has answered it with an uncommitted `path:` override on the import. The host is not what is missing. The CLI registers a workspace resolver on every command context and resolves the import through the module cache; the composition loads and binds the service; core's services.Load hands the agent that binding (ServiceIdentity.interface_bindings, core v0.19.8). The sentence comes from the AGENT process: an agent built on a core older than v0.19.8 ignores the field, loads the workspace at workspace_path itself, and cannot compose the import because it registers no resolver. The `go` agent every solution runs on is at core v0.19.5. The CLI now says so. When an agent's Load refusal carries core's sentence and this host did supply the binding, the error names the agent (the running selection, else the declared one), what it did instead of reading the binding, the release floor that reads it, and that a `path:` override hides this without fixing it; the agent's own error stays wrapped underneath. A service nothing bound — loaded outside its composition — keeps the agent's refusal as it stands, because binding against the workspace on disk is then the contract. Applied at the three places the orchestration loads an agent's builder or runtime. Tests lock both halves: the explained error for a bound service and the untouched error for an unbound one or any other failure; and, in pkg/composition, a workspace importing another by source and commit pin with the module cache populated loads through the resolver with no `path:` override, and the service it composes carries the binding the host hands its agent, while the same load without the resolver is core's refusal. Co-Authored-By: Claude Fable 5.1 --- docs/agent-compatibility.md | 18 ++++ pkg/composition/workspace_reference_test.go | 70 +++++++++++++++ pkg/orchestration/builder.go | 2 +- .../interface_binding_refusal.go | 51 +++++++++++ .../interface_binding_refusal_test.go | 88 +++++++++++++++++++ pkg/orchestration/local_runtime_image.go | 2 +- pkg/orchestration/runner.go | 1 + 7 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 pkg/composition/workspace_reference_test.go create mode 100644 pkg/orchestration/interface_binding_refusal.go create mode 100644 pkg/orchestration/interface_binding_refusal_test.go diff --git a/docs/agent-compatibility.md b/docs/agent-compatibility.md index d9f088d7e..57f79e865 100644 --- a/docs/agent-compatibility.md +++ b/docs/agent-compatibility.md @@ -123,6 +123,24 @@ response naming any other profile is refused, and the local-apply profile is the one documented exemption from restricted-rendering evidence, because it produces no promotable artifact and nothing persists it. +**An agent reads the interface binding the host supplies.** An agent loads +the service it serves by directory, and before core v0.19.8 it bound the +service's interface dependencies itself, against the workspace at the +identity's `workspace_path`. It cannot compose that workspace when it imports +another by `source:` and `version:`: only the host registers a workspace +resolver (every command context does), and the agent is another process, so it +fails with core's `requires host resolution` — a sentence about the host, from +a process that is not the host. Since core v0.19.8 the host hands the agent +the binding its composition made (`ServiceIdentity.interface_bindings`, filled +by core's `services.Load` from `Service.InterfaceBindingsProto` for every +service loaded through its workspace, in `run` and in a render alike), and the +agent applies it without loading the workspace. An agent built on an older +core ignores the field; when its refusal reaches the CLI for a service whose +binding the host did supply, the CLI names the agent and the release floor +(`pkg/orchestration/interface_binding_refusal.go`) rather than relaying the +host's sentence. An uncommitted `path:` override on the import makes such an +agent load, and fixes nothing: the agent's release is what is behind. + **A native run asks an agent nothing about what it started.** A `codefly run` of a composition whose environment declares a `host` delivers each module's signed manifest, which names the workloads that run as the module and diff --git a/pkg/composition/workspace_reference_test.go b/pkg/composition/workspace_reference_test.go new file mode 100644 index 000000000..bbc180daa --- /dev/null +++ b/pkg/composition/workspace_reference_test.go @@ -0,0 +1,70 @@ +package composition + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/cli/pkg/modulecache" + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" +) + +const importedPlatformCommit = "5a8d5ffa0d1e2c3b4a5968778695a4b3c2d1e0f9" + +// importingWorkspace writes a product workspace importing platform-core by +// source and commit pin, with platform-core already in the module cache at +// that pin — what a previous pull, or a module pin on the same source, leaves +// there. apps/web requires an interface platform/api implements. +func importingWorkspace(t *testing.T) string { + t.Helper() + cacheRoot := t.TempDir() + t.Setenv(modulecache.Env, cacheRoot) + core := filepath.Join(cacheRoot, "obin-ai", "platform-core", importedPlatformCommit) + product := t.TempDir() + agent := "agent:\n kind: codefly:service\n name: go\n version: 0.0.67\n publisher: codefly.dev\n" + files := map[string]string{ + filepath.Join(core, "workspace.codefly.yaml"): "name: platform-core\nlayout: modules\nmodules:\n - name: platform\n", + filepath.Join(core, "modules/platform/module.codefly.yaml"): "kind: module\nname: platform\nservices:\n - name: api\ninterface:\n endpoints:\n - service: api\n endpoint: grpc\n visibility: public\n implements:\n - example.dev/widgets@1.2.0\n", + filepath.Join(core, "modules/platform/services/api/service.codefly.yaml"): "kind: service\nname: api\nversion: 0.0.0\n" + agent + "endpoints:\n - name: grpc\n api: grpc\n", + filepath.Join(product, "workspace.codefly.yaml"): "name: product\nlayout: modules\nworkspaces:\n - name: platform-core\n source: obin-ai/platform-core\n version: " + importedPlatformCommit + "\nmodules:\n - name: apps\n", + filepath.Join(product, "modules/apps/module.codefly.yaml"): "kind: module\nname: apps\nservices:\n - name: web\n", + filepath.Join(product, "modules/apps/services/web/service.codefly.yaml"): "kind: service\nname: web\nversion: 0.0.0\n" + agent + "endpoints:\n - name: http\n api: http\nservice-dependencies:\n - interface: example.dev/widgets@^1.1\n", + } + for path, content := range files { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + } + return product +} + +// A `workspaces:` import naming a source and a commit pin resolves through the +// module cache a module pin uses, with no `path:` override, and the service +// loaded through that composition carries the binding the host hands its +// agent (ServiceIdentity.interface_bindings): the agent never has to compose +// the import itself. +func TestResolvedWorkspaceImportBindsTheServiceForItsAgent(t *testing.T) { + product := importingWorkspace(t) + ctx := WithWorkspaceResolver(context.Background()) + workspace, err := resources.LoadWorkspaceFromDir(ctx, product) + require.NoError(t, err) + web, err := workspace.LoadService(ctx, &resources.ServiceWithModule{Module: "apps", Name: "web"}) + require.NoError(t, err) + supplied := web.InterfaceBindingsProto() + require.NotNil(t, supplied, "the host's composition bound the import's provider") + require.Len(t, supplied.Bindings, 1) + require.Equal(t, "example.dev/widgets@^1.1", supplied.Bindings[0].Interface) + require.Equal(t, "platform", supplied.Bindings[0].Module) + require.Equal(t, "api", supplied.Bindings[0].Service) + require.Equal(t, "grpc", supplied.Bindings[0].Endpoint) +} + +// Without the resolver the import is genuinely unresolvable, and core's refusal +// stands: that is the sentence an agent process, which registers no resolver, +// reports when it loads the workspace itself. +func TestUnresolvedWorkspaceImportRequiresHostResolution(t *testing.T) { + product := importingWorkspace(t) + _, err := resources.LoadWorkspaceFromDir(context.Background(), product) + require.ErrorContains(t, err, `workspace "platform-core" at obin-ai/platform-core@`+importedPlatformCommit+" requires host resolution") +} diff --git a/pkg/orchestration/builder.go b/pkg/orchestration/builder.go index 1e15e578f..63d052c71 100644 --- a/pkg/orchestration/builder.go +++ b/pkg/orchestration/builder.go @@ -137,7 +137,7 @@ func (b *Builder) Load(ctx context.Context) (*OutputProperty, error) { } resp, err := b.instance.Builder.Load(ctx, options...) if err != nil { - return nil, w.Wrapf(err, "cannot load builder instance") + return nil, w.Wrapf(explainHostResolutionRefusal(err, b.instance), "cannot load builder instance") } w.Debug("loaded", wool.Field("endpoints", resources.MakeManyEndpointSummary(resp.Endpoints))) diff --git a/pkg/orchestration/interface_binding_refusal.go b/pkg/orchestration/interface_binding_refusal.go new file mode 100644 index 000000000..a8a729189 --- /dev/null +++ b/pkg/orchestration/interface_binding_refusal.go @@ -0,0 +1,51 @@ +package orchestration + +import ( + "fmt" + "strings" + + "github.com/codefly-dev/core/services" +) + +// An agent loads the service it serves by directory. Before core v0.19.8 it +// bound the service's interface dependencies itself, against the workspace at +// the identity's WorkspacePath — which it cannot compose when that workspace +// imports another by source and version: only the host registers a workspace +// resolver (cmd/common/context.go), and the agent is another process. Since +// core v0.19.8 the host hands the agent the binding its composition made +// (ServiceIdentity.interface_bindings, filled by core's services.Load from +// Service.InterfaceBindingsProto) and the agent never loads the workspace. +// +// An agent built on an older core ignores the field, loads the workspace, and +// fails with core's "requires host resolution" — a sentence about the host, +// from a process that is not the host, which has sent every operator to an +// uncommitted `path:` override on the import. The override does not fix the +// agent; it hides which one is behind. This names it. +const hostResolutionRefusal = "requires host resolution" + +// explainHostResolutionRefusal returns err as the agent reported it unless it +// is the refusal above for a service whose binding this host supplied, in +// which case the returned error names the agent, what it did instead of +// reading the binding, and the release that reads it. The agent's own error +// stays wrapped underneath. +func explainHostResolutionRefusal(err error, instance *services.Instance) error { + if err == nil || instance == nil || instance.Service == nil { + return err + } + if !strings.Contains(err.Error(), hostResolutionRefusal) { + return err + } + if instance.Service.InterfaceBindingsProto() == nil { + // Nothing bound this service — it was loaded outside its composition — + // so the agent was right to bind it against the workspace on disk. + return err + } + agent := "its agent" + switch { + case instance.Agent != nil && instance.Agent.Agent != nil: + agent = "agent " + instance.Agent.Agent.Identifier() + case instance.Service.Agent != nil: + agent = "agent " + instance.Service.Agent.Identifier() + } + return fmt.Errorf("%s binds %s's interface dependencies against the workspace on disk instead of reading the binding this host supplied (ServiceIdentity.interface_bindings), so it cannot compose a workspace imported by source and version; the agent was built on a core older than v0.19.8, which reads the binding. Upgrade the agent to a release built on core v0.19.8 or newer; a path: override on the import hides this without fixing it: %w", agent, instance.Unique(), err) +} diff --git a/pkg/orchestration/interface_binding_refusal_test.go b/pkg/orchestration/interface_binding_refusal_test.go new file mode 100644 index 000000000..2dfdf8869 --- /dev/null +++ b/pkg/orchestration/interface_binding_refusal_test.go @@ -0,0 +1,88 @@ +package orchestration + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/core/agents/services" + "github.com/codefly-dev/core/resources" + coreservices "github.com/codefly-dev/core/services" + "github.com/stretchr/testify/require" +) + +// bindingWorkspace writes a workspace where apps/web requires an interface +// platform/api implements, so a load through the workspace binds it. +func bindingWorkspace(t *testing.T) string { + t.Helper() + root := t.TempDir() + agent := "agent:\n kind: codefly:service\n name: go\n version: 0.0.67\n publisher: codefly.dev\n" + files := map[string]string{ + "workspace.codefly.yaml": "name: product\nlayout: modules\nmodules:\n - name: platform\n - name: apps\n", + "modules/platform/module.codefly.yaml": "kind: module\nname: platform\nservices:\n - name: api\ninterface:\n endpoints:\n - service: api\n endpoint: grpc\n visibility: public\n implements:\n - example.dev/widgets@1.2.0\n", + "modules/platform/services/api/service.codefly.yaml": "kind: service\nname: api\nversion: 0.0.0\n" + agent + "endpoints:\n - name: grpc\n api: grpc\n", + "modules/apps/module.codefly.yaml": "kind: module\nname: apps\nservices:\n - name: web\n", + "modules/apps/services/web/service.codefly.yaml": "kind: service\nname: web\nversion: 0.0.0\n" + agent + "endpoints:\n - name: http\n api: http\nservice-dependencies:\n - interface: example.dev/widgets@^1.1\n", + } + for name, content := range files { + path := filepath.Join(root, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + } + return root +} + +// The agent's refusal is core's sentence about the host. When this host did +// supply the binding, the error names the agent that ignored it and the +// release floor that reads it, and keeps the agent's own error underneath. +func TestExplainHostResolutionRefusalNamesTheAgentBehindTheBinding(t *testing.T) { + ctx := context.Background() + workspace, err := resources.LoadWorkspaceFromDir(ctx, bindingWorkspace(t)) + require.NoError(t, err) + bound, err := workspace.LoadService(ctx, &resources.ServiceWithModule{Module: "apps", Name: "web"}) + require.NoError(t, err) + require.NotNil(t, bound.InterfaceBindingsProto(), "the host's composition bound web's interface dependency") + identity, err := bound.Identity() + require.NoError(t, err) + instance := &coreservices.Instance{Service: bound, Identity: identity} + + agentErr := errors.New(`cannot bind interface dependencies: resources.ApplyInterfaceBindings: cannot load the workspace that binds the service's interfaces: LoadFromDir: workspace "platform-core" at obin-ai/platform-core@5a8d5ffa requires host resolution`) + explained := explainHostResolutionRefusal(agentErr, instance) + require.ErrorIs(t, explained, agentErr, "the agent's own error stays underneath") + require.ErrorContains(t, explained, "agent codefly.dev/go:0.0.67 binds apps/web's interface dependencies against the workspace on disk") + require.ErrorContains(t, explained, "core v0.19.8 or newer") + require.ErrorContains(t, explained, "path: override on the import hides this") + + // The running agent, when the selection resolved one, is the one named. + instance.Agent = &services.ServiceAgent{Agent: &resources.Agent{Kind: resources.ServiceAgent, Publisher: "codefly.dev", Name: "go", Version: "0.0.74"}} + require.ErrorContains(t, explainHostResolutionRefusal(agentErr, instance), "agent codefly.dev/go:0.0.74 binds") +} + +// A service loaded outside its composition has no binding from this host, so +// the agent binding it against the workspace on disk is the contract, and its +// refusal is reported as it stands. So is any other error. +func TestExplainHostResolutionRefusalLeavesOtherErrorsAlone(t *testing.T) { + ctx := context.Background() + root := bindingWorkspace(t) + unbound, err := resources.LoadServiceFromDir(ctx, filepath.Join(root, "modules/apps/services/web")) + require.NoError(t, err) + unbound.WithModule("apps") + require.Nil(t, unbound.InterfaceBindingsProto(), "loaded by directory: nothing bound it") + identity, err := unbound.Identity() + require.NoError(t, err) + instance := &coreservices.Instance{Service: unbound, Identity: identity} + + refusal := errors.New(`workspace "platform-core" at obin-ai/platform-core@5a8d5ffa requires host resolution`) + require.Same(t, refusal, explainHostResolutionRefusal(refusal, instance)) + + workspace, err := resources.LoadWorkspaceFromDir(ctx, root) + require.NoError(t, err) + bound, err := workspace.LoadService(ctx, &resources.ServiceWithModule{Module: "apps", Name: "web"}) + require.NoError(t, err) + other := errors.New("cannot load service configuration") + require.Same(t, other, explainHostResolutionRefusal(other, &coreservices.Instance{Service: bound, Identity: identity})) + require.NoError(t, explainHostResolutionRefusal(nil, instance)) + require.Same(t, refusal, explainHostResolutionRefusal(refusal, nil)) +} diff --git a/pkg/orchestration/local_runtime_image.go b/pkg/orchestration/local_runtime_image.go index cd5fcefd6..5f4829dbc 100644 --- a/pkg/orchestration/local_runtime_image.go +++ b/pkg/orchestration/local_runtime_image.go @@ -72,7 +72,7 @@ func (runner *Runner) localRuntimeImage(ctx context.Context) (string, error) { // not overwrite those runtime declarations in shared state. runner.instance.Builder.Workspace = runner.world.Workspace if _, err := runner.instance.Builder.Load(ctx); err != nil { - return "", err + return "", explainHostResolutionRefusal(err, runner.instance) } b, err := NewBuilder(ctx, runner.instance, runner.world) if err != nil { diff --git a/pkg/orchestration/runner.go b/pkg/orchestration/runner.go index 8fd5d2ad3..1867a2de8 100644 --- a/pkg/orchestration/runner.go +++ b/pkg/orchestration/runner.go @@ -194,6 +194,7 @@ func (runner *Runner) Load(ctx context.Context) (*OutputProperty, error) { if ContextCancelled(err) { return nil, nil } + err = explainHostResolutionRefusal(err, runner.instance) w.Warn(fmt.Sprintf("cannot load runtime instance for <%s> %v", runner.instance.Unique(), err)) if runner.outputPropertyForLoad.processed == nil { return nil, w.Wrapf(err, "cannot load runtime instance for %s", runner.instance.Unique()) From 66d8cc63e268d73d241ab5cae6adef7816ed547e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:45:27 -0500 Subject: [PATCH 2/2] fix(orchestration): the binding floor is core v0.19.7, the first tag carrying it 4ab16f55c (2026-10-25) is in v0.19.7 (2026-10-27); v0.19.6 predates it. Co-Authored-By: Claude Fable 5.1 --- docs/agent-compatibility.md | 4 ++-- pkg/orchestration/interface_binding_refusal.go | 6 +++--- pkg/orchestration/interface_binding_refusal_test.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/agent-compatibility.md b/docs/agent-compatibility.md index 57f79e865..fd4e6b3e6 100644 --- a/docs/agent-compatibility.md +++ b/docs/agent-compatibility.md @@ -124,13 +124,13 @@ the one documented exemption from restricted-rendering evidence, because it produces no promotable artifact and nothing persists it. **An agent reads the interface binding the host supplies.** An agent loads -the service it serves by directory, and before core v0.19.8 it bound the +the service it serves by directory, and before core v0.19.7 it bound the service's interface dependencies itself, against the workspace at the identity's `workspace_path`. It cannot compose that workspace when it imports another by `source:` and `version:`: only the host registers a workspace resolver (every command context does), and the agent is another process, so it fails with core's `requires host resolution` — a sentence about the host, from -a process that is not the host. Since core v0.19.8 the host hands the agent +a process that is not the host. Since core v0.19.7 the host hands the agent the binding its composition made (`ServiceIdentity.interface_bindings`, filled by core's `services.Load` from `Service.InterfaceBindingsProto` for every service loaded through its workspace, in `run` and in a render alike), and the diff --git a/pkg/orchestration/interface_binding_refusal.go b/pkg/orchestration/interface_binding_refusal.go index a8a729189..8339413ab 100644 --- a/pkg/orchestration/interface_binding_refusal.go +++ b/pkg/orchestration/interface_binding_refusal.go @@ -7,12 +7,12 @@ import ( "github.com/codefly-dev/core/services" ) -// An agent loads the service it serves by directory. Before core v0.19.8 it +// An agent loads the service it serves by directory. Before core v0.19.7 it // bound the service's interface dependencies itself, against the workspace at // the identity's WorkspacePath — which it cannot compose when that workspace // imports another by source and version: only the host registers a workspace // resolver (cmd/common/context.go), and the agent is another process. Since -// core v0.19.8 the host hands the agent the binding its composition made +// core v0.19.7 the host hands the agent the binding its composition made // (ServiceIdentity.interface_bindings, filled by core's services.Load from // Service.InterfaceBindingsProto) and the agent never loads the workspace. // @@ -47,5 +47,5 @@ func explainHostResolutionRefusal(err error, instance *services.Instance) error case instance.Service.Agent != nil: agent = "agent " + instance.Service.Agent.Identifier() } - return fmt.Errorf("%s binds %s's interface dependencies against the workspace on disk instead of reading the binding this host supplied (ServiceIdentity.interface_bindings), so it cannot compose a workspace imported by source and version; the agent was built on a core older than v0.19.8, which reads the binding. Upgrade the agent to a release built on core v0.19.8 or newer; a path: override on the import hides this without fixing it: %w", agent, instance.Unique(), err) + return fmt.Errorf("%s binds %s's interface dependencies against the workspace on disk instead of reading the binding this host supplied (ServiceIdentity.interface_bindings), so it cannot compose a workspace imported by source and version; the agent was built on a core older than v0.19.7, which reads the binding. Upgrade the agent to a release built on core v0.19.7 or newer; a path: override on the import hides this without fixing it: %w", agent, instance.Unique(), err) } diff --git a/pkg/orchestration/interface_binding_refusal_test.go b/pkg/orchestration/interface_binding_refusal_test.go index 2dfdf8869..1754c55b9 100644 --- a/pkg/orchestration/interface_binding_refusal_test.go +++ b/pkg/orchestration/interface_binding_refusal_test.go @@ -52,7 +52,7 @@ func TestExplainHostResolutionRefusalNamesTheAgentBehindTheBinding(t *testing.T) explained := explainHostResolutionRefusal(agentErr, instance) require.ErrorIs(t, explained, agentErr, "the agent's own error stays underneath") require.ErrorContains(t, explained, "agent codefly.dev/go:0.0.67 binds apps/web's interface dependencies against the workspace on disk") - require.ErrorContains(t, explained, "core v0.19.8 or newer") + require.ErrorContains(t, explained, "core v0.19.7 or newer") require.ErrorContains(t, explained, "path: override on the import hides this") // The running agent, when the selection resolved one, is the one named.