Skip to content

feat(generate): a solution's routes become Runnables, and so tools, as a module's methods do (#1184) - #1185

Merged
antoinetoussaint-byte merged 2 commits into
mainfrom
issue-1184-generate-runnables-a-solution-s-rest-routes-become
Oct 27, 2026
Merged

antoinetoussaint-byte merged 2 commits into
mainfrom
issue-1184-generate-runnables-a-solution-s-rest-routes-become

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #1184 — the third of three PRs. With codefly-dev/core#852 (GET reads are operations; Swagger 2.0 is read as itself; RouteMarker) and codefly-dev/solution-runtime-go#77 (the artifact marks every read), the same two commands now turn a solution's REST reads into Runnables, and so tools, as they already do a module's gRPC methods. No flag names a route, as none names a method.

  • generate runnables <solution> walks each openapi endpoint of the module's API catalog (a solution backend's http endpoint, as generate contracts already publishes it) and derives every route marked x-codefly-operation with core's PackageFromOpenAPIOperation. It goes through the same writers a method does — deriveMethod's location, refusal sorting and file/index writing are extracted into derivation.location, .admitted and .record, which both deriveMethod and the new deriveRoute call — so a route and a method land in one tree, one index, one --check and one ownership rule. A route's directory is its operationId (backend/http/Releases_Get_V1_Releases_History/), its release name the route's words (backend-http-releases-get-v1-releases-history). A marked route core refuses (a GET that would mutate, a payload outside the profile) fails the command by name and writes nothing, exactly as a method carrying the option does. The report shows origin marker.
  • generate runnable-bindings needed one change: callTarget routed only rest endpoints to a REST call, and a solution's backend endpoint is api: http. An http endpoint is now called the same way — still only when it publishes an openapi contract, which verifyPublishedContract already requires. Keys are SOLUTION__OPERATION by the existing RunnableBindingKey(module.Name, entry.Name).
  • The InterfaceDocument trap is named. A document that marks no route although its routes declare policies requiring only read — what solution-runtime-go's InterfaceDocument (which marks nothing) or a runtime from before solution-runtime-go#77 produces — gets a "not an operation" notice per read naming Server.InterfaceArtifact, rather than an empty index in silence. A notice, not a refusal: every solution still on such a runtime is in exactly this state. A document that marks some reads and not others says nothing (that is Operation.Exclude).
  • Core pinned to v0.19.7-0.20261026182446-518714d046af, feat(runnable): a REST read over GET is an operation, and Swagger 2.0 is read as itself core#852's merge commit on main; pkg/conformance/matrix.json's v0.19.7 line is unchanged.

Classification

Fix at the owner: the CLI owns running core's derivation over a module's published contracts and preparing bindings. No stopgap. The dialect question was settled in core#852 — core reads Swagger 2.0, the artifacts are not migrated (evidence there: solution-runtime-go renders 2.0 on purpose, core's OpenAPICombinator and protoc-gen-openapiv2 write 2.0, core-solutions' committed contracts are 2.0, and migrating collides with obin-ai/core-solutions#451).

Docs

docs/commands.md (generate runnables: a solution's routes), docs/runnable.md (derived packages from a solution's routes, <SOLUTION>__<OPERATION>), docs/cli-reference.md (regenerated from the command help).

