Skip to content

Commit 29bcbc6

Browse files
feat: implement native Runnable Builder over gRPC
1 parent 0a879b0 commit 29bcbc6

21 files changed

Lines changed: 948 additions & 563 deletions

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
The Codefly language agent for typed, finite Python operations: generate a handler, prepare its dependencies, and produce identified native packages and container build recipes.
44

5-
**Status: generation, harness, preparation and packaging are implemented; archive relocation and harness behavior are regression-tested; no version is released yet, and the CLI-to-agent gRPC surface waits on [core #472](https://github.com/codefly-dev/core/issues/472).** [docs/qualification.md](docs/qualification.md) records exactly what was proven, what was not, and where to resume; [Implementation issue #1](https://github.com/codefly-dev/runnable-python/issues/1) tracks the milestone.
5+
**Status: native authoring and packaging are implemented through the real Builder gRPC lifecycle using core #474's shared handoff. No agent version is released yet.** [Milestone 3](docs/milestone_3_builder_grpc.md) records this checkpoint; [issue #1](https://github.com/codefly-dev/runnable-python/issues/1) still tracks durable native and Kubernetes execution.
66

77
The review corrections and current validation are recorded in [milestone 2](docs/milestone_2_review_fixes.md). Earlier image/k3d observations are historical and do not qualify the Codefly/Orchestration path.
88

@@ -32,9 +32,9 @@ The CLI receives language-specific build and launch facts through the shared con
3232

3333
[Core PR #471](https://github.com/codefly-dev/core/pull/471) merged at `6a40c4bf28ac3dcebd534c32040349be96626605`. It introduces `runnable.codefly.yaml`, agent kind `codefly:runnable` (`Agent_RUNNABLE`), and the immutable `RunnablePackage` and `RunnableBinding` contracts. The agent name is `python`; distribution uses the `runnable-python` prefix.
3434

35-
[Core #472](https://github.com/codefly-dev/core/issues/472) is the shared handoff still to establish, raised out of [CLI #638](https://github.com/codefly-dev/cli/issues/638): Runnable loading over gRPC, transfer of the native command and build evidence, and precise invocation/completion framing. Until it lands this agent advertises no builder capability, `pkg/pack` writes evidence as a proposed versioned document; the CLI does not consume it yet, and [docs/protocol.md](docs/protocol.md) is this agent's implemented proposal for the framing.
35+
Core owns `RunnableLocation`, `RunnableBuild`, `PackageArtifact.command` and the invocation/result protocol. This agent advertises Builder, implements `Load`, `Create`, `RunnableBuildInputs` and native `Package`, and supplies Python generation, uv preparation and the launch command. The CLI assembles and verifies the shared package descriptor; it does not read the agent-private `runnable-build.json` evidence file.
3636

37-
The first proof generates a neutral Runnable in a separate workspace and exercises real typed I/O, first natively and then through actual invocation Jobs in disposable k3d. The baseline debugging proof is recorded in [docs/qualification.md](docs/qualification.md); the durable path through Codefly and Orchestration — and with it immutable version selection and reconnect behavior — waits on core #472 and [Orchestration #63](https://github.com/obin-ai/module-runtime/issues/63).
37+
The Builder supports the current host's native target. Image recipe helpers remain available for qualification, but image building is not exposed by this Builder yet. Durable installation, activation, invocation, recovery and Kubernetes qualification remain [Orchestration #63](https://github.com/obin-ai/module-runtime/issues/63) and [CLI #638](https://github.com/codefly-dev/cli/issues/638).
3838

3939
[Core #470](https://github.com/codefly-dev/core/issues/470) remains the parent delivery specification. The [Go agent repository](https://github.com/codefly-dev/runnable-go) exists separately; Go implementation follows the Python/native/k3d milestone. Domain-module adoption, engine changes, infra-base and canonical handbook updates remain later work.
4040

‎builder_grpc_test.go‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package main_test
2+
3+
import (
4+
"context"
5+
"io"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"runtime"
10+
"testing"
11+
"time"
12+
13+
"github.com/codefly-dev/core/agents/manager"
14+
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
15+
agentv0 "github.com/codefly-dev/core/generated/go/codefly/services/agent/v0"
16+
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
17+
"github.com/codefly-dev/core/resources"
18+
corerunnable "github.com/codefly-dev/core/runnable"
19+
"github.com/stretchr/testify/require"
20+
"google.golang.org/grpc/codes"
21+
"google.golang.org/grpc/status"
22+
"google.golang.org/protobuf/proto"
23+
)
24+
25+
func TestBuilderPackagesThePreparedSnapshotOverGRPC(t *testing.T) {
26+
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
27+
defer cancel()
28+
root := t.TempDir()
29+
t.Setenv(resources.CodeflyHomeEnv, filepath.Join(root, "home"))
30+
t.Setenv(manager.AgentSourceEnv, "local")
31+
agent, err := resources.ParseAgent(ctx, resources.RunnableAgent, "codefly.dev/python:0.0.1")
32+
require.NoError(t, err)
33+
binary, err := agent.Path(ctx)
34+
require.NoError(t, err)
35+
require.NoError(t, os.MkdirAll(filepath.Dir(binary), 0700))
36+
compile := exec.CommandContext(ctx, "go", "build", "-o", binary, ".")
37+
output, err := compile.CombinedOutput()
38+
require.NoError(t, err, string(output))
39+
connection, err := manager.Load(ctx, agent, manager.WithoutSandbox(), manager.WithoutPrincipal(), manager.WithLogWriter(io.Discard))
40+
require.NoError(t, err)
41+
defer connection.Close()
42+
client := builderv0.NewBuilderClient(connection.GRPCConn())
43+
info, err := agentv0.NewAgentClient(connection.GRPCConn()).GetAgentInformation(ctx, &agentv0.AgentInformationRequest{})
44+
require.NoError(t, err)
45+
require.Equal(t, []*agentv0.Capability{{Type: agentv0.Capability_BUILDER}}, info.GetCapabilities())
46+
47+
workspaceDir := filepath.Join(root, "workspace")
48+
require.NoError(t, os.Mkdir(workspaceDir, 0700))
49+
write(t, filepath.Join(workspaceDir, "workspace.codefly.yaml"), "kind: workspace\nname: proof\nlayout: flat\nservices: []\n")
50+
workspace, err := resources.LoadWorkspaceFromDir(ctx, workspaceDir)
51+
require.NoError(t, err)
52+
module, err := workspace.LoadModuleFromName(ctx, "proof")
53+
require.NoError(t, err)
54+
r, err := module.NewRunnable(ctx, "word-count", agent, "handler.py")
55+
require.NoError(t, err)
56+
r.Contract.Input.Fields = []*resources.RunnableField{{Name: "text", Type: resources.RunnableFieldType("string")}}
57+
r.Contract.Output.Fields = []*resources.RunnableField{{Name: "count", Type: resources.RunnableFieldType("integer")}}
58+
require.NoError(t, r.Save(ctx))
59+
location, err := workspace.RunnableLocationOf(r)
60+
require.NoError(t, err)
61+
wire, err := location.Proto()
62+
require.NoError(t, err)
63+
loaded, err := client.Load(ctx, &builderv0.LoadRequest{Runnable: wire})
64+
require.NoError(t, err)
65+
require.Equal(t, builderv0.LoadStatus_READY, loaded.GetState().GetState())
66+
created, err := client.Create(ctx, &builderv0.CreateRequest{})
67+
require.NoError(t, err)
68+
require.Equal(t, builderv0.CreateStatus_CREATED, created.GetState().GetState())
69+
require.FileExists(t, filepath.Join(r.Dir(), "handler.py"))
70+
require.FileExists(t, filepath.Join(r.Dir(), "codefly_types.py"))
71+
write(t, filepath.Join(r.Dir(), "handler.py"), "def handle(context, input):\n return {\"count\": len(input[\"text\"].split())}\n")
72+
73+
rejectedOutput := filepath.Join(r.Dir(), "build", "prepared")
74+
_, err = client.RunnableBuildInputs(ctx, &builderv0.RunnableBuildInputsRequest{OutputDirectory: rejectedOutput})
75+
require.Equal(t, codes.InvalidArgument, status.Code(err))
76+
require.NoDirExists(t, filepath.Join(r.Dir(), "build"))
77+
sourceLink := filepath.Join(root, "source-link")
78+
require.NoError(t, os.Symlink(r.Dir(), sourceLink))
79+
_, err = client.RunnableBuildInputs(ctx, &builderv0.RunnableBuildInputsRequest{OutputDirectory: filepath.Join(sourceLink, "build")})
80+
require.Equal(t, codes.InvalidArgument, status.Code(err))
81+
require.NoDirExists(t, filepath.Join(r.Dir(), "build"))
82+
83+
prepared, err := client.RunnableBuildInputs(ctx, &builderv0.RunnableBuildInputsRequest{OutputDirectory: filepath.Join(root, "prepared")})
84+
require.NoError(t, err)
85+
require.Equal(t, builderv0.RunnableBuildInputsStatus_SUCCESS, prepared.GetState().GetState())
86+
write(t, filepath.Join(r.Dir(), "handler.py"), "def handle(context, input):\n return {\"count\": 999}\n")
87+
_, err = client.Package(ctx, &builderv0.PackageRequest{Targets: []*builderv0.PackageTarget{{Os: "other", Architecture: runtime.GOARCH}}, OutputDirectory: filepath.Join(root, "unsupported")})
88+
require.Equal(t, codes.Unimplemented, status.Code(err))
89+
require.NoDirExists(t, filepath.Join(root, "unsupported"))
90+
response, err := client.Package(ctx, &builderv0.PackageRequest{OutputDirectory: filepath.Join(root, "artifacts")})
91+
require.NoError(t, err)
92+
require.Equal(t, builderv0.PackageStatus_SUCCESS, response.GetState().GetState())
93+
r, err = resources.LoadRunnableFromDir(ctx, r.Dir())
94+
require.NoError(t, err)
95+
emitted := response.GetArtifacts()[0]
96+
pkg := preparedPackage(t, r, wire.GetIdentity(), prepared.GetBuild(), []*basev0.RunnableArtifact{{
97+
Kind: basev0.RunnableArtifact_NATIVE, Platform: runtime.GOOS + "/" + runtime.GOARCH,
98+
Reference: filepath.Base(emitted.GetPath()), Digest: "sha256:" + emitted.GetSha256(), Command: emitted.GetCommand(),
99+
}})
100+
require.NoError(t, corerunnable.VerifyPackage(pkg))
101+
require.True(t, proto.Equal(prepared.GetBuild(), pkg.GetBuild()))
102+
require.True(t, proto.Equal(wire.GetIdentity(), pkg.GetIdentity()))
103+
require.Len(t, pkg.GetArtifacts(), 1)
104+
require.NotEmpty(t, pkg.GetArtifacts()[0].GetCommand())
105+
installed := filepath.Join(root, "installed")
106+
unpack(t, response.GetArtifacts()[0].GetPath(), installed)
107+
result := invoke(t, installed, pkg.GetArtifacts()[0].GetCommand(), pkg, map[string]any{"text": "one two three"})
108+
require.Equal(t, 0, result.exit, result.stderr)
109+
require.Equal(t, 3, result.count(t), "the package must execute the prepared handler")
110+
111+
write(t, filepath.Join(root, "prepared", "runnable", "handler.py"), "def handle(context, input): return {\"count\": 888}\n")
112+
_, err = client.Package(ctx, &builderv0.PackageRequest{OutputDirectory: filepath.Join(root, "tampered")})
113+
require.Equal(t, codes.FailedPrecondition, status.Code(err))
114+
require.NoDirExists(t, filepath.Join(root, "tampered"), "reject changed snapshot before emitting any archive")
115+
116+
wrong := proto.Clone(wire).(*basev0.RunnableLocation)
117+
wrong.Identity.Version = "9.0.0"
118+
_, err = client.Load(ctx, &builderv0.LoadRequest{Runnable: wrong})
119+
require.Equal(t, codes.InvalidArgument, status.Code(err))
120+
_, err = client.Package(ctx, &builderv0.PackageRequest{OutputDirectory: filepath.Join(root, "stale")})
121+
require.Equal(t, codes.FailedPrecondition, status.Code(err), "a failed load must clear the previous snapshot")
122+
}

‎docs/milestone_3_builder_grpc.md‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Milestone 3 — native Builder over gRPC
2+
3+
The Python agent can now receive a real `RunnableLocation`, scaffold author code,
4+
prepare its locked dependencies and bundled interpreter, and return a native
5+
archive with a launch command through the shared Builder protocol. The CLI can
6+
assemble the package without importing Python implementation code.
7+
8+
This checkpoint consumes core PR #474 at
9+
`b3470f0096cd818d6a90c67dc31e9ad09824d822`. The integration branch is
10+
`feat/runnable-builder-grpc`, based on merged PR #2 (`0a879b0`). No release tag is
11+
published by this work. Core #474 must settle before coordinated fleet release.
12+
13+
## Implementation
14+
15+
- [Builder](../pkg/agent/builder.go): `Load` checks the workspace-resolved release,
16+
location and pinned agent; `Create` scaffolds; `RunnableBuildInputs` prepares a
17+
snapshot and returns measured `RunnableBuild`; `Package` returns native archive
18+
metadata and `PackageArtifact.command`.
19+
- [Generation](../pkg/generate/generate.go): embeds the resolved release and its
20+
declared bounds beside the harness. Python tooling remains in this repository.
21+
- [Harness protocol](protocol.md): core's three environment variables and proto3
22+
JSON documents, base64 input/output, explicit `HandlerFailure`, atomic result.
23+
- [gRPC regression](../builder_grpc_test.go): builds and starts the real agent,
24+
verifies Builder capability, creates and packages over gRPC, edits live source,
25+
relocates and invokes the prepared archive. It also checks invalid target,
26+
source/symlink overlap, snapshot tampering and failed-Load state clearing.
27+
- [Native qualification](../qualification_test.go): core encodes the invocation
28+
and validates/classifies the real Python result; locked third-party dependencies
29+
and the bundled interpreter survive removal of the preparation environment.
30+
31+
There are no service-shaped Runnable declarations, language-specific CLI imports,
32+
mock agents or duplicate core protocol definitions. Agent-private evidence remains
33+
an internal packaging representation, not a CLI file API.
34+
35+
## Validation
36+
37+
```sh
38+
GOWORK=off go test ./...
39+
GOWORK=off go vet ./...
40+
GOWORK=off go test -race ./...
41+
uv run --with pytest --python 3.12 pytest -q pkg/harness/tests
42+
```
43+
44+
The native Go and gRPC tests pass on macOS/arm64. The harness has 57 passing real
45+
process/schema cases. The CLI companion integration creates through the actual
46+
CLI, packages through this agent, removes source/prepared files, and proves:
47+
48+
| Input | Observed result |
49+
| --- | --- |
50+
| `{"text":"one two three"}` | `SUCCEEDED`, `{"count":3}` |
51+
| `{"text":"four five"}` | `SUCCEEDED`, `{"count":2}` |
52+
| `{"text":42}` | exit 64, no result; core classifies `CRASHED`, uncertain |
53+
54+
An unexpected exception is never converted into a certain typed failure.
55+
Input/output schema errors and interruption leave no result; the launcher owns
56+
completion classification. Standalone test invocation is a debugging proof,
57+
not a production supervisor or durable task.
58+
59+
## Limits and next action
60+
61+
Native packaging supports the host OS/architecture only. Builder image build,
62+
SBOM, replacement release subjects and internal library preparation return
63+
unsupported. Docker execution is not advertised. No cluster, installation,
64+
active binding, Task or external domain effect is created by these tests.
65+
Temporary agent processes are closed by test cleanup.
66+
67+
Finish core/CLI/agent CI together, then implement the CLI's generic native
68+
supervisor and Orchestration's installed-binding invocation. Qualify deadline,
69+
cancellation, recovery and reconnect behavior before repeating through k3d.
70+
Reconcile cross-host clock policy first: this native harness preserves the
71+
original absolute deadline; core #474 currently describes clock-offset budgeting.
72+
Go agent execution, domain adoption, infrastructure and handbook changes remain
73+
later checkpoints. Earlier milestone records are historical, not current framing.

0 commit comments

Comments
 (0)