From 89555d615af479489a9385e69fd7639d33a75aa1 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:58:50 -0400 Subject: [PATCH 1/2] feat(generate): a solution's routes become Runnables, and so tools, as a module's methods do (#1184) `generate runnables` derives every route a module's OpenAPI contract marks x-codefly-operation through the same location, file and index writers a method goes through (deriveOpenAPIEndpoint / deriveRoute), one directory per route named by its operationId. solution-runtime-go marks each read a solution backend serves, so its GET reads become read-only Runnables whose query is their input. `generate runnable-bindings` prepares them keyed SOLUTION__OPERATION with a rest route; callTarget now calls an http endpoint that publishes OpenAPI exactly as it calls a rest one. Core pinned to the codefly-dev/core#852 head (GET operations, Swagger 2.0, RouteMarker). Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/generate/runnable_bindings.go | 14 +- cmd/generate/runnables.go | 180 ++++++++++++---- cmd/generate/runnables_solution_test.go | 265 ++++++++++++++++++++++++ docs/cli-reference.md | 22 +- docs/commands.md | 19 +- docs/runnable.md | 9 + 6 files changed, 462 insertions(+), 47 deletions(-) create mode 100644 cmd/generate/runnables_solution_test.go diff --git a/cmd/generate/runnable_bindings.go b/cmd/generate/runnable_bindings.go index 9759b8d4..f6a376a0 100644 --- a/cmd/generate/runnable_bindings.go +++ b/cmd/generate/runnable_bindings.go @@ -59,7 +59,7 @@ For each derived operation the value is one JSON document (` + corerunnable.PreparedSchemaV3 + `): operation the owner coordinates, and the operation as the owner spells - it: /pkg.Service/Method, or "POST /path" + it: /pkg.Service/Method, or "GET /path" for a route call where one call is sent: the resolved base URL, and a typed route — connect (the procedure, POSTed as JSON on the owner's Connect endpoint) or rest (the owner's own verb and path) @@ -76,6 +76,9 @@ A gRPC owner is therefore called on its **Connect** endpoint: a service that publishes a runnable-marked method and declares no connect endpoint is refused here, by name, rather than at a call. An operation published directly on a Connect endpoint uses that endpoint. +A solution's route is called on the http (or rest) endpoint whose OpenAPI +document published it, with its own verb and path, and keyed by the solution's +name exactly as a module operation is by its module's (SOLUTION__OPERATION). The address is resolved, never configured: it is the one a run or a render of the same environment gives the owner. Locally that is the address of the run's @@ -661,7 +664,10 @@ func (r *endpointResolver) callTarget(ctx context.Context, module *resources.Mod Address: address, Route: &runnablev0.PreparedCall_Connect{Connect: &runnablev0.ConnectProcedure{Procedure: spelling}}, }, composition.APIContractKindProtobuf, nil - case standards.REST: + case standards.REST, standards.HTTP: + // A solution's backend serves its routes on an http endpoint and + // publishes them as OpenAPI, which verifyPublishedContract holds it to: + // it is called exactly as a REST owner is. address, err := r.address(ctx, identity, endpoints, published) if err != nil { return nil, "", err @@ -675,8 +681,8 @@ func (r *endpointResolver) callTarget(ctx context.Context, module *resources.Mod Route: &runnablev0.PreparedCall_Rest{Rest: &runnablev0.HTTPRoute{Verb: verb, Path: path}}, }, composition.APIContractKindOpenAPI, nil default: - return nil, "", fmt.Errorf("%s/%s/%s is a %s endpoint, and a derived operation is called over %s, %s or %s", - module.Name, serviceName, publishedName, published.GetApi(), standards.GRPC, standards.CONNECT, standards.REST) + return nil, "", fmt.Errorf("%s/%s/%s is a %s endpoint, and a derived operation is called over %s, %s, %s or %s", + module.Name, serviceName, publishedName, published.GetApi(), standards.GRPC, standards.CONNECT, standards.REST, standards.HTTP) } } diff --git a/cmd/generate/runnables.go b/cmd/generate/runnables.go index 6eeb40c5..35de8ecf 100644 --- a/cmd/generate/runnables.go +++ b/cmd/generate/runnables.go @@ -1,6 +1,7 @@ package generate import ( + "cmp" "context" "errors" "fmt" @@ -36,9 +37,10 @@ var ( // published contracts mark as an operation. var RunnablesCmd = &cobra.Command{ Use: "runnables [module]", - Short: "Derive SERVICE-facility Runnable packages, and so tools, from a module's served methods", + Short: "Derive SERVICE-facility Runnable packages, and so tools, from a module's served methods and a solution's routes", Long: `Derive a Runnable package for every gRPC method a module's published contracts -serve behind the authority hop. Every Runnable is a tool. +serve behind the authority hop, and every REST route a solution's published +OpenAPI document marks. Every Runnable is a tool. A method that declares its authority policy (authority.hop.v1.policy) is an operation with nothing added: a policy requiring only "read" derives a @@ -49,8 +51,15 @@ codefly.runnable.v0.exclude keeps a method from every agent. The message descriptors say what the contract is, by their proto3 JSON. Nothing is written twice. +A solution publishes its routes as one OpenAPI document (Swagger 2.0 or +OpenAPI 3), and every route marked x-codefly-operation derives the same way: +solution-runtime-go marks each read the backend serves, so a GET read becomes a +read-only tool whose query parameters are its input. Its directory is named by +the route's operationId. No flag names a route. + The input is what ` + "`codefly generate contracts`" + ` already wrote — each endpoint's -served procedures in the API catalog and its serialized FileDescriptorSet. +served procedures in the API catalog and its serialized FileDescriptorSet, or +its OpenAPI document. Run that first. Only served methods are derived; a declared Lookup must be served on the same endpoint. No flag names a method. @@ -60,6 +69,8 @@ For each marked method this writes, under contracts/runnables: ///operation.json its execution policy and authority index.json one row per operation +and the same, under ///, for each marked route. + The tree is fully owned: a method that is no longer an operation loses its directory. A module with no operation writes an empty index, not an error. @@ -74,6 +85,7 @@ prints a unified diff of what differs, without writing anything. Examples: codefly generate runnables codefly generate runnables documents + codefly generate runnables releases # a solution: its marked routes codefly generate runnables --check`, Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { @@ -170,7 +182,9 @@ func (d *derivation) refusal() error { // from and what it does. type derivedOperation struct { explicit bool - effect string + // marker is a route its document marks (x-codefly-operation). + marker bool + effect string } // report says what the run made of the module, so an owner learns which of @@ -187,7 +201,10 @@ func (d *derivation) report() { entry := &d.index.Operations[i] operation := d.origins[entry.Name] origin := "option" - if !operation.explicit { + switch { + case operation.marker: + origin = "marker" + case !operation.explicit: origin = "default" defaults = appendOnce(defaults, entry.Method) if operation.effect == runnablev0.ToolExposure_EFFECT_MUTATION.String() { @@ -259,7 +276,12 @@ func deriveRunnablesIn(ctx context.Context, workspaceName, workspaceDir string, agents := map[string]*basev0.Agent{} for i := range endpoints { endpoint := &endpoints[i] - if endpoint.Kind != composition.APIContractKindProtobuf { + derive := deriveEndpoint + switch endpoint.Kind { + case composition.APIContractKindProtobuf: + case composition.APIContractKindOpenAPI: + derive = deriveOpenAPIEndpoint + default: continue } agent, ok := agents[endpoint.Service] @@ -269,7 +291,7 @@ func deriveRunnablesIn(ctx context.Context, workspaceName, workspaceDir string, } agents[endpoint.Service] = agent } - if err = deriveEndpoint(module, endpoint, agent, derived); err != nil { + if err = derive(module, endpoint, agent, derived); err != nil { return nil, err } } @@ -347,54 +369,136 @@ func deriveEndpoint(module *resources.Module, endpoint *composition.APIContractE // derivation refuses is recorded and the walk continues. func deriveMethod(module *resources.Module, endpoint *composition.APIContractEndpoint, owner corerunnable.ServiceOwner, files *protoregistry.Files, fullMethod string, served []string, derived *derivation) error { name := methodName(fullMethod) - relativeDir, err := derivedRelativeDir(derived.workspaceDir, module, endpoint, name) + location, err := derived.location(module, endpoint, name, name) if err != nil { return err } - location := &resources.RunnableLocation{ - Identity: &resources.RunnableIdentity{ - Name: derivedName(endpoint.Service, endpoint.Endpoint, name), - Module: module.Name, - Workspace: derived.index.Workspace, - Version: derivedVersion(derived.release, endpoint), - }, - WorkspacePath: derived.workspaceDir, - RelativeToWorkspace: relativeDir, + pkg, spec, err := corerunnable.PackageFromMethod(files, location, owner, fullMethod) + if !derived.admitted(fullMethod, err) { + return nil + } + if spec.LookupMethod != "" && !slices.Contains(served, spec.LookupMethod) { + derived.refusals = append(derived.refusals, fmt.Sprintf("%s: endpoint %s/%s does not serve lookup method %s", fullMethod, endpoint.Service, endpoint.Endpoint, spec.LookupMethod)) + return nil } + return derived.record(endpoint, name, pkg, spec, derivedOperation{explicit: spec.Declared, effect: spec.Tool.GetEffect().String()}) +} - pkg, spec, err := corerunnable.PackageFromMethod(files, location, owner, fullMethod) +// deriveOpenAPIEndpoint derives every route one OpenAPI contract marks with +// x-codefly-operation — a solution's backend publishes one, marking each read +// it serves — through the same writers a method goes through, so a route and a +// method land in one tree, one index and one binding key space. Routes are +// walked in path then method order, so the index is a function of the document. +func deriveOpenAPIEndpoint(module *resources.Module, endpoint *composition.APIContractEndpoint, agent *basev0.Agent, derived *derivation) error { + contract := filepath.Join(module.Dir(), filepath.FromSlash(endpoint.Path)) + document, err := os.ReadFile(contract) //nolint:gosec // the path comes from the module's own contract catalog + if err != nil { + return fmt.Errorf("cannot read the contract of %s/%s: %w", endpoint.Service, endpoint.Endpoint, err) + } + parsed, err := corerunnable.ParseOpenAPIDocument(document) if err != nil { - if errors.Is(err, corerunnable.ErrNotAnOperation) { - return nil + return fmt.Errorf("cannot read %s: %w", contract, err) + } + owner := corerunnable.ServiceOwner{Module: module.Name, Service: endpoint.Service, Endpoint: endpoint.Endpoint, Agent: agent} + paths := make([]string, 0, len(parsed.Paths)) + for path := range parsed.Paths { + paths = append(paths, path) + } + sort.Strings(paths) + for _, path := range paths { + item := parsed.Paths[path] + if item == nil { + continue } - if errors.Is(err, corerunnable.ErrNotDerivable) { - derived.notices = append(derived.notices, fmt.Sprintf("%s: %v", fullMethod, err)) - return nil + for _, route := range []struct { + method string + operation *corerunnable.OpenAPIOperation + }{{"GET", item.Get}, {"POST", item.Post}, {"PUT", item.Put}} { + if route.operation == nil || len(route.operation.Marker) == 0 { + continue + } + if err = deriveRoute(module, endpoint, owner, document, route.method, path, route.operation.OperationID, derived); err != nil { + return err + } } - derived.refusals = append(derived.refusals, fmt.Sprintf("%s: %v", fullMethod, err)) - return nil } - if spec.LookupMethod != "" && !slices.Contains(served, spec.LookupMethod) { - derived.refusals = append(derived.refusals, fmt.Sprintf("%s: endpoint %s/%s does not serve lookup method %s", fullMethod, endpoint.Service, endpoint.Endpoint, spec.LookupMethod)) + return nil +} + +// deriveRoute derives one marked route. Its directory is named by the route's +// operationId — the identity the document publishes it under, as a method's +// is its name — or, when the document gives none, by the route itself. +func deriveRoute(module *resources.Module, endpoint *composition.APIContractEndpoint, owner corerunnable.ServiceOwner, document []byte, method, path, operationID string, derived *derivation) error { + spelling := corerunnable.Route(method, path) + words := strings.FieldsFunc(cmp.Or(operationID, spelling), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) + for i, word := range words { + words[i] = kebab(word) + } + name := cmp.Or(operationID, strings.Join(words, "-")) + location, err := derived.location(module, endpoint, name, strings.Join(words, "-")) + if err != nil { + return err + } + pkg, spec, err := corerunnable.PackageFromOpenAPIOperation(document, location, owner, method, path) + if !derived.admitted(spelling, err) { return nil } + return derived.record(endpoint, name, pkg, spec, derivedOperation{marker: true, effect: spec.Tool.GetEffect().String()}) +} +// location is where one operation's release lives: its directory, named dir, +// and its identity, the release name spelled from words. +func (d *derivation) location(module *resources.Module, endpoint *composition.APIContractEndpoint, dir, words string) (*resources.RunnableLocation, error) { + relativeDir, err := derivedRelativeDir(d.workspaceDir, module, endpoint, dir) + if err != nil { + return nil, err + } + return &resources.RunnableLocation{ + Identity: &resources.RunnableIdentity{ + Name: derivedName(endpoint.Service, endpoint.Endpoint, words), + Module: module.Name, + Workspace: d.index.Workspace, + Version: derivedVersion(d.release, endpoint), + }, + WorkspacePath: d.workspaceDir, + RelativeToWorkspace: relativeDir, + }, nil +} + +// admitted sorts one derivation's answer: no operation is an answer, an +// operation the default derivation cannot make is a notice, anything else core +// refuses is a refusal, and only a derived operation is admitted. +func (d *derivation) admitted(subject string, err error) bool { + switch { + case err == nil: + return true + case errors.Is(err, corerunnable.ErrNotAnOperation): + case errors.Is(err, corerunnable.ErrNotDerivable): + d.notices = append(d.notices, fmt.Sprintf("%s: %v", subject, err)) + default: + d.refusals = append(d.refusals, fmt.Sprintf("%s: %v", subject, err)) + } + return false +} + +// record writes one derived operation into the tree and the index. +func (d *derivation) record(endpoint *composition.APIContractEndpoint, name string, pkg *basev0.RunnablePackage, spec *corerunnable.OperationSpec, origin derivedOperation) error { + operation := pkg.GetServiceOperations()[0] packageBytes, err := runnablespkg.CanonicalJSON(pkg) if err != nil { - return fmt.Errorf("%s: cannot encode the derived package: %w", fullMethod, err) + return fmt.Errorf("%s: cannot encode the derived package: %w", operation.GetOperation(), err) } operationBytes, err := marshalIndented(newOperationDocument(spec)) if err != nil { - return fmt.Errorf("%s: cannot encode the derived operation: %w", fullMethod, err) + return fmt.Errorf("%s: cannot encode the derived operation: %w", operation.GetOperation(), err) } dir := filepath.Join(endpoint.Service, endpoint.Endpoint, name) - derived.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.PackageFileName))] = append(packageBytes, '\n') - derived.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.OperationFileName))] = operationBytes + d.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.PackageFileName))] = append(packageBytes, '\n') + d.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.OperationFileName))] = operationBytes - operation := pkg.GetServiceOperations()[0] - derived.origins[pkg.GetIdentity().GetName()] = derivedOperation{explicit: spec.Declared, effect: spec.Tool.GetEffect().String()} - derived.index.Operations = append(derived.index.Operations, runnablespkg.IndexEntry{ + d.origins[pkg.GetIdentity().GetName()] = origin + d.index.Operations = append(d.index.Operations, runnablespkg.IndexEntry{ Name: pkg.GetIdentity().GetName(), Version: pkg.GetIdentity().GetVersion(), Digest: pkg.GetDigest(), @@ -484,7 +588,9 @@ func derivedVersion(release string, endpoint *composition.APIContractEndpoint) s return "0.0.0-contract-" + digest } -// derivedName names the release a method derives. The endpoint is part of it +// derivedName names the release an operation derives, its words spelled as a +// resource name is (kebab): a method's name, or a route's words, which are +// already. The endpoint is part of it // because one proto service may be published on both a grpc and a connect // endpoint: those are two operations reached two ways, and a name that dropped // the endpoint would give them one identity and two digests. @@ -493,8 +599,8 @@ func derivedVersion(release string, endpoint *composition.APIContractEndpoint) s // shortened deterministically — a stable prefix and a short digest of the // whole name — so a long method name never fails generation and two long // names never collapse into one. -func derivedName(service, endpoint, method string) string { - return corerunnable.BoundedName(strings.Join([]string{service, endpoint, kebab(method)}, "-"), maxDerivedName, "-") +func derivedName(service, endpoint, words string) string { + return corerunnable.BoundedName(strings.Join([]string{service, endpoint, kebab(words)}, "-"), maxDerivedName, "-") } // appendOnce appends value unless the list already holds it. diff --git a/cmd/generate/runnables_solution_test.go b/cmd/generate/runnables_solution_test.go new file mode 100644 index 00000000..e1cf03e2 --- /dev/null +++ b/cmd/generate/runnables_solution_test.go @@ -0,0 +1,265 @@ +package generate + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/composition" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + runnablev0 "github.com/codefly-dev/core/generated/go/codefly/runnable/v0" + "github.com/codefly-dev/core/resources" + corerunnable "github.com/codefly-dev/core/runnable" + "github.com/stretchr/testify/require" +) + +// releasesRoute is one route of the releases solution's backend as its +// runtime publishes it: the policy it declares and, for a read, the marker +// core writes from that policy (corerunnable.RouteMarker), which is exactly +// what solution-runtime-go puts beside it. +type releasesRoute struct { + method, path, id, summary, response string + policy string + query []map[string]any +} + +var releasesRoutes = []releasesRoute{ + {method: "get", path: "/v1/releases/recorded", id: "Releases_Get_V1_Releases_Recorded", + summary: "Read the board as records holds it, without asking GitHub", response: "Board", + policy: `{"requires":[{"resource_kind":"records.records","actions":["read"]}]}`}, + {method: "get", path: "/v1/releases/history", id: "Releases_Get_V1_Releases_History", + summary: "Read the recorded tags of one repository and the recorded pins of one component", response: "History", + policy: `{"requires":[{"resource_kind":"records.records","actions":["read"]}]}`, + query: []map[string]any{ + {"name": "repo", "in": "query", "required": true, "type": "string"}, + {"name": "component", "in": "query", "required": false, "type": "string"}, + }}, + {method: "post", path: "/v1/releases/record", id: "Releases_Post_V1_Releases_Record", + summary: "Record every tag and pin the board shows that is not recorded yet", response: "RecordResult", + policy: `{"requires":[{"resource_kind":"records.records","actions":["read","write"]}]}`}, + {method: "post", path: "/v1/releases/chat/install", id: "Releases_Post_V1_Releases_Chat_Install", + summary: "Import this solution's Robin intent into the installation", response: "RecordResult", + policy: `{"requires":[{"resource_kind":"robin.intents","actions":["import","activate"]}]}`}, + {method: "get", path: "/v1/releases/status", id: "Releases_Get_V1_Releases_Status", + summary: "Report whether the viewer's GitHub source is connected", response: "RecordResult", + policy: `{"any_checked_caller":true}`}, +} + +// releasesDocument is the releases backend's Swagger 2.0 artifact. edit +// reshapes the parsed document for the case under test. +func releasesDocument(t *testing.T, edit func(map[string]any)) []byte { + t.Helper() + object := func(properties map[string]any) map[string]any { + return map[string]any{"type": "object", "properties": properties, "additionalProperties": false} + } + paths := map[string]any{} + for _, route := range releasesRoutes { + operation := map[string]any{ + "operationId": route.id, + "summary": route.summary, + "responses": map[string]any{ + "200": map[string]any{"description": "A successful response.", "schema": map[string]any{"$ref": "#/definitions/" + route.response}}, + "401": map[string]any{"description": "No Work Context.", "schema": map[string]any{"$ref": "#/definitions/Error"}}, + }, + "x-codefly-authority-policy": json.RawMessage(route.policy), + } + if len(route.query) > 0 { + operation["parameters"] = route.query + } + if route.method == "get" && strings.Contains(route.policy, `["read"]`) { + marker, err := corerunnable.RouteMarker(route.method, route.path, json.RawMessage(route.policy), "releases", route.id, route.summary) + require.NoError(t, err) + operation[corerunnable.OpenAPIOperationMarker] = marker + } + paths[route.path] = map[string]any{route.method: operation} + } + raw, err := json.Marshal(map[string]any{ + "swagger": "2.0", + "info": map[string]any{"title": "releases solution backend", "version": "0.3.0"}, + "consumes": []string{"application/json"}, + "produces": []string{"application/json"}, + "paths": paths, + "definitions": map[string]any{ + "Board": object(map[string]any{"observedAt": map[string]any{"type": "string"}}), + "History": object(map[string]any{"repo": map[string]any{"type": "string"}}), + "RecordResult": object(map[string]any{"tags": map[string]any{"type": "integer"}}), + "Error": object(map[string]any{"error": map[string]any{"type": "string"}}), + }, + }) + require.NoError(t, err) + if edit == nil { + return raw + } + var doc map[string]any + require.NoError(t, json.Unmarshal(raw, &doc)) + edit(doc) + raw, err = json.Marshal(doc) + require.NoError(t, err) + return raw +} + +// saveSolutionFixture is a workspace holding the releases solution: a module +// whose backend serves its routes on an http endpoint and publishes them as +// OpenAPI, the way `codefly generate contracts` leaves a solution behind. +func saveSolutionFixture(t *testing.T, ctx context.Context, document []byte) (root, moduleDir string) { + t.Helper() + root, moduleDir = saveFixtureWorkspace(t, ctx, "releases", + &resources.ModuleInterface{Endpoints: []*resources.InterfaceEndpoint{ + {Service: "backend", Endpoint: "http", Visibility: resources.VisibilityPublic}, + }}, + &resources.Service{ + Name: "backend", + Version: "0.3.0", + Agent: &resources.Agent{Kind: resources.ServiceAgent, Name: "go", Version: "0.0.70", Publisher: "codefly.dev"}, + Endpoints: []*resources.Endpoint{ + {Name: "http", API: "http", Visibility: resources.VisibilityPublic, Exposure: resources.ExposureNone}, + }, + }, + ) + publishSolutionContract(t, moduleDir, document) + return root, moduleDir +} + +func publishSolutionContract(t *testing.T, moduleDir string, document []byte) { + t.Helper() + const contractPath = "contracts/api/backend/http/openapi.json" + writeFixtureFile(t, filepath.Join(moduleDir, filepath.FromSlash(contractPath)), document) + routes := make([]composition.APIContractRoute, 0, len(releasesRoutes)) + for _, route := range releasesRoutes { + routes = append(routes, composition.APIContractRoute{Method: strings.ToUpper(route.method), Path: route.path}) + } + catalog := &composition.APIContractCatalog{ + Schema: composition.APIContractCatalogSchema, + Package: "obin/releases", + Version: "0.3.0", + Endpoints: []composition.APIContractEndpoint{{ + Service: "backend", + Endpoint: "http", + API: "http", + Kind: composition.APIContractKindOpenAPI, + Package: "releases solution backend", + Path: contractPath, + Digest: composition.APIContractDigest(document), + Routes: routes, + }}, + } + catalogBytes, err := catalog.CanonicalBytes() + require.NoError(t, err) + writeFixtureFile(t, filepath.Join(moduleDir, filepath.FromSlash(composition.APIContractCatalogFileName)), catalogBytes) +} + +// TestGenerateRunnablesDerivesASolutionsReads is the solution half of "every +// Runnable is a tool": the same command, given a solution, writes the same +// tree from its OpenAPI document — one directory per marked route, named by +// its operationId — and a solution's two reads become read-only Runnables +// while its writes, which its runtime does not mark, become nothing. +func TestGenerateRunnablesDerivesASolutionsReads(t *testing.T) { + ctx := context.Background() + root, moduleDir := saveSolutionFixture(t, ctx, releasesDocument(t, nil)) + t.Chdir(root) + resetRunnablesFlags(t) + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"releases"})) + + require.Equal(t, []string{ + "backend/http/Releases_Get_V1_Releases_History/operation.json", + "backend/http/Releases_Get_V1_Releases_History/runnable-package.json", + "backend/http/Releases_Get_V1_Releases_Recorded/operation.json", + "backend/http/Releases_Get_V1_Releases_Recorded/runnable-package.json", + "index.json", + }, generatedTree(t, moduleDir)) + + index := readIndex(t, moduleDir) + require.Len(t, index.Operations, 2) + history := index.Operations[0] + require.Equal(t, "backend-http-releases-get-v1-releases-history", history.Name) + require.Equal(t, "GET /v1/releases/history", history.Method) + require.Equal(t, "Releases_Get_V1_Releases_History", history.InputMessage) + require.Equal(t, "History", history.OutputMessage) + require.Equal(t, "contracts/runnables/backend/http/Releases_Get_V1_Releases_History", history.Path) + + pkg := readDerivedPackage(t, moduleDir, history) + require.NoError(t, corerunnable.VerifyPackage(pkg)) + require.Equal(t, history.Digest, pkg.GetDigest()) + require.Equal(t, basev0.RunnableExecution_RECOVERY_RECOMPUTE, pkg.GetExecution().GetRecovery()) + require.Len(t, pkg.GetContract().GetInput().GetFields(), 2, "the query is the input") + + operation := readDerivedOperation(t, moduleDir, history) + require.Equal(t, "releases", operation.Audience) + require.Equal(t, []string{"503"}, operation.RetryableCodes) + require.Equal(t, runnablev0.ToolExposure_EFFECT_READ_ONLY, operation.Tool.GetEffect()) + require.Equal(t, "Read the recorded tags of one repository and the recorded pins of one component", operation.Tool.GetDescription()) + + // Idempotent: a second run is the same tree. + before := snapshotTree(t, moduleDir) + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"releases"})) + require.True(t, equalTrees(before, snapshotTree(t, moduleDir))) +} + +// TestGenerateRunnablesRefusesAMarkedRouteCoreRefuses: a marker is the owner's +// declaration, so a marked GET that would mutate is refused by name, and the +// command fails and writes nothing — never a write tool over GET. +func TestGenerateRunnablesRefusesAMarkedRouteCoreRefuses(t *testing.T) { + ctx := context.Background() + document := releasesDocument(t, func(doc map[string]any) { + marker, err := corerunnable.RouteMarker("POST", "/v1/releases/record", + json.RawMessage(`{"requires":[{"resource_kind":"records.records","actions":["read","write"]}]}`), "releases", "record", "Record") + require.NoError(t, err) + var declared map[string]any + require.NoError(t, json.Unmarshal(marker, &declared)) + history := doc["paths"].(map[string]any)["/v1/releases/history"].(map[string]any)["get"].(map[string]any) + history[corerunnable.OpenAPIOperationMarker] = declared + }) + root, moduleDir := saveSolutionFixture(t, ctx, document) + t.Chdir(root) + resetRunnablesFlags(t) + err := RunnablesCmd.RunE(RunnablesCmd, []string{"releases"}) + require.Error(t, err) + require.Contains(t, err.Error(), "GET /v1/releases/history") + require.Contains(t, err.Error(), "admitted only as a read") + _, statErr := os.Stat(filepath.Join(moduleDir, "contracts", "runnables")) + require.True(t, os.IsNotExist(statErr), "a refused run wrote the tree") +} + +// TestGenerateRunnableBindingsPreparesASolutionsReads: each derived read is +// prepared keyed SOLUTION__OPERATION, as a module's is MODULE__OPERATION, and +// calls the route itself — the verb, the path and the http endpoint's +// resolved address — under the policy core derived for it. +func TestGenerateRunnableBindingsPreparesASolutionsReads(t *testing.T) { + ctx := context.Background() + root, _ := saveSolutionFixture(t, ctx, releasesDocument(t, nil)) + t.Chdir(root) + resetRunnablesFlags(t) + resetRunnableBindingsFlags(t) + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"releases"})) + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) + + raw, err := os.ReadFile(filepath.Join(root, "configurations", "local", RunnableBindingsGroup+".env")) + require.NoError(t, err) + values := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + if key, value, found := strings.Cut(line, "="); found && !strings.HasPrefix(line, "#") { + values[key] = value + } + } + require.Len(t, values, 2, "the reads and nothing else:\n%s", raw) + for key, route := range map[string]string{ + RunnableBindingKey("releases", "backend-http-releases-get-v1-releases-history"): "/v1/releases/history", + RunnableBindingKey("releases", "backend-http-releases-get-v1-releases-recorded"): "/v1/releases/recorded", + } { + require.True(t, strings.HasPrefix(key, "RELEASES__"), key) + value, ok := values[key] + require.True(t, ok, "no binding %s in:\n%s", key, raw) + prepared, err := corerunnable.DecodePrepared([]byte(value)) + require.NoError(t, err) + require.Equal(t, "GET", prepared.GetCall().GetRest().GetVerb()) + require.Equal(t, route, prepared.GetCall().GetRest().GetPath()) + require.True(t, strings.HasPrefix(prepared.GetCall().GetAddress(), "http://localhost:"), prepared.GetCall().GetAddress()) + tool, err := corerunnable.ToolFromPrepared(prepared) + require.NoError(t, err) + require.Equal(t, runnablev0.ToolExposure_EFFECT_READ_ONLY, tool.GetEffect()) + require.Equal(t, basev0.RunnableExecution_RECOVERY_RECOMPUTE, prepared.GetPolicy().GetRecovery()) + } +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c707c887..fce4c7cc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2913,7 +2913,7 @@ For each derived operation the value is one JSON document (codefly.runnable-prepared/v3): operation the owner coordinates, and the operation as the owner spells - it: /pkg.Service/Method, or "POST /path" + it: /pkg.Service/Method, or "GET /path" for a route call where one call is sent: the resolved base URL, and a typed route — connect (the procedure, POSTed as JSON on the owner's Connect endpoint) or rest (the owner's own verb and path) @@ -2930,6 +2930,9 @@ A gRPC owner is therefore called on its **Connect** endpoint: a service that publishes a runnable-marked method and declares no connect endpoint is refused here, by name, rather than at a call. An operation published directly on a Connect endpoint uses that endpoint. +A solution's route is called on the http (or rest) endpoint whose OpenAPI +document published it, with its own verb and path, and keyed by the solution's +name exactly as a module operation is by its module's (SOLUTION__OPERATION). The address is resolved, never configured: it is the one a run or a render of the same environment gives the owner. Locally that is the address of the run's @@ -3002,11 +3005,12 @@ Flags: ## `codefly generate runnables` -Derive SERVICE-facility Runnable packages, and so tools, from a module's served methods +Derive SERVICE-facility Runnable packages, and so tools, from a module's served methods and a solution's routes ``` Derive a Runnable package for every gRPC method a module's published contracts -serve behind the authority hop. Every Runnable is a tool. +serve behind the authority hop, and every REST route a solution's published +OpenAPI document marks. Every Runnable is a tool. A method that declares its authority policy (authority.hop.v1.policy) is an operation with nothing added: a policy requiring only "read" derives a @@ -3017,8 +3021,15 @@ codefly.runnable.v0.exclude keeps a method from every agent. The message descriptors say what the contract is, by their proto3 JSON. Nothing is written twice. +A solution publishes its routes as one OpenAPI document (Swagger 2.0 or +OpenAPI 3), and every route marked x-codefly-operation derives the same way: +solution-runtime-go marks each read the backend serves, so a GET read becomes a +read-only tool whose query parameters are its input. Its directory is named by +the route's operationId. No flag names a route. + The input is what `codefly generate contracts` already wrote — each endpoint's -served procedures in the API catalog and its serialized FileDescriptorSet. +served procedures in the API catalog and its serialized FileDescriptorSet, or +its OpenAPI document. Run that first. Only served methods are derived; a declared Lookup must be served on the same endpoint. No flag names a method. @@ -3028,6 +3039,8 @@ For each marked method this writes, under contracts/runnables: ///operation.json its execution policy and authority index.json one row per operation +and the same, under ///, for each marked route. + The tree is fully owned: a method that is no longer an operation loses its directory. A module with no operation writes an empty index, not an error. @@ -3042,6 +3055,7 @@ prints a unified diff of what differs, without writing anything. Examples: codefly generate runnables codefly generate runnables documents + codefly generate runnables releases # a solution: its marked routes codefly generate runnables --check ``` diff --git a/docs/commands.md b/docs/commands.md index 26cf4e87..43aded51 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3683,8 +3683,22 @@ person without the grant. The message descriptors say what the contract is, spelled as the Connect wire carries it — JSON member names, 64-bit integers as strings (core docs/runnable.md). -The run reports each operation with its origin (`option` or `default`) and -effect, lists the methods left plain calls and why, and — when any operation +**A solution's routes derive the same way.** A solution is a module whose +backend serves its routes on an `http` endpoint and publishes them as one +OpenAPI document (Swagger 2.0, as solution-runtime-go writes it, or OpenAPI 3). +`generate runnables ` walks that document and derives every route +marked `x-codefly-operation`, through the same writers, into the same tree and +index: one directory per route, named by its operationId (the route's words +when the document gives none). solution-runtime-go marks each read a backend +serves — a route whose `x-codefly-authority-policy` requires only `read` — with +the marker core derives from that policy (`runnable.RouteMarker`), so a `GET` +read becomes a read-only Runnable whose query parameters are its input, and a +mutation, which the runtime does not mark, becomes nothing. A marked route core +refuses (a `GET` that would mutate, a payload outside the profile) is named and +fails the command, as a method carrying the option does. No flag names a route. + +The run reports each operation with its origin (`option`, `default`, or +`marker` for a route) and effect, lists the methods left plain calls and why, and — when any operation was derived by default — counts them, names the mutations, and points at `codefly.runnable.v0.exclude`. A release name longer than 50 characters is shortened deterministically to a stable prefix and an eight-digit digest, so @@ -3703,6 +3717,7 @@ contracts/runnables/ index.json one row per operation ///runnable-package.json the canonical package ///operation.json its policy and authority + ///... the same, for a route ``` `runnable-package.json` is the canonical proto3 JSON core takes the package diff --git a/docs/runnable.md b/docs/runnable.md index 00e87264..40a3eae2 100644 --- a/docs/runnable.md +++ b/docs/runnable.md @@ -53,6 +53,15 @@ Owners with restricted listeners generate the procedure inventory from their registration/routing tables and configure [the module's `contracts.codefly.yaml`](commands.md#generate-contracts) before exporting. +A solution's routes are the same derivation read from its OpenAPI document +rather than from descriptors: `generate runnables` derives every route marked +`x-codefly-operation` with core's `runnable.PackageFromOpenAPIOperation`, into +the same tree and index, and `generate runnable-bindings` prepares each keyed +`__` with a `rest` route — the verb and path — at the +solution's `http` endpoint. solution-runtime-go marks every read a backend +serves, so a solution's `GET` reads become read-only Runnables, and so tools, +with nothing hand-written (codefly-dev/cli#1184). + This is a separate path from `build runnable`, not a branch in it. A SERVICE package has no archive and no launch command: the implementation is the method itself, reached on the owner's endpoint, inside the process the owner already From 1fb442004efcd7cdfdc8a515635707008d9a0de9 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:34:59 -0400 Subject: [PATCH 2/2] test(generate): gate a solution's committed artifact end to end; name the InterfaceDocument trap TestTheCommittedArtifactBecomesTools runs generate contracts, runnables and runnable-bindings on the releases backend's artifact exactly as solution-runtime-go's Server.InterfaceArtifact renders it. A document that marks no route although its routes declare read-only policies now names each such read as not an operation. Core pinned to 518714d0, core#852's merge commit. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/generate/runnables.go | 49 +- cmd/generate/runnables_solution_test.go | 130 +++ .../solution-runnables/api.swagger.json | 742 ++++++++++++++++++ docs/cli-reference.md | 5 +- docs/commands.md | 5 + 5 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 cmd/generate/testdata/solution-runnables/api.swagger.json diff --git a/cmd/generate/runnables.go b/cmd/generate/runnables.go index 35de8ecf..36f9ab14 100644 --- a/cmd/generate/runnables.go +++ b/cmd/generate/runnables.go @@ -3,6 +3,7 @@ package generate import ( "cmp" "context" + "encoding/json" "errors" "fmt" "os" @@ -55,7 +56,10 @@ A solution publishes its routes as one OpenAPI document (Swagger 2.0 or OpenAPI 3), and every route marked x-codefly-operation derives the same way: solution-runtime-go marks each read the backend serves, so a GET read becomes a read-only tool whose query parameters are its input. Its directory is named by -the route's operationId. No flag names a route. +the route's operationId. No flag names a route. A document that marks no route +although its routes declare read-only policies — rendered by the runtime's +InterfaceDocument rather than Server.InterfaceArtifact, or by a runtime from +before the marker — names each such read as not an operation. The input is what ` + "`codefly generate contracts`" + ` already wrote — each endpoint's served procedures in the API catalog and its serialized FileDescriptorSet, or @@ -405,6 +409,7 @@ func deriveOpenAPIEndpoint(module *resources.Module, endpoint *composition.APICo paths = append(paths, path) } sort.Strings(paths) + marked := 0 for _, path := range paths { item := parsed.Paths[path] if item == nil { @@ -417,14 +422,56 @@ func deriveOpenAPIEndpoint(module *resources.Module, endpoint *composition.APICo if route.operation == nil || len(route.operation.Marker) == 0 { continue } + marked++ if err = deriveRoute(module, endpoint, owner, document, route.method, path, route.operation.OperationID, derived); err != nil { return err } } } + if marked == 0 { + derived.notices = append(derived.notices, unmarkedReads(document, endpoint)...) + } return nil } +// unmarkedReads names the reads of a document that marks no route at all +// although its routes declare policies requiring only read: the signature of +// an artifact rendered by solution-runtime-go's InterfaceDocument rather than +// Server.InterfaceArtifact, or by a runtime from before the marker existed. +// Either way every read is silently no tool, so each is named. A document that +// marks some routes and not others is the owner's choice (Operation.Exclude) +// and says nothing. +func unmarkedReads(document []byte, endpoint *composition.APIContractEndpoint) []string { + type requirement struct { + Actions []string `json:"actions"` + } + var parsed struct { + Paths map[string]map[string]struct { + Policy *struct { + Requires []requirement `json:"requires"` + } `json:"x-codefly-authority-policy"` + } `json:"paths"` + } + if json.Unmarshal(document, &parsed) != nil { + return nil + } + var notices []string + for path, methods := range parsed.Paths { + for method, operation := range methods { + if operation.Policy == nil || len(operation.Policy.Requires) == 0 || + slices.ContainsFunc(operation.Policy.Requires, func(r requirement) bool { + return len(r.Actions) != 1 || r.Actions[0] != corerunnable.ReadOnlyScopeAction + }) { + continue + } + notices = append(notices, fmt.Sprintf("%s: %s/%s requires only %q but its document marks no route x-codefly-operation; render the artifact with solution-runtime-go's Server.InterfaceArtifact (InterfaceDocument, and a runtime before the marker, mark none)", + corerunnable.Route(method, path), endpoint.Service, endpoint.Endpoint, corerunnable.ReadOnlyScopeAction)) + } + } + sort.Strings(notices) + return notices +} + // deriveRoute derives one marked route. Its directory is named by the route's // operationId — the identity the document publishes it under, as a method's // is its name — or, when the document gives none, by the route itself. diff --git a/cmd/generate/runnables_solution_test.go b/cmd/generate/runnables_solution_test.go index e1cf03e2..a8bc9af5 100644 --- a/cmd/generate/runnables_solution_test.go +++ b/cmd/generate/runnables_solution_test.go @@ -13,6 +13,7 @@ import ( runnablev0 "github.com/codefly-dev/core/generated/go/codefly/runnable/v0" "github.com/codefly-dev/core/resources" corerunnable "github.com/codefly-dev/core/runnable" + "github.com/codefly-dev/core/standards" "github.com/stretchr/testify/require" ) @@ -263,3 +264,132 @@ func TestGenerateRunnableBindingsPreparesASolutionsReads(t *testing.T) { require.Equal(t, basev0.RunnableExecution_RECOVERY_RECOMPUTE, prepared.GetPolicy().GetRecovery()) } } + +// committedArtifact is testdata/solution-runnables/api.swagger.json: the +// releases backend's interface artifact exactly as solution-runtime-go's +// Server.InterfaceArtifact renders it (codefly-dev/solution-runtime-go#77) — +// every operation core-solutions' releases solution serves, each with the +// policy it declares on the authority runtime, and its MCP server — with no +// edit. It is a snapshot of that shape; the runtime's own tests hold +// InterfaceArtifact to deriving in core. +func committedArtifact(t *testing.T) []byte { + t.Helper() + return mustRead(t, filepath.Join("testdata", "solution-runnables", "api.swagger.json")) +} + +// saveCommittedSolution places the artifact where a solution backend commits +// it (openapi/api.swagger.json beside its service.codefly.yaml), so the whole +// chain runs on it: generate contracts publishes it as the http endpoint's +// contract, extensions and all, and generate runnables reads what that wrote. +func saveCommittedSolution(t *testing.T, ctx context.Context, artifact []byte) (root, moduleDir string) { + t.Helper() + root, moduleDir = saveFixtureWorkspace(t, ctx, "releases", + &resources.ModuleInterface{Endpoints: []*resources.InterfaceEndpoint{ + {Service: "backend", Endpoint: "http", Visibility: resources.VisibilityPublic}, + }}, + &resources.Service{ + Name: "backend", + Version: "0.3.0", + Agent: &resources.Agent{Kind: resources.ServiceAgent, Name: "go", Version: "0.0.70", Publisher: "codefly.dev"}, + Endpoints: []*resources.Endpoint{ + {Name: "http", API: "http", Visibility: resources.VisibilityPublic, Exposure: resources.ExposureNone}, + }, + }, + ) + writeFixtureFile(t, filepath.Join(moduleDir, "services", "backend", filepath.FromSlash(standards.OpenAPIPath)), artifact) + writeFixtureFile(t, filepath.Join(moduleDir, composition.PackageManifestFileName), []byte(`schema: codefly/module-package/v2 +kind: module-package +id: obin/releases +version: 0.1.0 +minimum-codefly-version: ">=0.0.0" +artifact-roots: + - contracts + - services +contracts: + composition: ">=2.0.0 <3.0.0" +services: + - name: backend + endpoints: [http] +`)) + t.Chdir(root) + resetContractsFlags(t) + resetRunnablesFlags(t) + resetRunnableBindingsFlags(t) + if out, err := captureStdout(t, func() error { return ContractsCmd.RunE(ContractsCmd, []string{"releases"}) }); err != nil { + t.Fatalf("generate contracts: %v\n%s", err, out) + } + return root, moduleDir +} + +// TestTheCommittedArtifactBecomesTools gates the seam end to end rather than +// inferring it: the runtime's own artifact survives generate contracts with its +// vendor extensions, and from what that published, generate runnables derives +// exactly the two reads and generate runnable-bindings prepares exactly two GET +// calls. The writes, the any-checked-caller routes, the datasource read +// (datasource.sources:invoke, not read) and the MCP route become nothing. +func TestTheCommittedArtifactBecomesTools(t *testing.T) { + ctx := context.Background() + artifact := committedArtifact(t) + root, moduleDir := saveCommittedSolution(t, ctx, artifact) + + published := mustRead(t, filepath.Join(moduleDir, "contracts", "api", "backend", "http", "openapi.json")) + require.Equal(t, artifact, published, "generate contracts publishes the artifact byte for byte") + + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"releases"})) + index := readIndex(t, moduleDir) + methods := make([]string, 0, len(index.Operations)) + for _, entry := range index.Operations { + methods = append(methods, entry.Method) + } + require.Equal(t, []string{"GET /v1/releases/history", "GET /v1/releases/recorded"}, methods) + + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) + raw := mustRead(t, filepath.Join(root, "configurations", "local", RunnableBindingsGroup+".env")) + var keys []string + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + key, value, found := strings.Cut(line, "=") + if !found || strings.HasPrefix(line, "#") { + continue + } + keys = append(keys, key) + prepared, err := corerunnable.DecodePrepared([]byte(value)) + require.NoError(t, err) + require.Equal(t, "GET", prepared.GetCall().GetRest().GetVerb()) + tool, err := corerunnable.ToolFromPrepared(prepared) + require.NoError(t, err) + require.Equal(t, runnablev0.ToolExposure_EFFECT_READ_ONLY, tool.GetEffect()) + } + require.Equal(t, []string{ + "RELEASES__BACKEND_HTTP_RELEASES_GET_V1_RELEASES_HISTORY", + "RELEASES__BACKEND_HTTP_RELEASES_GET_V1_RELEASES_RECORDED", + }, keys) +} + +// TestAnArtifactThatMarksNothingNamesItsReads is the InterfaceDocument trap: an +// artifact whose reads declare their policies but carry no marker — rendered by +// InterfaceDocument rather than Server.InterfaceArtifact, or by a runtime from +// before the marker — derives nothing, and the run says so read by read rather +// than writing an empty index in silence. It is a notice, not a refusal: every +// solution still on such a runtime is in exactly this state. +func TestAnArtifactThatMarksNothingNamesItsReads(t *testing.T) { + ctx := context.Background() + var doc map[string]any + require.NoError(t, json.Unmarshal(committedArtifact(t), &doc)) + for _, methods := range doc["paths"].(map[string]any) { + for _, operation := range methods.(map[string]any) { + delete(operation.(map[string]any), corerunnable.OpenAPIOperationMarker) + } + } + unmarked, err := json.Marshal(doc) + require.NoError(t, err) + _, moduleDir := saveCommittedSolution(t, ctx, unmarked) + + out, err := captureStdout(t, func() error { return RunnablesCmd.RunE(RunnablesCmd, []string{"releases"}) }) + require.NoError(t, err) + require.Empty(t, readIndex(t, moduleDir).Operations) + for _, read := range []string{"GET /v1/releases/history", "GET /v1/releases/recorded"} { + require.Contains(t, out, read+": backend/http requires only \"read\" but its document marks no route x-codefly-operation") + } + require.Contains(t, out, "Server.InterfaceArtifact") + require.NotContains(t, out, "POST /v1/releases/record:", "a write is not a read the trap lost") +} diff --git a/cmd/generate/testdata/solution-runnables/api.swagger.json b/cmd/generate/testdata/solution-runnables/api.swagger.json new file mode 100644 index 00000000..665c03f1 --- /dev/null +++ b/cmd/generate/testdata/solution-runnables/api.swagger.json @@ -0,0 +1,742 @@ +{ + "consumes": [ + "application/json" + ], + "definitions": { + "AppAccess": { + "additionalProperties": false, + "properties": { + "installedOn": { + "items": { + "type": "string" + }, + "type": "array" + }, + "slug": { + "type": "string" + } + }, + "type": "object" + }, + "Board": { + "additionalProperties": false, + "properties": { + "app": { + "$ref": "#/definitions/AppAccess" + }, + "components": { + "items": { + "$ref": "#/definitions/Component" + }, + "type": "array" + }, + "observedAt": { + "type": "string" + }, + "unreadable": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ChatBinding": { + "additionalProperties": false, + "properties": { + "installation": { + "type": "string" + }, + "intent": { + "type": "string" + } + }, + "type": "object" + }, + "ChatInstallResult": { + "additionalProperties": false, + "properties": { + "imported": { + "type": "string" + } + }, + "type": "object" + }, + "Component": { + "additionalProperties": false, + "properties": { + "composition": { + "type": "string" + }, + "latest": { + "type": "string" + }, + "latestAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "pinned": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + }, + "Error": { + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + } + }, + "type": "object" + }, + "History": { + "additionalProperties": false, + "properties": { + "component": { + "type": "string" + }, + "pins": { + "items": { + "$ref": "#/definitions/PinEvent" + }, + "type": "array" + }, + "repo": { + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/definitions/TagEvent" + }, + "type": "array" + } + }, + "type": "object" + }, + "LinkBeginRequest": { + "additionalProperties": false, + "properties": { + "redirectUri": { + "type": "string" + } + }, + "type": "object" + }, + "LinkBeginResponse": { + "additionalProperties": false, + "properties": { + "authorizeUrl": { + "type": "string" + } + }, + "type": "object" + }, + "LinkCompleteRequest": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + }, + "type": "object" + }, + "PinEvent": { + "additionalProperties": false, + "properties": { + "component": { + "type": "string" + }, + "composition": { + "type": "string" + }, + "observedAt": { + "type": "string" + }, + "path": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "type": "object" + }, + "RecordResult": { + "additionalProperties": false, + "properties": { + "observedAt": { + "type": "string" + }, + "pins": { + "type": "integer" + }, + "tags": { + "type": "integer" + } + }, + "type": "object" + }, + "StatusReport": { + "additionalProperties": false, + "properties": { + "declared": { + "type": "boolean" + }, + "linked": { + "type": "boolean" + }, + "organization": { + "type": "string" + }, + "recordTypeRevision": { + "type": "string" + }, + "sourceId": { + "type": "string" + } + }, + "type": "object" + }, + "TagEvent": { + "additionalProperties": false, + "properties": { + "repo": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "taggedAt": { + "type": "string" + } + }, + "type": "object" + } + }, + "info": { + "description": "", + "title": "releases solution backend", + "version": "0.3.0" + }, + "paths": { + "/v1/releases/chat": { + "get": { + "description": "Any member of the organization\n\nThe intent and the installation the gateway stamped on the request", + "operationId": "Releases_Get_V1_Releases_Chat", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ChatBinding" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Name the Robin intent and installation the page binds its chat to", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "any_checked_caller": true + } + } + }, + "/v1/releases/chat/install": { + "post": { + "description": "An organization administrator\n\nRobin asks the host to approve each intent version before writing it", + "operationId": "Releases_Post_V1_Releases_Chat_Install", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/ChatInstallResult" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Import this solution's Robin intent into the installation", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "robin.intents", + "actions": [ + "import", + "activate" + ] + } + ] + } + } + }, + "/v1/releases/history": { + "get": { + "description": "Any member who may read records\n\nNewest first; pins only when a component is named", + "operationId": "Releases_Get_V1_Releases_History", + "parameters": [ + { + "in": "query", + "name": "repo", + "required": true, + "type": "string" + }, + { + "in": "query", + "name": "component", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/History" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Read the recorded tags of one repository and the recorded pins of one component", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "records.records", + "actions": [ + "read" + ] + } + ] + }, + "x-codefly-operation": { + "attempt_timeout": "30s", + "audience": "releases", + "backoff": "1s", + "completion": "COMPLETION_CALL", + "invoke_scopes": [ + { + "actions": [ + "read" + ], + "resource_kind": "records.records" + } + ], + "max_attempts": 3, + "recovery": "RECOVERY_RECOMPUTE", + "retryable_codes": [ + "503" + ], + "tool": { + "description": "Read the recorded tags of one repository and the recorded pins of one component", + "effect": "EFFECT_READ_ONLY", + "name": "Releases_Get_V1_Releases_History" + }, + "total_timeout": "90s" + } + } + }, + "/v1/releases/link/begin": { + "post": { + "description": "An organization administrator\n\nCreates the viewer's personal api source", + "operationId": "Releases_Post_V1_Releases_Link_Begin", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/LinkBeginRequest" + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/LinkBeginResponse" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Connect the viewer's GitHub source and begin its OAuth sign-in", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "datasource.sources", + "actions": [ + "add", + "declare" + ] + } + ] + } + } + }, + "/v1/releases/link/complete": { + "post": { + "description": "The member who began the link\n\nThe status report after the host sealed the provider's tokens", + "operationId": "Releases_Post_V1_Releases_Link_Complete", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/LinkCompleteRequest" + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/StatusReport" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Redeem the code GitHub sent back and link the account to the source", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "datasource.sources", + "actions": [ + "link" + ] + } + ] + } + } + }, + "/v1/releases/mcp": { + "post": { + "description": "Agent clients, through the authority's edge, as the person whose Work Context the request carries; every call is held to this route's policy.\n\nThe MCP Streamable HTTP transport, stateless: one JSON-RPC message per POST, answered with its result. Each tool runs as the viewer and reaches a module only through a child the authority issues through one of this solution's bindings. Any other method is refused.", + "operationId": "Releases_Post_V1_Releases_Mcp", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "type": "object" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Call the releases MCP server's tools as the viewer", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "records.records", + "actions": [ + "read" + ] + } + ] + } + } + }, + "/v1/releases/record": { + "post": { + "description": "A member who may write records\n\nCreates one event per new tag and per new pin, and says how many", + "operationId": "Releases_Post_V1_Releases_Record", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/RecordResult" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Record every tag and pin the board shows that is not recorded yet", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "records.records", + "actions": [ + "read", + "write" + ] + } + ] + } + } + }, + "/v1/releases/recorded": { + "get": { + "description": "Any member who may read records\n\nThe newest recorded pin of every component; 404 when nothing is recorded yet", + "operationId": "Releases_Get_V1_Releases_Recorded", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/Board" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Read the board as records holds it, without asking GitHub", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "records.records", + "actions": [ + "read" + ] + } + ] + }, + "x-codefly-operation": { + "attempt_timeout": "30s", + "audience": "releases", + "backoff": "1s", + "completion": "COMPLETION_CALL", + "invoke_scopes": [ + { + "actions": [ + "read" + ], + "resource_kind": "records.records" + } + ], + "max_attempts": 3, + "recovery": "RECOVERY_RECOMPUTE", + "retryable_codes": [ + "503" + ], + "tool": { + "description": "Read the board as records holds it, without asking GitHub", + "effect": "EFFECT_READ_ONLY", + "name": "Releases_Get_V1_Releases_Recorded" + }, + "total_timeout": "90s" + } + } + }, + "/v1/releases/releases": { + "get": { + "description": "A member holding datasource:invoke on the connected source\n\nThe board, read live from GitHub", + "operationId": "Releases_Get_V1_Releases_Releases", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/Board" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Read every composition's pins and every component's latest tag", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "requires": [ + { + "resource_kind": "datasource.sources", + "actions": [ + "invoke" + ] + } + ] + } + } + }, + "/v1/releases/status": { + "get": { + "description": "Any member of the organization\n\nOne object; never an error for a missing source or type, which are states the page shows", + "operationId": "Releases_Get_V1_Releases_Status", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/StatusReport" + } + }, + "401": { + "description": "The request carried no Work Context the authority checks for this solution.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "502": { + "description": "A call this operation makes to a module failed.", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "summary": "Report whether the viewer's GitHub source is connected and the event types installed", + "tags": [ + "releases" + ], + "x-codefly-authority-policy": { + "any_checked_caller": true + } + } + } + }, + "produces": [ + "application/json" + ], + "security": [ + { + "workContext": [] + } + ], + "securityDefinitions": { + "workContext": { + "description": "The signed-in person's Work Context for this solution, sent by the authority's edge. Every operation requires it.", + "in": "header", + "name": "x-codefly-work-context", + "type": "apiKey" + } + }, + "swagger": "2.0" +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fce4c7cc..b302a537 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -3025,7 +3025,10 @@ A solution publishes its routes as one OpenAPI document (Swagger 2.0 or OpenAPI 3), and every route marked x-codefly-operation derives the same way: solution-runtime-go marks each read the backend serves, so a GET read becomes a read-only tool whose query parameters are its input. Its directory is named by -the route's operationId. No flag names a route. +the route's operationId. No flag names a route. A document that marks no route +although its routes declare read-only policies — rendered by the runtime's +InterfaceDocument rather than Server.InterfaceArtifact, or by a runtime from +before the marker — names each such read as not an operation. The input is what `codefly generate contracts` already wrote — each endpoint's served procedures in the API catalog and its serialized FileDescriptorSet, or diff --git a/docs/commands.md b/docs/commands.md index 43aded51..467cfb66 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3696,6 +3696,11 @@ read becomes a read-only Runnable whose query parameters are its input, and a mutation, which the runtime does not mark, becomes nothing. A marked route core refuses (a `GET` that would mutate, a payload outside the profile) is named and fails the command, as a method carrying the option does. No flag names a route. +A document that marks **no** route although some declare a policy requiring only +`read` is the signature of an artifact rendered by solution-runtime-go's +`InterfaceDocument` instead of `Server.InterfaceArtifact`, or by a runtime from +before the marker; each such read is listed as "not an operation", naming the +fix, rather than leaving an empty index in silence. The run reports each operation with its origin (`option`, `default`, or `marker` for a route) and effect, lists the methods left plain calls and why, and — when any operation