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 ded2e8090..74542efbc 100644 --- a/cmd/list/runnables.go +++ b/cmd/list/runnables.go @@ -1,12 +1,15 @@ package list import ( + "context" "encoding/json" "fmt" + "sort" "strings" "text/tabwriter" "github.com/codefly-dev/cli/cmd/common" + "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,30 @@ 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 --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, judged by the +rules ` + "`generate runnable-bindings`" + ` judges it by: + + 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 + invalid generation refuses this answer, and the row says why + +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 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 { @@ -36,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() @@ -57,51 +110,149 @@ func listRunnables(cmd *cobra.Command) error { return fmt.Errorf("cannot load modules: %w", err) } - entries := make([]runnablespkg.Identity, 0) + // 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) + } + } + + listing, err := runnableListingOf(ctx, modules, env) + if err != nil { + return err + } + + if listRunnablesJSON { + if env == nil { + return json.NewEncoder(cmd.OutOrStdout()).Encode(listing.Runnables) + } + return json.NewEncoder(cmd.OutOrStdout()).Encode(listing) + } + 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, listing, env) +} + +// 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, 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)) + 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 // 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()) + row := runnableRow{Identity: derived[i].Identity(), Tool: derived[i].ToolExposure()} + if env != nil { + // 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 + } + listing.Runnables = append(listing.Runnables, row) } } - - if listRunnablesJSON { - return json.NewEncoder(cmd.OutOrStdout()).Encode(entries) + 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 len(entries) == 0 { - fmt.Fprintf(cmd.OutOrStdout(), "No runnables in workspace <%s>\n", workspace.Name) - return nil + if !listRunnablesTools { + return listing, nil + } + tools := make([]runnableRow, 0, len(listing.Runnables)) + for i := range listing.Runnables { + if listing.Runnables[i].Tool != nil { + tools = append(tools, listing.Runnables[i]) + } } + listing.Runnables = tools + return listing, nil +} +func writeRunnables(cmd *cobra.Command, listing *runnableListing, env *environments.Environment) error { w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "MODULE\tNAME\tVERSION\tAGENT\tFACILITIES\tTIMEOUT\tSOURCE") - for index := range entries { - e := &entries[index] + // 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 listing.Runnables { + e := &listing.Runnables[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", + 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 + } + if env != nil { + key, state := "", "" + if e.Grant != nil { + key, state = e.Grant.Key, e.Grant.State + } + row += "\t" + key + "\t" + state + } + fmt.Fprintln(w, row) + } + 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 w.Flush() + return nil } 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..74c7afeb6 --- /dev/null +++ b/cmd/list/runnables_grants_test.go @@ -0,0 +1,330 @@ +package list + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "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) 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() { + listRunnablesJSON, listRunnablesModule, listRunnablesEnv, listRunnablesTools = false, "", "", false + }) + listRunnablesJSON, listRunnablesEnv = true, "staging" + cmd, buf := newListTestCmd() + require.NoError(t, listRunnables(cmd)) + + 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 +// 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 []runnableRow + 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 []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 45bdc9f40..bcfb3ceaa 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -3370,9 +3370,30 @@ 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 --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, judged by the +rules `generate runnable-bindings` judges it by: + + 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 + invalid generation refuses this answer, and the row says why + +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 codefly list runnables --module=backend + codefly list runnables --tools --env=staging codefly list runnables --json ``` @@ -3383,8 +3404,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..acd3e2d47 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2648,8 +2648,44 @@ 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. 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, judged by the rules +`generate runnable-bindings` judges it by rather than by whether an answer is +present: + +| grant | meaning | +| --- | --- | +| `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 | +| `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 +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..89e29cda7 100644 --- a/pkg/runnables/derived.go +++ b/pkg/runnables/derived.go @@ -336,6 +336,31 @@ func (d *Derived) Identity() Identity { } } +// 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/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 c81c12b81..b15057e82 100644 --- a/pkg/runnables/projection.go +++ b/pkg/runnables/projection.go @@ -73,3 +73,14 @@ 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"` +}