From 166f8a83754cb730ad231c438cfa27e46d1352c2 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:07:10 -0400 Subject: [PATCH 1/2] feat(list): runnables carry the tool they are offered as, and each one's grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking which tools a solution gets, and noticing that a module version offers a new one, had all its parts and no way to read them: the catalog is contracts/runnables/index.json (which arrives inside a third-party module package, so a released module ships its own tool list), the answer is runnable-scope-selections keyed MODULE__OPERATION, and the judgement is SelectRunnableBindings. Nothing joined them, so "what tools does this module offer and which has this environment granted?" meant reading generated JSON by hand, and a new operation in a new module version was invisible. `codefly list runnables` now carries both halves of that question. - The owner's exposure, repeated and never completed: the model-facing name and the explicit effect. An operation with no exposure is listed without one — absent means not exposed, an unstated effect is not read-only — and `--tools` keeps only the exposed ones, which is what a composition can grant. - With `--env`, each derived operation carries the configuration key its authority is answered under and what that environment answered: `selected` (a binding is prepared), `declined` (answered [], granted nothing), or `needs-scopes` (its owner asks for scope slots nobody answered, which `generate runnable-bindings` refuses by name). `needs-scopes` is the piece that was missing: a module version that adds an operation adds a row nothing answers, so the new tool shows up as work to do rather than as a silent omission, and the listing names the key the grant is written under. No new state and no second storage format — the judgement is the generator's own rule, read through the environment the workspace already declares. An environment the workspace does not declare is an error rather than a listing whose grant column is quietly absent. docs/commands.md gains the flags and the grant vocabulary; the CLI reference is regenerated. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/list/runnables.go | 125 ++++++++++++++++++--- cmd/list/runnables_grants_test.go | 180 ++++++++++++++++++++++++++++++ docs/cli-reference.md | 20 ++++ docs/commands.md | 24 ++++ pkg/runnables/derived.go | 26 +++++ pkg/runnables/projection.go | 50 +++++++++ 6 files changed, 409 insertions(+), 16 deletions(-) create mode 100644 cmd/list/runnables_grants_test.go diff --git a/cmd/list/runnables.go b/cmd/list/runnables.go index ded2e8090..c19cad9ff 100644 --- a/cmd/list/runnables.go +++ b/cmd/list/runnables.go @@ -1,12 +1,15 @@ package list import ( + "context" "encoding/json" "fmt" "strings" "text/tabwriter" "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/cmd/generate" + "github.com/codefly-dev/cli/pkg/environments" runnablespkg "github.com/codefly-dev/cli/pkg/runnables" "github.com/codefly-dev/core/resources" "github.com/spf13/cobra" @@ -15,6 +18,8 @@ import ( var ( listRunnablesModule string listRunnablesJSON bool + listRunnablesEnv string + listRunnablesTools bool ) // RunnablesCmd lists the runnables declared in the current workspace. @@ -26,9 +31,27 @@ var RunnablesCmd = &cobra.Command{ A runnable is identified by module/name @ version: a version is an immutable release and several versions of one name coexist. +Every Runnable a module derives from its published contracts is listed beside +the ones it declares, with the tool it is offered as when its owner exposed one +— the model-facing name and the explicit effect. An operation with no exposure +is not a tool and is shown without one: absent means not exposed. + +With --env, each derived operation also carries the configuration key its +authority is answered under (` + "`runnable-scope-selections`" + ` in +workspace.codefly.yaml) and what that environment has answered: + + selected a binding is prepared: no slot to answer, or an answer resolves it + declined answered [] — installed for no resource, granted no authority + needs-scopes its owner asks for scope slots nobody has answered yet + +That last state is how a module version offering a new tool becomes visible: +the operation appears, nothing answers it, and generation refuses it by name +rather than preparing a binding with the question still open. + Examples: codefly list runnables codefly list runnables --module=backend + codefly list runnables --tools --env=staging codefly list runnables --json`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -57,11 +80,40 @@ func listRunnables(cmd *cobra.Command) error { return fmt.Errorf("cannot load modules: %w", err) } + // The environment is resolved once, before any module is read: a listing + // asked about an environment the workspace does not declare answers with + // that, rather than with rows whose grant column is silently absent. + var env *environments.Environment + if listRunnablesEnv != "" { + if env, err = environments.Select(workspace, listRunnablesEnv); err != nil { + return fmt.Errorf("cannot list the grants of environment %q: %w", listRunnablesEnv, err) + } + } + + entries, err := runnableEntries(ctx, modules, env) + if err != nil { + return err + } + + if listRunnablesJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(entries) + } + if len(entries) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No runnables in workspace <%s>\n", workspace.Name) + return nil + } + return writeRunnables(cmd, entries, env != nil) +} + +// runnableEntries is every runnable the modules hold: the ones they declare, +// and the operations they derive. With env, each derived row carries how that +// environment answers it. +func runnableEntries(ctx context.Context, modules []*resources.Module, env *environments.Environment) ([]runnablespkg.Identity, error) { entries := make([]runnablespkg.Identity, 0) for _, mod := range modules { - runnables, loadErr := mod.LoadRunnables(ctx) - if loadErr != nil { - return fmt.Errorf("cannot load runnables: %w", loadErr) + runnables, err := mod.LoadRunnables(ctx) + if err != nil { + return nil, fmt.Errorf("cannot load runnables: %w", err) } for _, r := range runnables { entries = append(entries, runnablespkg.NewIdentity(r)) @@ -69,34 +121,73 @@ func listRunnables(cmd *cobra.Command) error { // A module's derived operations are runnables it can be asked to // perform just as much as the ones it declares, so one listing // answers the question without the caller knowing which is which. - derived, derivedErr := runnablespkg.LoadDerivedOperations(mod.Dir()) - if derivedErr != nil { - return fmt.Errorf("cannot load the derived runnables of module %s: %w", mod.Name, derivedErr) + derived, err := runnablespkg.LoadDerivedOperations(mod.Dir()) + if err != nil { + return nil, fmt.Errorf("cannot load the derived runnables of module %s: %w", mod.Name, err) } for i := range derived { - entries = append(entries, derived[i].Identity()) + identity := derived[i].Identity() + if env != nil { + identity.Grant = grantOf(env, mod.Name, &derived[i]) + } + entries = append(entries, identity) } } - - if listRunnablesJSON { - return json.NewEncoder(cmd.OutOrStdout()).Encode(entries) + if !listRunnablesTools { + return entries, nil + } + tools := make([]runnablespkg.Identity, 0, len(entries)) + for i := range entries { + if entries[i].Tool != nil { + tools = append(tools, entries[i]) + } } + return tools, nil +} - if len(entries) == 0 { - fmt.Fprintf(cmd.OutOrStdout(), "No runnables in workspace <%s>\n", workspace.Name) - return nil +// grantOf is how env answers one derived operation, judged the way +// `generate runnable-bindings` judges it: an explicitly empty list declines, +// an owner's unanswered scope slot is refused rather than skipped, and +// everything else prepares a binding. +func grantOf(env *environments.Environment, module string, operation *runnablespkg.Derived) *runnablespkg.Grant { + key := generate.RunnableBindingKey(module, operation.Entry.Name) + grant := &runnablespkg.Grant{Key: key, State: runnablespkg.GrantSelected, Slots: operation.Slots()} + switch { + case env.DeclinesRunnableBinding(key): + grant.State = runnablespkg.GrantDeclined + case len(grant.Slots) > 0 && len(env.RunnableScopeSelections[key]) == 0: + grant.State = runnablespkg.GrantNeedsScopes } + return grant +} +func writeRunnables(cmd *cobra.Command, entries []runnablespkg.Identity, grants bool) error { w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "MODULE\tNAME\tVERSION\tAGENT\tFACILITIES\tTIMEOUT\tSOURCE") + header := "MODULE\tNAME\tVERSION\tAGENT\tFACILITIES\tTIMEOUT\tSOURCE\tTOOL\tEFFECT" + if grants { + header += "\tKEY\tGRANT" + } + fmt.Fprintln(w, header) for index := range entries { e := &entries[index] source := e.Source if source == "" { source = "declared" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - e.Module, e.Name, e.Version, e.Agent, strings.Join(e.Execution.Facilities, ","), e.Execution.Timeout, source) + tool, effect := "", "" + if e.Tool != nil { + tool, effect = e.Tool.Name, e.Tool.Effect + } + row := fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s", + e.Module, e.Name, e.Version, e.Agent, strings.Join(e.Execution.Facilities, ","), e.Execution.Timeout, source, tool, effect) + if grants { + key, state := "", "" + if e.Grant != nil { + key, state = e.Grant.Key, e.Grant.State + } + row += "\t" + key + "\t" + state + } + fmt.Fprintln(w, row) } return w.Flush() } @@ -104,4 +195,6 @@ func listRunnables(cmd *cobra.Command) error { func init() { RunnablesCmd.Flags().StringVar(&listRunnablesModule, "module", "", "Module to list runnables from") RunnablesCmd.Flags().BoolVar(&listRunnablesJSON, "json", false, "Emit machine-readable JSON") + RunnablesCmd.Flags().StringVar(&listRunnablesEnv, "env", "", "Environment whose runnable-scope-selections to report as each operation's grant") + RunnablesCmd.Flags().BoolVar(&listRunnablesTools, "tools", false, "List only the operations their owner exposes as a tool") } diff --git a/cmd/list/runnables_grants_test.go b/cmd/list/runnables_grants_test.go new file mode 100644 index 000000000..fd7904a7e --- /dev/null +++ b/cmd/list/runnables_grants_test.go @@ -0,0 +1,180 @@ +package list + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + runnablespkg "github.com/codefly-dev/cli/pkg/runnables" + "github.com/codefly-dev/cli/pkg/runnables/runnablestest" + "github.com/stretchr/testify/require" +) + +// toolKey is the configuration key the fixture operation's authority is +// answered under: the module and the derived name, as +// generate.RunnableBindingKey spells them. +const toolKey = "DOCUMENTS__RUNTIME_WORKER_GRPC_APPLY_TEXT" + +// writeWorkspaceWithAToolAndSlot lays out a module holding one derived +// operation that its owner exposes as a tool and whose authority it cannot +// spell alone: one required scope slot the composition must answer. selections +// is the environment's runnable-scope-selections block, indented to sit under +// the environment, or empty for an environment that answers nothing. +func writeWorkspaceWithAToolAndSlot(t *testing.T, selections string) string { + t.Helper() + dir := t.TempDir() + workspace := "name: test-workspace\nlayout: modules\nmodules:\n - name: documents\nenvironments:\n - name: staging\n" + selections + require.NoError(t, os.WriteFile(filepath.Join(dir, "workspace.codefly.yaml"), []byte(workspace), 0o644)) + + moduleDir := filepath.Join(dir, "modules", "documents") + require.NoError(t, os.MkdirAll(moduleDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "module.codefly.yaml"), + []byte("kind: module\nname: documents\n"), 0o644)) + runnablestest.Write(t, moduleDir, "test-workspace", "documents", "runtime-worker-grpc-apply-text") + + // The shared fixture writes an operation with no exposure and no slot, + // which is the common case; this one is the case a composition has to + // answer, so its operation document is rewritten rather than the helper + // changed for every other test. + operation := map[string]any{ + "method": runnablestest.Method, + "attempt_timeout": "10s", + "total_timeout": "1m0s", + "max_attempts": 1, + "backoff": "1s", + "audience": "documents.ingestion", + "invoke_scopes": []map[string]any{{"resource_kind": "documents", "actions": []string{"ingest", "read"}}}, + "lookup_scopes": []map[string]any{{"resource_kind": "documents", "actions": []string{"read"}}}, + "lookup_method": "/documents.ingest.v1.IngestionService/LookupText", + "completion": "COMPLETION_CALL", + "tool": map[string]any{"name": "apply_text", "description": "Apply text to a document.", "effect": "EFFECT_MUTATION"}, + "required_scope_slots": []map[string]any{{ + "name": "collection", "required_actions": []string{"write"}, + }}, + } + document, err := json.MarshalIndent(operation, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile( + filepath.Join(moduleDir, "contracts", "runnables", "runtime-worker", "grpc", "ApplyText", runnablespkg.OperationFileName), + append(document, '\n'), 0o644)) + return dir +} + +// listOneDerived runs the listing over the fixture and returns its single +// derived row. Reading the JSON rather than the table is deliberate: the row a +// console or a picker consumes is the one under test. +func listOneDerived(t *testing.T, dir string) runnablespkg.Identity { + t.Helper() + t.Chdir(dir) + t.Cleanup(func() { + listRunnablesJSON, listRunnablesModule, listRunnablesEnv, listRunnablesTools = false, "", "", false + }) + listRunnablesJSON, listRunnablesEnv = true, "staging" + cmd, buf := newListTestCmd() + require.NoError(t, listRunnables(cmd)) + + var entries []runnablespkg.Identity + require.NoError(t, json.Unmarshal(buf.Bytes(), &entries)) + require.Len(t, entries, 1, "the fixture module derives exactly one operation") + return entries[0] +} + +// A listing is what a composition picks its tools from, so it carries the +// owner's exposure as the owner declared it: the model-facing name and the +// explicit effect. Nothing is defaulted — an operation with no exposure is not +// a tool. +func TestAListingCarriesTheToolItsOwnerExposed(t *testing.T) { + entry := listOneDerived(t, writeWorkspaceWithAToolAndSlot(t, "")) + + require.NotNil(t, entry.Tool, "an exposed operation is listed as the tool it is") + require.Equal(t, "apply_text", entry.Tool.Name) + require.Equal(t, "EFFECT_MUTATION", entry.Tool.Effect, "the effect is the owner's, never inferred") + require.Equal(t, "Apply text to a document.", entry.Tool.Description) +} + +// An operation nobody has answered is the signal that matters on a version +// bump: a module that adds an operation adds a row its environment has not +// granted, and `generate runnable-bindings` refuses it by name rather than +// preparing a binding with the owner's question still open. +func TestAnUnansweredSlotIsReportedAsNeedingScopes(t *testing.T) { + entry := listOneDerived(t, writeWorkspaceWithAToolAndSlot(t, "")) + + require.NotNil(t, entry.Grant) + require.Equal(t, toolKey, entry.Grant.Key, "the key is where the answer is written") + require.Equal(t, runnablespkg.GrantNeedsScopes, entry.Grant.State) + require.Equal(t, []string{"collection"}, entry.Grant.Slots, "the open question is named") +} + +// An environment that answers the slot grants the tool. +func TestAnAnsweredSlotIsReportedAsSelected(t *testing.T) { + answered := " runnable-scope-selections:\n" + + " " + toolKey + ":\n" + + " - slot: collection\n" + + " invoke:\n" + + " - resource_kind: documents.collection\n" + + " actions: [write]\n" + + " resource_ids: [acme]\n" + entry := listOneDerived(t, writeWorkspaceWithAToolAndSlot(t, answered)) + + require.NotNil(t, entry.Grant) + require.Equal(t, runnablespkg.GrantSelected, entry.Grant.State) +} + +// An environment that declines it with an empty list grants nothing, and the +// listing says so rather than showing a tool the composition will not install. +func TestADeclinedOperationIsReportedAsDeclined(t *testing.T) { + declined := " runnable-scope-selections:\n " + toolKey + ": []\n" + entry := listOneDerived(t, writeWorkspaceWithAToolAndSlot(t, declined)) + + require.NotNil(t, entry.Grant) + require.Equal(t, runnablespkg.GrantDeclined, entry.Grant.State) +} + +// Without --env there is no grant to report: a listing never implies an answer +// no environment gave. +func TestWithoutAnEnvironmentNoGrantIsReported(t *testing.T) { + dir := writeWorkspaceWithAToolAndSlot(t, "") + t.Chdir(dir) + t.Cleanup(func() { + listRunnablesJSON, listRunnablesModule, listRunnablesEnv, listRunnablesTools = false, "", "", false + }) + listRunnablesJSON = true + cmd, buf := newListTestCmd() + require.NoError(t, listRunnables(cmd)) + + var entries []runnablespkg.Identity + require.NoError(t, json.Unmarshal(buf.Bytes(), &entries)) + require.Len(t, entries, 1) + require.Nil(t, entries[0].Grant) + require.NotNil(t, entries[0].Tool, "the exposure is the owner's fact and does not depend on an environment") +} + +// An environment the workspace does not declare is an error, not a listing +// whose grant column is quietly absent. +func TestAnUndeclaredEnvironmentIsRefused(t *testing.T) { + t.Chdir(writeWorkspaceWithAToolAndSlot(t, "")) + t.Cleanup(func() { + listRunnablesJSON, listRunnablesModule, listRunnablesEnv, listRunnablesTools = false, "", "", false + }) + listRunnablesJSON, listRunnablesEnv = true, "production" + cmd, _ := newListTestCmd() + require.ErrorContains(t, listRunnables(cmd), "production") +} + +// --tools lists only what a composition can actually grant as a tool, so a +// picker is not offered operations their owner never exposed. +func TestOnlyExposedOperationsAreListedAsTools(t *testing.T) { + dir := writeWorkspaceWithADerivedOperation(t) // the shared fixture: no exposure + t.Chdir(dir) + t.Cleanup(func() { + listRunnablesJSON, listRunnablesModule, listRunnablesEnv, listRunnablesTools = false, "", "", false + }) + listRunnablesJSON, listRunnablesTools = true, true + cmd, buf := newListTestCmd() + require.NoError(t, listRunnables(cmd)) + + var entries []runnablespkg.Identity + require.NoError(t, json.Unmarshal(buf.Bytes(), &entries)) + require.Empty(t, entries, "an operation with no exposure is not a tool, and an authored runnable is not one either") +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 45bdc9f40..97c4b28c7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -3370,9 +3370,27 @@ List the runnables (typed finite operations) declared in the workspace. A runnable is identified by module/name @ version: a version is an immutable release and several versions of one name coexist. +Every Runnable a module derives from its published contracts is listed beside +the ones it declares, with the tool it is offered as when its owner exposed one +— the model-facing name and the explicit effect. An operation with no exposure +is not a tool and is shown without one: absent means not exposed. + +With --env, each derived operation also carries the configuration key its +authority is answered under (`runnable-scope-selections` in +workspace.codefly.yaml) and what that environment has answered: + + selected a binding is prepared: no slot to answer, or an answer resolves it + declined answered [] — installed for no resource, granted no authority + needs-scopes its owner asks for scope slots nobody has answered yet + +That last state is how a module version offering a new tool becomes visible: +the operation appears, nothing answers it, and generation refuses it by name +rather than preparing a binding with the question still open. + Examples: codefly list runnables codefly list runnables --module=backend + codefly list runnables --tools --env=staging codefly list runnables --json ``` @@ -3383,8 +3401,10 @@ codefly list runnables [flags] Flags: ``` + --env string Environment whose runnable-scope-selections to report as each operation's grant --json Emit machine-readable JSON --module string Module to list runnables from + --tools List only the operations their owner exposes as a tool ``` ## `codefly login` diff --git a/docs/commands.md b/docs/commands.md index 42dd9b0c4..8db964a88 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2648,8 +2648,32 @@ codefly list module # List modules (alias: application) codefly list libraries # List workspace libraries (--remote for published versions, --json for machine output) codefly list jobs # List jobs codefly list runnables # List runnables (--module to scope, --json for machine output) +codefly list runnables --tools --env=staging # Only the tools, with each one's grant ``` +Every Runnable a module derives from its published contracts is listed beside +the ones it declares, carrying the tool its owner exposed it as — the +model-facing name and the explicit effect (`codefly.runnable.v0.ToolExposure`). +An operation with no exposure is listed without one: absent means not exposed, +and an unstated effect is not read-only. `--tools` keeps only the exposed ones, +which is what a composition can grant as a tool. + +`--env ` adds the configuration key each derived operation's authority is +answered under — the `runnable-scope-selections` key in +`workspace.codefly.yaml`, see [Generate runnable-bindings](#generate-runnable-bindings) +— and what that environment has answered: + +| grant | meaning | +| --- | --- | +| `selected` | a binding is prepared: the operation asks for no scope slot, or the environment's answer resolves the ones it asks for | +| `declined` | answered `[]`: installed for no resource, granted no authority, offered as no tool | +| `needs-scopes` | its owner asks for scope slots nobody has answered; `generate runnable-bindings` refuses it by name rather than preparing a binding with the question still open | + +`needs-scopes` is how a module version that offers a new tool becomes visible: +the operation appears in the listing, nothing answers it, and the answer is +written under the key the listing names. An environment the workspace does not +declare is an error, not a listing whose grant column is quietly absent. + See [Runnables](runnable.md) for what a runnable is and which parts of its lifecycle the CLI implements today. diff --git a/pkg/runnables/derived.go b/pkg/runnables/derived.go index 2ea281717..a6c937589 100644 --- a/pkg/runnables/derived.go +++ b/pkg/runnables/derived.go @@ -333,9 +333,35 @@ func (d *Derived) Identity() Identity { Protocol: d.Package.GetContract().GetProtocol(), Execution: newPackageExecution(d.Package.GetExecution()), Source: d.Entry.Source(), + Tool: d.ToolExposure(), } } +// ToolExposure is how this operation is offered to a model, or nil when it is +// no tool. The owner's own declaration is repeated, never completed: an +// operation with no exposure is not a tool, and one whose effect is unstated +// is not read-only, so neither is given a default here. +func (d *Derived) ToolExposure() *Tool { + if d.Operation == nil || d.Operation.Tool == nil { + return nil + } + tool := d.Operation.Tool + return &Tool{Name: tool.GetName(), Description: tool.GetDescription(), Effect: tool.GetEffect().String()} +} + +// Slots names the scope slots the owner asks a composition to answer. They are +// the questions a grant resolves, and an operation with none needs no answer. +func (d *Derived) Slots() []string { + if d.Operation == nil { + return nil + } + slots := make([]string, 0, len(d.Operation.RequiredScopeSlots)) + for _, slot := range d.Operation.RequiredScopeSlots { + slots = append(slots, slot.GetName()) + } + return slots +} + func newPackageExecution(execution *basev0.RunnableExecution) Execution { facilities := make([]string, 0, len(execution.GetFacilities())) for _, facility := range execution.GetFacilities() { diff --git a/pkg/runnables/projection.go b/pkg/runnables/projection.go index c81c12b81..dcae2da63 100644 --- a/pkg/runnables/projection.go +++ b/pkg/runnables/projection.go @@ -39,6 +39,13 @@ type Identity struct { // which is the distinction a reader needs: a derived row is regenerated // from a contract, not edited. Source string `json:"source,omitempty"` + // Tool is the exposure the owner declared, nil when the operation is no + // tool. Absent means not exposed: that is the owner's fail-closed default + // and this listing repeats it rather than inventing one. + Tool *Tool `json:"tool,omitempty"` + // Grant is how one environment answers this operation, set only when a + // listing was asked about an environment. + Grant *Grant `json:"grant,omitempty"` } // NewIdentity projects a loaded runnable. The runnable must have come from @@ -73,3 +80,46 @@ func NewExecution(execution *resources.RunnableExecution) Execution { MaxOutputBytes: execution.MaxOutputBytes(), } } + +// Tool is how one operation is offered to a model, as the owner declared it +// (codefly.runnable.v0.ToolExposure). It is what a composition picks from: a +// name, a bounded description and an explicit effect, because an operation +// with no exposure is not a tool and an unstated effect is not read-only. +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + // Effect is EFFECT_READ_ONLY or EFFECT_MUTATION, spelled as the enum is. + Effect string `json:"effect"` +} + +// Grant is what one environment has answered about one derived operation: the +// configuration key the answer is written under, whether a binding is prepared +// for it, and — when it is not — which of the owner's scope slots are still +// open. A module version that adds an operation adds an unanswered row, which +// is how "this version offers a tool nobody granted" becomes visible without +// comparing two releases by hand. +type Grant struct { + // Key is the runnable-bindings key, which is also the + // runnable-scope-selections key an answer is written under. + Key string `json:"key"` + // State is `selected`, `declined`, or `needs-scopes`. + State string `json:"state"` + // Slots names the owner's required scope slots, when the operation + // declares any: the questions an answer must resolve. + Slots []string `json:"slots,omitempty"` +} + +// Grant states, which are the whole vocabulary a listing reports. +const ( + // GrantSelected is an operation this environment prepares a binding for: + // either it needs no slot answered, or its answer resolves them. + GrantSelected = "selected" + // GrantDeclined is an operation answered with an empty list: installed + // for no resource, granted no authority, offered as no tool. + GrantDeclined = "declined" + // GrantNeedsScopes is an operation whose owner asks for scope slots this + // environment has not answered. Generation refuses it by name rather than + // preparing a binding with the question still open, so an unanswered slot + // is work to do, not a quiet omission. + GrantNeedsScopes = "needs-scopes" +) From e0c357206ba60ca7fa0b9ae7a413ec8891bb1b59 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:30:27 -0400 Subject: [PATCH 2/2] fix(list): a grant is what generation would accept, not whether an answer exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r2 found the GRANT column agreeing with a generator whose rules it only partly implemented. Three shapes of one defect, all of them reporting a grant on configurations `generate runnable-bindings` refuses — wrong in the one place an operator looks to check their work, which is worse than having no column. **A present but insufficient selection read `selected`.** The state came from `len(selections) == 0`, so any non-empty answer was a grant. The real judgement is core's ResolveScopeSlots, which refuses a slot left unselected, a slot named that the operation does not declare, a selection with no invoke scope, an invoke scope missing an action the owner requires, and a missing lookup scope where the slot asks for one. An operation declaring `collection` and `source` in an environment answering only `collection` reported as granted while the run failed. The listing now builds the operation's spec and resolves the selection exactly as the generator does, and reports `invalid` carrying core's own reason. **An illegal decline read `declined`.** generate's declinableOperation refuses `[]` against an operation with no required scope slot — "there is nothing to decline; remove the entry" — so that an empty list typed against the wrong key cannot hide. That rule is now applied where the state is computed. **An answer naming no operation was invisible.** The listing walked the catalogue only, so a mistyped key — the likeliest mistake with a hand-typed key — produced an answer to no question while its operation still read as unanswered, and nothing pointed at the typo. Those keys are now reported beside the rows, as `unanswered_keys` in --json. The shared vocabulary moved to where both callers can hold one spelling: pkg/runnables now owns BindingKey, Operation.Spec (was cmd/generate's operationSpec, which took nothing from the endpoint descriptor but the method spelling) and GrantOf, with cmd/generate.RunnableBindingKey a thin alias for its own call sites. cmd/list no longer imports cmd/generate — a sibling command package was the wrong home for a spelling the listing must match exactly, and linking it ran five unrelated init()s. Two output contracts restored: - The tool columns appear only with --tools or --env, so the default table is byte-identical to before and anything parsing it keeps working. - `tool` and `grant` live on the listing's own row type instead of the shared Identity, so `show runnable --json` no longer states the same exposure twice (once promoted, once under `operation`). show is untouched again. - With --env, --json emits an object (runnables, unanswered_keys); without it, the array it has always emitted. Four new tests cover what the first shape got wrong: a partially answered operation, a selection naming an undeclared slot, a decline of an operation with nothing to decline, and an answer that names no operation. The first three fail when the resolve and the decline rule are reverted. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/generate/runnable_bindings.go | 79 +--------- cmd/generate/runnable_scopes_test.go | 2 +- cmd/list/runnables.go | 172 ++++++++++++++-------- cmd/list/runnables_grants_test.go | 164 ++++++++++++++++++++- docs/cli-reference.md | 21 +-- docs/commands.md | 28 ++-- pkg/runnables/derived.go | 1 - pkg/runnables/grant.go | 206 +++++++++++++++++++++++++++ pkg/runnables/projection.go | 39 ----- 9 files changed, 517 insertions(+), 195 deletions(-) create mode 100644 pkg/runnables/grant.go diff --git a/cmd/generate/runnable_bindings.go b/cmd/generate/runnable_bindings.go index f6a376a08..ec6715f14 100644 --- a/cmd/generate/runnable_bindings.go +++ b/cmd/generate/runnable_bindings.go @@ -10,7 +10,6 @@ import ( "slices" "sort" "strings" - "time" "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" @@ -447,19 +446,12 @@ func SelectRunnableBindings(ctx context.Context, workspace *resources.Workspace, return selection, nil } -// RunnableBindingKey is the configuration key an operation is bound under: -// its module and its derived name, upper-cased with every other character an -// underscore, so the SDK's workspace-value lookup finds it. +// RunnableBindingKey is the configuration key an operation is bound under. +// The spelling belongs to pkg/runnables, because the listing that reports what +// an environment has answered must key by exactly what this writes; this stays +// as the name generation's own call sites and tests read by. func RunnableBindingKey(module, name string) string { - var key strings.Builder - for _, r := range strings.ToUpper(module + "__" + name) { - if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - key.WriteRune(r) - continue - } - key.WriteByte('_') - } - return key.String() + return runnablespkg.BindingKey(module, name) } // prepareRunnable prepares one derived operation for the environment the @@ -486,7 +478,7 @@ func prepareRunnable(ctx context.Context, module *resources.Module, operation *r if kind == composition.APIContractKindOpenAPI { codes = corerunnable.HTTPStatusCodes } - spec, err := operationSpec(operation.Operation, descriptor.GetOperation(), codes) + spec, err := operation.Operation.Spec(descriptor.GetOperation(), codes) if err != nil { return nil, err } @@ -511,65 +503,6 @@ func prepareRunnable(ctx context.Context, module *resources.Module, operation *r // operationSpec reads the derived document back into Core's spec so Core can // resolve its slots once and write the resulting policy with Policy(). The // document round-trip test holds both conversions to every Operation field. -func operationSpec(declared *runnablespkg.Operation, spelling string, codes corerunnable.CodeVocabulary) (*corerunnable.OperationSpec, error) { - if declared == nil { - return nil, errors.New("the operation declares no execution policy") - } - durations := map[string]time.Duration{} - for field, spelled := range map[string]string{ - "attempt_timeout": declared.AttemptTimeout, - "total_timeout": declared.TotalTimeout, - "backoff": declared.Backoff, - } { - parsed, err := time.ParseDuration(spelled) - if err != nil { - return nil, fmt.Errorf("%s %q is not a duration: %w", field, spelled, err) - } - durations[field] = parsed - } - completion, named := basev0.RunnableExecution_Completion_value[declared.Completion] - if !named || basev0.RunnableExecution_Completion(completion) == basev0.RunnableExecution_COMPLETION_UNKNOWN { - return nil, fmt.Errorf("completion %q is not a completion mode; %s and %s are the two", - declared.Completion, basev0.RunnableExecution_COMPLETION_CALL, basev0.RunnableExecution_COMPLETION_SUBMIT) - } - var recovery basev0.RunnableExecution_Recovery - if declared.Recovery != "" { - value, known := basev0.RunnableExecution_Recovery_value[declared.Recovery] - if !known || basev0.RunnableExecution_Recovery(value) == basev0.RunnableExecution_RECOVERY_UNKNOWN { - return nil, fmt.Errorf("recovery %q is not a recovery; %s and %s are the two", - declared.Recovery, basev0.RunnableExecution_RECOVERY_RECOMPUTE, basev0.RunnableExecution_RECOVERY_RECEIPT) - } - recovery = basev0.RunnableExecution_Recovery(value) - } - return &corerunnable.OperationSpec{ - Method: spelling, - Codes: codes, - AttemptTimeout: durations["attempt_timeout"], - TotalTimeout: durations["total_timeout"], - MaxAttempts: declared.MaxAttempts, - Backoff: durations["backoff"], - RetryableCodes: declared.RetryableCodes, - Audience: declared.Audience, - InvokeScopes: preparedScopes(declared.InvokeScopes), - LookupScopes: preparedScopes(declared.LookupScopes), - LookupMethod: declared.LookupMethod, - MaxInputBytes: declared.MaxInputBytes, - MaxOutputBytes: declared.MaxOutputBytes, - Completion: basev0.RunnableExecution_Completion(completion), - Tool: declared.Tool, - RequiredScopeSlots: declared.RequiredScopeSlots, - Recovery: recovery, - }, nil -} - -func preparedScopes(declared []runnablespkg.Scope) []*basev0.WorkScopeV1 { - scopes := make([]*basev0.WorkScopeV1, 0, len(declared)) - for _, scope := range declared { - scopes = append(scopes, &basev0.WorkScopeV1{ResourceKind: scope.ResourceKind, Actions: scope.Actions, ResourceIds: scope.ResourceIDs}) - } - return scopes -} - // verifyPublishedContract holds the owner's published contract to the bytes the // API contract catalog recorded for it. A prepared value states the digest of // the bounded contract derived from it, so a catalog that no longer describes diff --git a/cmd/generate/runnable_scopes_test.go b/cmd/generate/runnable_scopes_test.go index dfbe1abca..878a28964 100644 --- a/cmd/generate/runnable_scopes_test.go +++ b/cmd/generate/runnable_scopes_test.go @@ -69,7 +69,7 @@ func TestOperationDocumentRoundTripsEveryCoreField(t *testing.T) { require.Contains(t, fields, string(field.Name()), "carry every Core field through the document") } require.Len(t, fields, coreFields.Len()+1, "only method is document-specific") - spec, err := operationSpec(document, document.Method, corerunnable.GRPCStatusNames) + spec, err := document.Spec(document.Method, corerunnable.GRPCStatusNames) require.NoError(t, err) require.True(t, proto.Equal(declared, spec.Policy()), "declared %v; read %v", declared, spec.Policy()) // Check the Go shape too, so an omitempty zero cannot hide a new field. diff --git a/cmd/list/runnables.go b/cmd/list/runnables.go index c19cad9ff..74542efbc 100644 --- a/cmd/list/runnables.go +++ b/cmd/list/runnables.go @@ -4,11 +4,11 @@ import ( "context" "encoding/json" "fmt" + "sort" "strings" "text/tabwriter" "github.com/codefly-dev/cli/cmd/common" - "github.com/codefly-dev/cli/cmd/generate" "github.com/codefly-dev/cli/pkg/environments" runnablespkg "github.com/codefly-dev/cli/pkg/runnables" "github.com/codefly-dev/core/resources" @@ -32,21 +32,24 @@ A runnable is identified by module/name @ version: a version is an immutable release and several versions of one name coexist. Every Runnable a module derives from its published contracts is listed beside -the ones it declares, with the tool it is offered as when its owner exposed one -— the model-facing name and the explicit effect. An operation with no exposure -is not a tool and is shown without one: absent means not exposed. +the ones it declares. With --tools or --env, each row also carries the tool its +owner exposed it as — the model-facing name and the explicit effect. An +operation with no exposure is not a tool and is shown without one: absent means +not exposed. --tools keeps only the exposed ones. With --env, each derived operation also carries the configuration key its authority is answered under (` + "`runnable-scope-selections`" + ` in -workspace.codefly.yaml) and what that environment has answered: +workspace.codefly.yaml) and what that environment has answered, judged by the +rules ` + "`generate runnable-bindings`" + ` judges it by: - selected a binding is prepared: no slot to answer, or an answer resolves it + selected its answer resolves every slot the owner declared, or it declares none declined answered [] — installed for no resource, granted no authority - needs-scopes its owner asks for scope slots nobody has answered yet + needs-scopes its owner asks for scope slots nobody has answered + invalid generation refuses this answer, and the row says why -That last state is how a module version offering a new tool becomes visible: -the operation appears, nothing answers it, and generation refuses it by name -rather than preparing a binding with the question still open. +An answer naming no operation at all is reported beside the rows rather than +dropped, because a mistyped key answers no question and leaves its operation +reading as unanswered. Examples: codefly list runnables @@ -59,6 +62,33 @@ Examples: }, } +// runnableRow is one listed runnable. The tool and the grant live here rather +// than on the shared projection because they are this listing's question: a +// field on runnablespkg.Identity would also appear in `show runnable`, whose +// report already states the operation's own exposure. +type runnableRow struct { + runnablespkg.Identity + // Tool is the exposure the owner declared, nil when the operation is no + // tool. Absent means not exposed: that is the owner's fail-closed default + // and this listing repeats it rather than inventing one. + Tool *runnablespkg.Tool `json:"tool,omitempty"` + // Grant is how the environment answers this operation, set only when a + // listing was asked about one. + Grant *runnablespkg.Grant `json:"grant,omitempty"` +} + +// runnableListing is what a listing asked about an environment answers with: +// the rows, and the answers that match no operation. Without --env there is no +// environment to have answered anything, and the listing stays the array it +// has always been. +type runnableListing struct { + Runnables []runnableRow `json:"runnables"` + // UnansweredKeys are the runnable-scope-selections keys of this + // environment that no listed operation binds. Generation refuses each by + // name; a listing that only walked the catalogue would never show them. + UnansweredKeys []string `json:"unanswered_keys,omitempty"` +} + func listRunnables(cmd *cobra.Command) error { ctx, done := common.NewContext() defer done() @@ -90,33 +120,37 @@ func listRunnables(cmd *cobra.Command) error { } } - entries, err := runnableEntries(ctx, modules, env) + listing, err := runnableListingOf(ctx, modules, env) if err != nil { return err } if listRunnablesJSON { - return json.NewEncoder(cmd.OutOrStdout()).Encode(entries) + if env == nil { + return json.NewEncoder(cmd.OutOrStdout()).Encode(listing.Runnables) + } + return json.NewEncoder(cmd.OutOrStdout()).Encode(listing) } - if len(entries) == 0 { + if len(listing.Runnables) == 0 && len(listing.UnansweredKeys) == 0 { fmt.Fprintf(cmd.OutOrStdout(), "No runnables in workspace <%s>\n", workspace.Name) return nil } - return writeRunnables(cmd, entries, env != nil) + return writeRunnables(cmd, listing, env) } -// runnableEntries is every runnable the modules hold: the ones they declare, -// and the operations they derive. With env, each derived row carries how that -// environment answers it. -func runnableEntries(ctx context.Context, modules []*resources.Module, env *environments.Environment) ([]runnablespkg.Identity, error) { - entries := make([]runnablespkg.Identity, 0) +// runnableListingOf is every runnable the modules hold — the ones they declare +// and the operations they derive — and, when an environment was named, how it +// answers each derived operation and which of its answers match nothing. +func runnableListingOf(ctx context.Context, modules []*resources.Module, env *environments.Environment) (*runnableListing, error) { + listing := &runnableListing{Runnables: make([]runnableRow, 0)} + bound := map[string]bool{} for _, mod := range modules { runnables, err := mod.LoadRunnables(ctx) if err != nil { return nil, fmt.Errorf("cannot load runnables: %w", err) } for _, r := range runnables { - entries = append(entries, runnablespkg.NewIdentity(r)) + listing.Runnables = append(listing.Runnables, runnableRow{Identity: runnablespkg.NewIdentity(r)}) } // A module's derived operations are runnables it can be asked to // perform just as much as the ones it declares, so one listing @@ -126,61 +160,68 @@ func runnableEntries(ctx context.Context, modules []*resources.Module, env *envi return nil, fmt.Errorf("cannot load the derived runnables of module %s: %w", mod.Name, err) } for i := range derived { - identity := derived[i].Identity() + row := runnableRow{Identity: derived[i].Identity(), Tool: derived[i].ToolExposure()} if env != nil { - identity.Grant = grantOf(env, mod.Name, &derived[i]) + // ScopeSelections is core's own ScopeSelection messages: the + // environment decoded them, so the rules are applied to what + // an installation would carry, not to a re-parse of the YAML. + selections, answered := env.RunnableScopeSelections[runnablespkg.BindingKey(mod.Name, derived[i].Entry.Name)] + row.Grant = runnablespkg.GrantOf(mod.Name, &derived[i], selections, answered) + bound[row.Grant.Key] = true } - entries = append(entries, identity) + listing.Runnables = append(listing.Runnables, row) } } + if env != nil { + answers := make([]string, 0, len(env.RunnableScopeSelections)) + for key := range env.RunnableScopeSelections { + answers = append(answers, key) + } + sort.Strings(answers) + listing.UnansweredKeys = runnablespkg.UnansweredKeys(answers, bound) + } if !listRunnablesTools { - return entries, nil + return listing, nil } - tools := make([]runnablespkg.Identity, 0, len(entries)) - for i := range entries { - if entries[i].Tool != nil { - tools = append(tools, entries[i]) + tools := make([]runnableRow, 0, len(listing.Runnables)) + for i := range listing.Runnables { + if listing.Runnables[i].Tool != nil { + tools = append(tools, listing.Runnables[i]) } } - return tools, nil -} - -// grantOf is how env answers one derived operation, judged the way -// `generate runnable-bindings` judges it: an explicitly empty list declines, -// an owner's unanswered scope slot is refused rather than skipped, and -// everything else prepares a binding. -func grantOf(env *environments.Environment, module string, operation *runnablespkg.Derived) *runnablespkg.Grant { - key := generate.RunnableBindingKey(module, operation.Entry.Name) - grant := &runnablespkg.Grant{Key: key, State: runnablespkg.GrantSelected, Slots: operation.Slots()} - switch { - case env.DeclinesRunnableBinding(key): - grant.State = runnablespkg.GrantDeclined - case len(grant.Slots) > 0 && len(env.RunnableScopeSelections[key]) == 0: - grant.State = runnablespkg.GrantNeedsScopes - } - return grant + listing.Runnables = tools + return listing, nil } -func writeRunnables(cmd *cobra.Command, entries []runnablespkg.Identity, grants bool) error { +func writeRunnables(cmd *cobra.Command, listing *runnableListing, env *environments.Environment) error { w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - header := "MODULE\tNAME\tVERSION\tAGENT\tFACILITIES\tTIMEOUT\tSOURCE\tTOOL\tEFFECT" - if grants { + // The tool columns appear only when a caller asked about tools or about an + // environment: the default table is the one every existing reader parses. + tools := listRunnablesTools || env != nil + header := "MODULE\tNAME\tVERSION\tAGENT\tFACILITIES\tTIMEOUT\tSOURCE" + if tools { + header += "\tTOOL\tEFFECT" + } + if env != nil { header += "\tKEY\tGRANT" } fmt.Fprintln(w, header) - for index := range entries { - e := &entries[index] + for index := range listing.Runnables { + e := &listing.Runnables[index] source := e.Source if source == "" { source = "declared" } - tool, effect := "", "" - if e.Tool != nil { - tool, effect = e.Tool.Name, e.Tool.Effect + row := fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%s", + e.Module, e.Name, e.Version, e.Agent, strings.Join(e.Execution.Facilities, ","), e.Execution.Timeout, source) + if tools { + name, effect := "", "" + if e.Tool != nil { + name, effect = e.Tool.Name, e.Tool.Effect + } + row += "\t" + name + "\t" + effect } - row := fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s", - e.Module, e.Name, e.Version, e.Agent, strings.Join(e.Execution.Facilities, ","), e.Execution.Timeout, source, tool, effect) - if grants { + if env != nil { key, state := "", "" if e.Grant != nil { key, state = e.Grant.Key, e.Grant.State @@ -189,7 +230,24 @@ func writeRunnables(cmd *cobra.Command, entries []runnablespkg.Identity, grants } fmt.Fprintln(w, row) } - return w.Flush() + if err := w.Flush(); err != nil { + return err + } + // A refused answer is printed under the table with its reason, because the + // state alone ("invalid") does not say what to change. + for index := range listing.Runnables { + grant := listing.Runnables[index].Grant + if grant != nil && grant.Reason != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\n%s: %s", grant.Key, grant.Reason) + } + } + if len(listing.UnansweredKeys) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "\n\nEnvironment %s answers keys no operation binds; generation refuses each by name:\n", env.Name) + for _, key := range listing.UnansweredKeys { + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", key) + } + } + return nil } func init() { diff --git a/cmd/list/runnables_grants_test.go b/cmd/list/runnables_grants_test.go index fd7904a7e..74c7afeb6 100644 --- a/cmd/list/runnables_grants_test.go +++ b/cmd/list/runnables_grants_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" runnablespkg "github.com/codefly-dev/cli/pkg/runnables" @@ -64,7 +65,16 @@ func writeWorkspaceWithAToolAndSlot(t *testing.T, selections string) string { // listOneDerived runs the listing over the fixture and returns its single // derived row. Reading the JSON rather than the table is deliberate: the row a // console or a picker consumes is the one under test. -func listOneDerived(t *testing.T, dir string) runnablespkg.Identity { +func listOneDerived(t *testing.T, dir string) runnableRow { + listing := listEnvironment(t, dir) + require.Len(t, listing.Runnables, 1, "the fixture module derives exactly one operation") + return listing.Runnables[0] +} + +// listEnvironment runs the listing against the fixture's staging environment +// and returns what a console or a picker consumes: the rows, and the answers +// that match no operation. +func listEnvironment(t *testing.T, dir string) runnableListing { t.Helper() t.Chdir(dir) t.Cleanup(func() { @@ -74,10 +84,9 @@ func listOneDerived(t *testing.T, dir string) runnablespkg.Identity { cmd, buf := newListTestCmd() require.NoError(t, listRunnables(cmd)) - var entries []runnablespkg.Identity - require.NoError(t, json.Unmarshal(buf.Bytes(), &entries)) - require.Len(t, entries, 1, "the fixture module derives exactly one operation") - return entries[0] + var listing runnableListing + require.NoError(t, json.Unmarshal(buf.Bytes(), &listing)) + return listing } // A listing is what a composition picks its tools from, so it carries the @@ -143,7 +152,7 @@ func TestWithoutAnEnvironmentNoGrantIsReported(t *testing.T) { cmd, buf := newListTestCmd() require.NoError(t, listRunnables(cmd)) - var entries []runnablespkg.Identity + var entries []runnableRow require.NoError(t, json.Unmarshal(buf.Bytes(), &entries)) require.Len(t, entries, 1) require.Nil(t, entries[0].Grant) @@ -174,7 +183,148 @@ func TestOnlyExposedOperationsAreListedAsTools(t *testing.T) { cmd, buf := newListTestCmd() require.NoError(t, listRunnables(cmd)) - var entries []runnablespkg.Identity + var entries []runnableRow require.NoError(t, json.Unmarshal(buf.Bytes(), &entries)) require.Empty(t, entries, "an operation with no exposure is not a tool, and an authored runnable is not one either") } + +// writeTwoSlotOperation is the case that exposed the listing's first shape as +// wrong: an operation whose owner asks TWO questions. An environment that +// answers one of them has answered something, and the generator still refuses +// the run. +func writeTwoSlotOperation(t *testing.T, selections string) string { + t.Helper() + dir := writeWorkspaceWithAToolAndSlot(t, selections) + operation := operationDocument(t, dir) + operation["required_scope_slots"] = []map[string]any{ + {"name": "collection", "required_actions": []string{"write"}}, + {"name": "source", "required_actions": []string{"read"}}, + } + writeOperationDocument(t, dir, operation) + return dir +} + +func operationDocument(t *testing.T, dir string) map[string]any { + t.Helper() + data, err := os.ReadFile(operationPath(dir)) + require.NoError(t, err) + document := map[string]any{} + require.NoError(t, json.Unmarshal(data, &document)) + return document +} + +func writeOperationDocument(t *testing.T, dir string, document map[string]any) { + t.Helper() + data, err := json.MarshalIndent(document, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(operationPath(dir), append(data, '\n'), 0o644)) +} + +func operationPath(dir string) string { + return filepath.Join(dir, "modules", "documents", "contracts", "runnables", + "runtime-worker", "grpc", "ApplyText", runnablespkg.OperationFileName) +} + +// collectionOnly answers the `collection` slot and nothing else. +const collectionOnly = " runnable-scope-selections:\n" + + " " + toolKey + ":\n" + + " - slot: collection\n" + + " invoke:\n" + + " - resource_kind: documents.collection\n" + + " actions: [write]\n" + + " resource_ids: [acme]\n" + +// A selection that answers some of the owner's questions is not a grant. The +// listing's first shape read any non-empty selection as `selected`, so an +// operation declaring two slots and answered for one reported as granted while +// `generate runnable-bindings` refused the run — wrong in the one place an +// operator looks to check their work. +func TestAPartiallyAnsweredOperationIsNotSelected(t *testing.T) { + entry := listOneDerived(t, writeTwoSlotOperation(t, collectionOnly)) + + require.NotNil(t, entry.Grant) + require.Equal(t, runnablespkg.GrantInvalid, entry.Grant.State, + "an answer generation refuses is not a grant") + require.Contains(t, entry.Grant.Reason, "source", "the unanswered slot is named") + require.Equal(t, []string{"collection", "source"}, entry.Grant.Slots) +} + +// A selection naming a slot the operation does not declare is the same class +// of mistake, and core refuses it with its own words. +func TestASelectionNamingAnUndeclaredSlotIsInvalid(t *testing.T) { + wrong := " runnable-scope-selections:\n" + + " " + toolKey + ":\n" + + " - slot: colection\n" + + " invoke:\n" + + " - resource_kind: documents.collection\n" + + " actions: [write]\n" + + " resource_ids: [acme]\n" + entry := listOneDerived(t, writeWorkspaceWithAToolAndSlot(t, wrong)) + + require.NotNil(t, entry.Grant) + require.Equal(t, runnablespkg.GrantInvalid, entry.Grant.State) + require.Contains(t, entry.Grant.Reason, "colection") +} + +// Declining is only something an operation with a required scope slot can be +// answered with: generate's declinableOperation refuses `[]` against an +// operation that has nothing to decline, so reporting it as a valid `declined` +// would legitimise a configuration that fails the run. +func TestDecliningAnOperationWithNoSlotIsInvalid(t *testing.T) { + dir := writeWorkspaceWithAToolAndSlot(t, " runnable-scope-selections:\n "+toolKey+": []\n") + operation := operationDocument(t, dir) + delete(operation, "required_scope_slots") + writeOperationDocument(t, dir, operation) + + entry := listOneDerived(t, dir) + require.NotNil(t, entry.Grant) + require.Equal(t, runnablespkg.GrantInvalid, entry.Grant.State) + require.Contains(t, entry.Grant.Reason, "nothing to decline") +} + +// An answer that names no operation is the likeliest mistake with a key that +// is typed by hand, and the listing used to drop it: the real operation read +// as unanswered and nothing pointed at the typo, while the generator would +// have named it. +func TestAnAnswerThatNamesNoOperationIsReported(t *testing.T) { + typo := " runnable-scope-selections:\n " + toolKey[:len(toolKey)-1] + ": []\n" + listing := listEnvironment(t, writeWorkspaceWithAToolAndSlot(t, typo)) + + require.Equal(t, []string{toolKey[:len(toolKey)-1]}, listing.UnansweredKeys, + "an answer to no question is reported, not dropped") + require.Len(t, listing.Runnables, 1) + require.Equal(t, runnablespkg.GrantNeedsScopes, listing.Runnables[0].Grant.State, + "and the operation it meant to answer still reads as unanswered") +} + +// Without --env the listing stays the array it has always been, so a reader of +// `list runnables --json` is not handed a different shape by this change. +func TestWithoutAnEnvironmentTheJSONStaysAnArray(t *testing.T) { + t.Chdir(writeWorkspaceWithAToolAndSlot(t, "")) + t.Cleanup(func() { + listRunnablesJSON, listRunnablesModule, listRunnablesEnv, listRunnablesTools = false, "", "", false + }) + listRunnablesJSON = true + cmd, buf := newListTestCmd() + require.NoError(t, listRunnables(cmd)) + require.Equal(t, byte('['), buf.Bytes()[0], "the no-environment listing is an array") +} + +// The default table is the one every existing reader parses: the tool columns +// appear when a caller asks about tools or about an environment, never before. +func TestTheDefaultTableKeepsItsColumns(t *testing.T) { + t.Chdir(writeWorkspaceWithAToolAndSlot(t, "")) + t.Cleanup(func() { + listRunnablesJSON, listRunnablesModule, listRunnablesEnv, listRunnablesTools = false, "", "", false + }) + cmd, buf := newListTestCmd() + require.NoError(t, listRunnables(cmd)) + header := strings.Split(buf.String(), "\n")[0] + require.NotContains(t, header, "TOOL") + require.NotContains(t, header, "EFFECT") + + listRunnablesTools = true + cmd, buf = newListTestCmd() + require.NoError(t, listRunnables(cmd)) + require.Contains(t, strings.Split(buf.String(), "\n")[0], "TOOL") +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 97c4b28c7..bcfb3ceaa 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -3371,21 +3371,24 @@ A runnable is identified by module/name @ version: a version is an immutable release and several versions of one name coexist. Every Runnable a module derives from its published contracts is listed beside -the ones it declares, with the tool it is offered as when its owner exposed one -— the model-facing name and the explicit effect. An operation with no exposure -is not a tool and is shown without one: absent means not exposed. +the ones it declares. With --tools or --env, each row also carries the tool its +owner exposed it as — the model-facing name and the explicit effect. An +operation with no exposure is not a tool and is shown without one: absent means +not exposed. --tools keeps only the exposed ones. With --env, each derived operation also carries the configuration key its authority is answered under (`runnable-scope-selections` in -workspace.codefly.yaml) and what that environment has answered: +workspace.codefly.yaml) and what that environment has answered, judged by the +rules `generate runnable-bindings` judges it by: - selected a binding is prepared: no slot to answer, or an answer resolves it + selected its answer resolves every slot the owner declared, or it declares none declined answered [] — installed for no resource, granted no authority - needs-scopes its owner asks for scope slots nobody has answered yet + needs-scopes its owner asks for scope slots nobody has answered + invalid generation refuses this answer, and the row says why -That last state is how a module version offering a new tool becomes visible: -the operation appears, nothing answers it, and generation refuses it by name -rather than preparing a binding with the question still open. +An answer naming no operation at all is reported beside the rows rather than +dropped, because a mistyped key answers no question and leaves its operation +reading as unanswered. Examples: codefly list runnables diff --git a/docs/commands.md b/docs/commands.md index 8db964a88..acd3e2d47 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2652,22 +2652,34 @@ codefly list runnables --tools --env=staging # Only the tools, with each one's ``` Every Runnable a module derives from its published contracts is listed beside -the ones it declares, carrying the tool its owner exposed it as — the -model-facing name and the explicit effect (`codefly.runnable.v0.ToolExposure`). -An operation with no exposure is listed without one: absent means not exposed, -and an unstated effect is not read-only. `--tools` keeps only the exposed ones, -which is what a composition can grant as a tool. +the ones it declares. With `--tools` or `--env`, each row also carries the tool +its owner exposed it as — the model-facing name and the explicit effect +(`codefly.runnable.v0.ToolExposure`). An operation with no exposure is listed +without one: absent means not exposed, and an unstated effect is not read-only. +`--tools` keeps only the exposed ones, which is what a composition can grant as +a tool. The default table is unchanged, so anything already parsing it keeps +working. `--env ` adds the configuration key each derived operation's authority is answered under — the `runnable-scope-selections` key in `workspace.codefly.yaml`, see [Generate runnable-bindings](#generate-runnable-bindings) -— and what that environment has answered: +— and what that environment has answered, judged by the rules +`generate runnable-bindings` judges it by rather than by whether an answer is +present: | grant | meaning | | --- | --- | -| `selected` | a binding is prepared: the operation asks for no scope slot, or the environment's answer resolves the ones it asks for | +| `selected` | its answer resolves every scope slot the owner declared, or it declares none | | `declined` | answered `[]`: installed for no resource, granted no authority, offered as no tool | -| `needs-scopes` | its owner asks for scope slots nobody has answered; `generate runnable-bindings` refuses it by name rather than preparing a binding with the question still open | +| `needs-scopes` | its owner asks for scope slots nobody has answered | +| `invalid` | generation refuses this answer, and the row carries core's reason: a slot left unselected, a slot named that the operation does not declare, a selection with no invoke scope, or an `[]` against an operation that has nothing to decline | + +An answer naming no operation at all is reported beside the rows — as +`unanswered_keys` in `--json` — rather than dropped: a mistyped key answers no +question and leaves the operation it meant to answer reading as unanswered, +which is the one thing a listing must not hide. With `--env`, `--json` emits an +object (`runnables`, `unanswered_keys`); without it, the array it has always +emitted. `needs-scopes` is how a module version that offers a new tool becomes visible: the operation appears in the listing, nothing answers it, and the answer is diff --git a/pkg/runnables/derived.go b/pkg/runnables/derived.go index a6c937589..89e29cda7 100644 --- a/pkg/runnables/derived.go +++ b/pkg/runnables/derived.go @@ -333,7 +333,6 @@ func (d *Derived) Identity() Identity { Protocol: d.Package.GetContract().GetProtocol(), Execution: newPackageExecution(d.Package.GetExecution()), Source: d.Entry.Source(), - Tool: d.ToolExposure(), } } diff --git a/pkg/runnables/grant.go b/pkg/runnables/grant.go new file mode 100644 index 000000000..66734aaf8 --- /dev/null +++ b/pkg/runnables/grant.go @@ -0,0 +1,206 @@ +package runnables + +import ( + "errors" + "fmt" + "strings" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + runnablev0 "github.com/codefly-dev/core/generated/go/codefly/runnable/v0" + corerunnable "github.com/codefly-dev/core/runnable" +) + +// BindingKey is the configuration key an operation is bound under: its module +// and its derived name, upper-cased with every other character an underscore, +// so the SDK's workspace-value lookup finds it. It is also the key an +// environment answers the operation's scope slots under +// (runnable-scope-selections in workspace.codefly.yaml). +// +// It lives here rather than beside one command because two of them need the +// same spelling: the generator writes the value, and a listing says what has +// been answered. A spelling that differed between them would report a grant +// against a key nothing installs. +func BindingKey(module, name string) string { + key := strings.Builder{} + for _, r := range strings.ToUpper(module + "__" + name) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + key.WriteRune(r) + continue + } + key.WriteRune('_') + } + return key.String() +} + +// Spec is the operation's declared policy as core's own OperationSpec, which +// is what core's rules are written against: spelling is how the owner names +// the operation on the wire, and codes is the vocabulary its retryable codes +// are spelled in. +func (o *Operation) Spec(spelling string, codes corerunnable.CodeVocabulary) (*corerunnable.OperationSpec, error) { + if o == nil { + return nil, errors.New("the operation declares no execution policy") + } + durations := map[string]time.Duration{} + for field, spelled := range map[string]string{ + "attempt_timeout": o.AttemptTimeout, + "total_timeout": o.TotalTimeout, + "backoff": o.Backoff, + } { + parsed, err := time.ParseDuration(spelled) + if err != nil { + return nil, fmt.Errorf("%s %q is not a duration: %w", field, spelled, err) + } + durations[field] = parsed + } + completion, named := basev0.RunnableExecution_Completion_value[o.Completion] + if !named || basev0.RunnableExecution_Completion(completion) == basev0.RunnableExecution_COMPLETION_UNKNOWN { + return nil, fmt.Errorf("completion %q is not a completion mode; %s and %s are the two", + o.Completion, basev0.RunnableExecution_COMPLETION_CALL, basev0.RunnableExecution_COMPLETION_SUBMIT) + } + var recovery basev0.RunnableExecution_Recovery + if o.Recovery != "" { + value, known := basev0.RunnableExecution_Recovery_value[o.Recovery] + if !known || basev0.RunnableExecution_Recovery(value) == basev0.RunnableExecution_RECOVERY_UNKNOWN { + return nil, fmt.Errorf("recovery %q is not a recovery; %s and %s are the two", + o.Recovery, basev0.RunnableExecution_RECOVERY_RECOMPUTE, basev0.RunnableExecution_RECOVERY_RECEIPT) + } + recovery = basev0.RunnableExecution_Recovery(value) + } + return &corerunnable.OperationSpec{ + Method: spelling, + Codes: codes, + AttemptTimeout: durations["attempt_timeout"], + TotalTimeout: durations["total_timeout"], + MaxAttempts: o.MaxAttempts, + Backoff: durations["backoff"], + RetryableCodes: o.RetryableCodes, + Audience: o.Audience, + InvokeScopes: specScopes(o.InvokeScopes), + LookupScopes: specScopes(o.LookupScopes), + LookupMethod: o.LookupMethod, + MaxInputBytes: o.MaxInputBytes, + MaxOutputBytes: o.MaxOutputBytes, + Completion: basev0.RunnableExecution_Completion(completion), + Tool: o.Tool, + RequiredScopeSlots: o.RequiredScopeSlots, + Recovery: recovery, + }, nil +} + +func specScopes(declared []Scope) []*basev0.WorkScopeV1 { + scopes := make([]*basev0.WorkScopeV1, 0, len(declared)) + for _, scope := range declared { + scopes = append(scopes, &basev0.WorkScopeV1{ResourceKind: scope.ResourceKind, Actions: scope.Actions, ResourceIds: scope.ResourceIDs}) + } + return scopes +} + +// CodeVocabularyFor is the vocabulary an operation's retryable codes are +// spelled in, read off the way its owner names it: a Connect or gRPC procedure +// is a path (`/pkg.Service/Method`), and a REST route is a verb and a path +// (`GET /v1/things`), so the space is the discriminator. Nothing else +// distinguishes them, and a derived operation carries no other field saying +// which it is. +func CodeVocabularyFor(spelling string) corerunnable.CodeVocabulary { + if strings.Contains(strings.TrimSpace(spelling), " ") { + return corerunnable.HTTPStatusCodes + } + return corerunnable.GRPCStatusNames +} + +// Grant is what one environment has answered about one derived operation: the +// configuration key the answer is written under, whether a binding is prepared +// for it, and — when it is not — why. A module version that adds an operation +// adds an unanswered row, which is how "this version offers a tool nobody +// granted" becomes visible without comparing two releases by hand. +type Grant struct { + // Key is the runnable-bindings key, which is also the + // runnable-scope-selections key an answer is written under. + Key string `json:"key"` + // State is `selected`, `declined`, `needs-scopes` or `invalid`. + State string `json:"state"` + // Slots names the owner's required scope slots, when the operation + // declares any: the questions an answer must resolve. + Slots []string `json:"slots,omitempty"` + // Reason is why an `invalid` answer is one, in the words generation + // refuses it with. Empty for every other state. + Reason string `json:"reason,omitempty"` +} + +// Grant states, which are the whole vocabulary a listing reports. +const ( + // GrantSelected is an operation this environment prepares a binding for: + // its answer resolves every slot the owner declared, or it declared none. + GrantSelected = "selected" + // GrantDeclined is an operation answered with an empty list: installed + // for no resource, granted no authority, offered as no tool. + GrantDeclined = "declined" + // GrantNeedsScopes is an operation whose owner asks for scope slots this + // environment has not answered at all. Generation refuses it by name + // rather than preparing a binding with the question still open, so an + // unanswered slot is work to do, not a quiet omission. + GrantNeedsScopes = "needs-scopes" + // GrantInvalid is an answer generation will refuse: a selection that does + // not resolve the operation's slots, or a decline of an operation that has + // nothing to decline. Reporting it as granted would send an operator away + // from the one command that would have told them. + GrantInvalid = "invalid" +) + +// GrantOf is how an environment's answer stands against one derived +// operation, judged by the rules generation judges it by rather than by +// whether an answer is present: +// +// - an empty list declines, and only an operation that declares a required +// scope slot has anything to decline (generate's declinableOperation); +// - otherwise the answer must resolve every slot the owner declared, which +// is core's own ResolveScopeSlots — it refuses a slot left unselected, a +// slot named that the operation does not declare, a selection with no +// invoke scope, an invoke scope missing an action the owner requires, and +// a missing lookup scope where the slot asks for one; +// - an operation declaring no slot and answered by nothing is simply +// prepared. +// +// Anything core refuses is `invalid` carrying core's reason, because a listing +// that reported it as granted would be wrong in exactly the place an operator +// looks to check their work. +func GrantOf(module string, operation *Derived, selections []*runnablev0.ScopeSelection, answered bool) *Grant { + grant := &Grant{Key: BindingKey(module, operation.Entry.Name), State: GrantSelected, Slots: operation.Slots()} + if answered && len(selections) == 0 { + grant.State = GrantDeclined + if len(grant.Slots) == 0 { + grant.State, grant.Reason = GrantInvalid, "declines the binding, but the operation declares no required scope slot: there is nothing to decline; remove the entry" + } + return grant + } + if len(grant.Slots) > 0 && !answered { + grant.State = GrantNeedsScopes + return grant + } + spec, err := operation.Operation.Spec(operation.Entry.Method, CodeVocabularyFor(operation.Entry.Method)) + if err != nil { + grant.State, grant.Reason = GrantInvalid, err.Error() + return grant + } + if _, err = spec.ResolveScopeSlots(selections); err != nil { + grant.State, grant.Reason = GrantInvalid, err.Error() + } + return grant +} + +// UnansweredKeys is every selection key in answers that no derived operation +// of these modules binds. Generation refuses such a key by name +// (generate's unknownSelectionKey), and a listing that only walked the +// catalogue would never show it: the likeliest mistake with a hand-typed key +// is a typo, which produces an answer to no question and an operation that +// still reports as unanswered. Sorted. +func UnansweredKeys(answers []string, bound map[string]bool) []string { + unanswered := make([]string, 0) + for _, key := range answers { + if !bound[key] { + unanswered = append(unanswered, key) + } + } + return unanswered +} diff --git a/pkg/runnables/projection.go b/pkg/runnables/projection.go index dcae2da63..b15057e82 100644 --- a/pkg/runnables/projection.go +++ b/pkg/runnables/projection.go @@ -39,13 +39,6 @@ type Identity struct { // which is the distinction a reader needs: a derived row is regenerated // from a contract, not edited. Source string `json:"source,omitempty"` - // Tool is the exposure the owner declared, nil when the operation is no - // tool. Absent means not exposed: that is the owner's fail-closed default - // and this listing repeats it rather than inventing one. - Tool *Tool `json:"tool,omitempty"` - // Grant is how one environment answers this operation, set only when a - // listing was asked about an environment. - Grant *Grant `json:"grant,omitempty"` } // NewIdentity projects a loaded runnable. The runnable must have come from @@ -91,35 +84,3 @@ type Tool struct { // Effect is EFFECT_READ_ONLY or EFFECT_MUTATION, spelled as the enum is. Effect string `json:"effect"` } - -// Grant is what one environment has answered about one derived operation: the -// configuration key the answer is written under, whether a binding is prepared -// for it, and — when it is not — which of the owner's scope slots are still -// open. A module version that adds an operation adds an unanswered row, which -// is how "this version offers a tool nobody granted" becomes visible without -// comparing two releases by hand. -type Grant struct { - // Key is the runnable-bindings key, which is also the - // runnable-scope-selections key an answer is written under. - Key string `json:"key"` - // State is `selected`, `declined`, or `needs-scopes`. - State string `json:"state"` - // Slots names the owner's required scope slots, when the operation - // declares any: the questions an answer must resolve. - Slots []string `json:"slots,omitempty"` -} - -// Grant states, which are the whole vocabulary a listing reports. -const ( - // GrantSelected is an operation this environment prepares a binding for: - // either it needs no slot answered, or its answer resolves them. - GrantSelected = "selected" - // GrantDeclined is an operation answered with an empty list: installed - // for no resource, granted no authority, offered as no tool. - GrantDeclined = "declined" - // GrantNeedsScopes is an operation whose owner asks for scope slots this - // environment has not answered. Generation refuses it by name rather than - // preparing a binding with the question still open, so an unanswered slot - // is work to do, not a quiet omission. - GrantNeedsScopes = "needs-scopes" -)