diff --git a/cmd/generate/runnable_bindings.go b/cmd/generate/runnable_bindings.go index 6bd0bba5c..5daaf89f9 100644 --- a/cmd/generate/runnable_bindings.go +++ b/cmd/generate/runnable_bindings.go @@ -10,6 +10,7 @@ import ( "slices" "sort" "strings" + "time" "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" @@ -475,12 +476,19 @@ func SelectRunnableBindings(ctx context.Context, workspace *resources.Workspace, return selection, nil } -// 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. +// 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. func RunnableBindingKey(module, name string) string { - return runnablespkg.BindingKey(module, name) + 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() } // prepareRunnable prepares one derived operation for the environment the @@ -507,7 +515,7 @@ func prepareRunnable(ctx context.Context, module *resources.Module, operation *r if kind == composition.APIContractKindOpenAPI { codes = corerunnable.HTTPStatusCodes } - spec, err := operation.Operation.Spec(descriptor.GetOperation(), codes) + spec, err := operationSpec(operation.Operation, descriptor.GetOperation(), codes) if err != nil { return nil, err } @@ -532,6 +540,65 @@ 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 878a28964..dfbe1abca 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 := document.Spec(document.Method, corerunnable.GRPCStatusNames) + spec, err := operationSpec(document, 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 74542efbc..ded2e8090 100644 --- a/cmd/list/runnables.go +++ b/cmd/list/runnables.go @@ -1,15 +1,12 @@ 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" @@ -18,8 +15,6 @@ import ( var ( listRunnablesModule string listRunnablesJSON bool - listRunnablesEnv string - listRunnablesTools bool ) // RunnablesCmd lists the runnables declared in the current workspace. @@ -31,30 +26,9 @@ 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 { @@ -62,33 +36,6 @@ 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() @@ -110,149 +57,51 @@ 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) - } - } - - 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{} + entries := make([]runnablespkg.Identity, 0) for _, mod := range modules { - runnables, err := mod.LoadRunnables(ctx) - if err != nil { - return nil, fmt.Errorf("cannot load runnables: %w", err) + runnables, loadErr := mod.LoadRunnables(ctx) + if loadErr != nil { + return fmt.Errorf("cannot load runnables: %w", loadErr) } for _, r := range runnables { - listing.Runnables = append(listing.Runnables, runnableRow{Identity: runnablespkg.NewIdentity(r)}) + entries = append(entries, 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, err := runnablespkg.LoadDerivedOperations(mod.Dir()) - if err != nil { - return nil, fmt.Errorf("cannot load the derived runnables of module %s: %w", mod.Name, err) + derived, derivedErr := runnablespkg.LoadDerivedOperations(mod.Dir()) + if derivedErr != nil { + return fmt.Errorf("cannot load the derived runnables of module %s: %w", mod.Name, derivedErr) } for i := range derived { - 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) + entries = append(entries, derived[i].Identity()) } } - 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 listing, nil + + if listRunnablesJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(entries) } - tools := make([]runnableRow, 0, len(listing.Runnables)) - for i := range listing.Runnables { - if listing.Runnables[i].Tool != nil { - tools = append(tools, listing.Runnables[i]) - } + + if len(entries) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No runnables in workspace <%s>\n", workspace.Name) + return nil } - 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) - // 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] + fmt.Fprintln(w, "MODULE\tNAME\tVERSION\tAGENT\tFACILITIES\tTIMEOUT\tSOURCE") + for index := range entries { + e := &entries[index] source := e.Source if source == "" { source = "declared" } - row := fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%s", + 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) - 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 nil + return w.Flush() } 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 deleted file mode 100644 index 74c7afeb6..000000000 --- a/cmd/list/runnables_grants_test.go +++ /dev/null @@ -1,330 +0,0 @@ -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 3cc88a6f1..8ab323210 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -3370,30 +3370,9 @@ 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 ``` @@ -3404,10 +3383,8 @@ 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 f1fbb900c..72aecc6cf 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2668,44 +2668,8 @@ 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 89e29cda7..2ea281717 100644 --- a/pkg/runnables/derived.go +++ b/pkg/runnables/derived.go @@ -336,31 +336,6 @@ 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 deleted file mode 100644 index 66734aaf8..000000000 --- a/pkg/runnables/grant.go +++ /dev/null @@ -1,206 +0,0 @@ -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 b15057e82..c81c12b81 100644 --- a/pkg/runnables/projection.go +++ b/pkg/runnables/projection.go @@ -73,14 +73,3 @@ 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"` -}