Test plan

  • GOWORK=off go test ./... -count=1 on the 518714d0 pin: green except pkg/gateway/dockerexec, whose Docker daemon went down locally when the disk filled (it passed in the earlier full run on d2cf8206, whose tree is identical); cmd/run's 30 ms TestANativeDeliveryAtTheDeadline… failed once under load and passes alone (×5) and as a package.
  • golangci-lint run ./cmd/generate/... — 0 issues.
  • New cmd/generate/runnables_solution_test.go: a releases-shaped Swagger 2.0 contract marked by core's RouteMarker → exactly the two GET reads derive (tree, index row, package verifies, query as input, RECOVERY_RECOMPUTE, audience, "503", read-only tool described by the Summary), idempotent; a marked GET that would mutate fails the command naming the route and writes nothing; runnable-bindings prepares exactly two values keyed RELEASES__…, each a GET REST call at the resolved local address with a read-only tool.
  • The seam on the committed artifact, gated (TestTheCommittedArtifactBecomesTools). cmd/generate/testdata/solution-runnables/api.swagger.json is the releases backend's artifact exactly as Server.InterfaceArtifact renders it on solution-runtime-go#77 (every operation the releases solution serves with the policy it declares on the authority runtime, plus its MCP server; unedited). The test commits it where a backend does (services/backend/openapi/api.swagger.json) and runs the whole chain: generate contracts publishes it byte for byte as contracts/api/backend/http/openapi.json (vendor extensions included), generate runnables derives exactly GET /v1/releases/history and GET /v1/releases/recorded from what that wrote, and generate runnable-bindings prepares exactly RELEASES__BACKEND_HTTP_RELEASES_GET_V1_RELEASES_HISTORY and …_RECORDED, each a read-only GET. It is a snapshot of the shape; solution-runtime-go's own TestTheArtifactMarksEveryReadAsARunnableOperation holds InterfaceArtifact itself to deriving in core.
  • TestAnArtifactThatMarksNothingNamesItsReads: the same artifact with its markers stripped derives nothing and names both reads (and no write). I disabled the notice and watched it fail, then restored it.

End to end against the releases solution (acceptance)

In a scratch copy of obin-ai/core-solutions main (6edcb4b7) solutions/releases: its real response types and its real operations() list, each given the policy it would declare on the authority runtime (reads records.records:read; /record read+write; /link/* datasource; /chat/install robin.intents:import,activate; /status, /chat any checked caller; /releases datasource.sources:invoke), rendered by Server.InterfaceArtifact on solution-runtime-go#77, then the codefly binary built from this branch:

$ codefly generate contracts releases
contracts: backend/http  openapi  releases solution backend  9 route(s)  sha256:31dd0e93…
$ codefly generate runnables releases
runnables: backend-http-releases-get-v1-releases-history   GET /v1/releases/history   marker  READ_ONLY  38e6ebfd…
runnables: backend-http-releases-get-v1-releases-recorded  GET /v1/releases/recorded  marker  READ_ONLY  6753f733…
Derived 2 runnable operation(s)
$ codefly generate runnable-bindings
Bound 2 runnable operation(s) for local …
RELEASES__BACKEND_HTTP_RELEASES_GET_V1_RELEASES_HISTORY={"call":{"address":"http://localhost:36201","rest":{"path":"/v1/releases/history","verb":"GET"}}, … "tool":{"name":"Releases_Get_V1_Releases_History","description":"Read the recorded tags of one repository and the recorded pins of one component","effect":"EFFECT_READ_ONLY"}, "recovery":"RECOVERY_RECOMPUTE", invoke records.records:[read], input {repo: STRING, component: STRING optional}}
RELEASES__BACKEND_HTTP_RELEASES_GET_V1_RELEASES_RECORDED=…

/record, /link/begin, /link/complete, /chat/install and the other GETs derive nothing. Deviations of the scratch copy from main, all because releases is not on the authority runtime yet: a minimal workspace.codefly.yaml (no platform-core import), the backend's service-dependencies removed, an interface: block for backend/http added (as proposals declares on #451), and contracts added to artifact-roots.

The executor gap: what each owner must admit (not opened, no issues filed)

Nothing calls a prepared REST binding yet, so these tools are prepared but unreachable from a chat. Two owners each need one change. The encoding is core's (docs/runnable.md, "The REST reader"; HTTPRoute.verb in prepared.proto): a GET carries the input object as its query, each member one parameter of that name, a string as written, a number or a boolean in its JSON spelling, no body. POST/PUT send the plain JSON body. corerunnable.VerifyPrepared already guarantees a delivered GET is a read (recompute, read-only tool, read-only invoke scopes), so neither executor needs a receipt route for it. The solution side already accepts the call: solution-runtime-go reads the Work Context from x-codefly-work-context (the header core's served seam names) and checks it for its Manifest.ID audience against the route "GET /v1/releases/history", which is the prepared operation's spelling.

  • obin-ai/module-robin (cf25e8bf).
    • services/chat/code/runnable_catalog.go:94 must stop skipping a binding whose call is rest (prepared.GetCall().GetConnect() == nil { continue }), or a solution's tool is dropped silently.
    • internal/toolowner/operation.go NewOperation must accept a PreparedCall_Rest (today it reads only GetConnect().GetProcedure() and refuses anything not starting with /).
    • send must issue the route's own verb: for a GET, the input as the query and no body. It must not send the Connect protocol header, and must read the owner's answer as plain JSON with outcomes by HTTP status (retryable_codes are statuses such as "503"), not as a Connect error document.
    • The effect id hashes c.call, which should become the operation spelling.
    • A REST mutation would additionally need the receipt route; nothing derives one today, so the minimal change is GET + RECOVERY_RECOMPUTE only.
  • obin-ai/module-runtime (85942d79). modules/runtime/services/tasks/code/pkg/runnable/transport.go:
    • routes() refuses any verb outside bodiedMethods (POST/PUT/PATCH/DELETE). It must admit GET for a recomputed binding.
    • Its send path must put the input in the query with no body.
    • The receipt URL it computes (path + ReceiptSuffix) is never read for a recompute.

Also outstanding, outside this PR: the releases solution must move onto the authority runtime (v0.0.15 → a release with solution-runtime-go#77) and declare its policies, and the regeneration of core-solutions' committed artifacts waits on obin-ai/core-solutions#451.

🤖 Generated with Claude Code

AntoineToussaint pushed a commit to codefly-dev/solution-runtime-go that referenced this pull request Oct 26, 2026
…t trap

Core is pinned to the merge commit of core#852 rather than its PR head.
README, CHANGELOG and InterfaceDocument's doc say it plainly: a solution that
commits InterfaceDocument's output publishes no tool; commit
Server.InterfaceArtifact's. codefly generate runnables names each read of a
document that marks no route (codefly-dev/cli#1185).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte marked this pull request as ready for review October 26, 2026 18:36
antoinetoussaint-byte and others added 2 commits October 27, 2026 09:36
…s 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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte force-pushed the issue-1184-generate-runnables-a-solution-s-rest-routes-become branch from 2d995ca to 1fb4420 Compare October 27, 2026 13:37
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

Triaged against today's main (604ba6a2) — still wanted, still correct, and it needs only a merge. Not closing.

Checked, so it is not taken on trust:

What it needs: merge origin/main into the branch and land. Nothing else.

On the repo's one open PR. It should not fold into #1197. The two are disjoint in responsibility — #1197 merges each composed module's fixture-composition fragment, this derives Runnables from a solution's OpenAPI routes — and they share exactly one file, docs/cli-reference.md, which both regenerate. Folding them would make one PR with two responsibilities. Land this one first, as the repo's one PR: it is the owner's non-negotiable rule ("tools are always Runnables derived from endpoint operations") applied to a solution's routes, and it is the thing on the critical path. Then #1197, re-running the cli-reference generation.

The executor gap this PR names is still exactly as described, and it is what stands between these prepared bindings and a tool call in a chat. Verified today at both owners:

  • obin-ai/module-robin modules/robin/services/chat/code/runnable_catalog.go:117 still does if prepared.GetPolicy().GetTool() == nil || prepared.GetCall().GetConnect() == nil { continue }, so a solution's REST tool is dropped silently.
  • obin-ai/module-runtime modules/runtime/services/tasks/code/pkg/runnable/transport.go:140 still has bodiedMethods = {POST, PUT, PATCH, DELETE} and refuses any other verb at line 242, so a prepared GET is refused.

Neither has an owner working on it as far as I can see.

@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 8fe8f4d Oct 27, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

generate runnables: a solution's REST routes become Runnables, and so tools, as a module's methods already do

1 participant