Skip to content

Commit 15d351e

Browse files
Merge pull request #3 from codefly-dev/feat/runnable-builder-grpc
feat: native Runnable Builder over gRPC
2 parents 0a879b0 + 0cf1a61 commit 15d351e

25 files changed

Lines changed: 1270 additions & 530 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

‎agent.codefly.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
publisher: codefly.dev
22
kind: codefly:runnable
33
name: python
4-
version: 0.0.1
4+
version: 0.0.2

‎builder_grpc_test.go‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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.2")
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+
87+
// Preparing again into the caller's populated directory is refused by name
88+
// rather than from inside preparation, and the snapshot already prepared
89+
// survives the refusal: the Package below still emits it.
90+
_, err = client.RunnableBuildInputs(ctx, &builderv0.RunnableBuildInputsRequest{OutputDirectory: filepath.Join(root, "prepared")})
91+
require.Equal(t, codes.AlreadyExists, status.Code(err))
92+
93+
write(t, filepath.Join(r.Dir(), "handler.py"), "def handle(context, input):\n return {\"count\": 999}\n")
94+
_, err = client.Package(ctx, &builderv0.PackageRequest{Targets: []*builderv0.PackageTarget{{Os: "other", Architecture: runtime.GOARCH}}, OutputDirectory: filepath.Join(root, "unsupported")})
95+
require.Equal(t, codes.Unimplemented, status.Code(err))
96+
require.NoDirExists(t, filepath.Join(root, "unsupported"))
97+
response, err := client.Package(ctx, &builderv0.PackageRequest{OutputDirectory: filepath.Join(root, "artifacts")})
98+
require.NoError(t, err)
99+
require.Equal(t, builderv0.PackageStatus_SUCCESS, response.GetState().GetState())
100+
r, err = resources.LoadRunnableFromDir(ctx, r.Dir())
101+
require.NoError(t, err)
102+
emitted := response.GetArtifacts()[0]
103+
pkg := preparedPackage(t, r, wire.GetIdentity(), prepared.GetBuild(), []*basev0.RunnableArtifact{{
104+
Kind: basev0.RunnableArtifact_NATIVE, Platform: runtime.GOOS + "/" + runtime.GOARCH,
105+
Reference: filepath.Base(emitted.GetPath()), Digest: "sha256:" + emitted.GetSha256(), Command: emitted.GetCommand(),
106+
}})
107+
require.NoError(t, corerunnable.VerifyPackage(pkg))
108+
require.True(t, proto.Equal(prepared.GetBuild(), pkg.GetBuild()))
109+
require.True(t, proto.Equal(wire.GetIdentity(), pkg.GetIdentity()))
110+
require.Len(t, pkg.GetArtifacts(), 1)
111+
require.NotEmpty(t, pkg.GetArtifacts()[0].GetCommand())
112+
installed := filepath.Join(root, "installed")
113+
unpack(t, response.GetArtifacts()[0].GetPath(), installed)
114+
result := invoke(t, installed, pkg.GetArtifacts()[0].GetCommand(), pkg, map[string]any{"text": "one two three"})
115+
require.Equal(t, 0, result.exit, result.stderr)
116+
require.Equal(t, 3, result.count(t),
117+
"the package must execute the prepared handler, so the refused retry discarded no snapshot")
118+
119+
write(t, filepath.Join(root, "prepared", "runnable", "handler.py"), "def handle(context, input): return {\"count\": 888}\n")
120+
_, err = client.Package(ctx, &builderv0.PackageRequest{OutputDirectory: filepath.Join(root, "tampered")})
121+
require.Equal(t, codes.FailedPrecondition, status.Code(err))
122+
require.NoDirExists(t, filepath.Join(root, "tampered"), "reject changed snapshot before emitting any archive")
123+
124+
wrong := proto.Clone(wire).(*basev0.RunnableLocation)
125+
wrong.Identity.Version = "9.0.0"
126+
_, err = client.Load(ctx, &builderv0.LoadRequest{Runnable: wrong})
127+
require.Equal(t, codes.InvalidArgument, status.Code(err))
128+
_, err = client.Package(ctx, &builderv0.PackageRequest{OutputDirectory: filepath.Join(root, "stale")})
129+
require.Equal(t, codes.FailedPrecondition, status.Code(err), "a failed load must clear the previous snapshot")
130+
}

‎docs/milestone_3_builder_grpc.md‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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, failed-Load state clearing, and
27+
that preparing again into an occupied output directory is refused without
28+
discarding the snapshot already prepared.
29+
- [Native qualification](../qualification_test.go): core encodes the invocation
30+
and validates/classifies the real Python result; locked third-party dependencies
31+
and the bundled interpreter survive removal of the preparation environment.
32+
33+
There are no service-shaped Runnable declarations, language-specific CLI imports,
34+
mock agents or duplicate core protocol definitions. Agent-private evidence remains
35+
an internal packaging representation, not a CLI file API.
36+
37+
## Validation
38+
39+
```sh
40+
GOWORK=off go test ./...
41+
GOWORK=off go vet ./...
42+
GOWORK=off go test -race ./...
43+
uv run --with pytest --python 3.12 pytest -q pkg/harness/tests
44+
```
45+
46+
The native Go and gRPC tests pass on macOS/arm64. The harness has 61 passing real
47+
process/schema cases. The CLI companion integration creates through the actual
48+
CLI, packages through this agent, removes source/prepared files, and proves:
49+
50+
| Input | Observed result |
51+
| --- | --- |
52+
| `{"text":"one two three"}` | `SUCCEEDED`, `{"count":3}` |
53+
| `{"text":"four five"}` | `SUCCEEDED`, `{"count":2}` |
54+
| `{"text":42}` | exit 64, no result; core classifies `CRASHED`, uncertain |
55+
56+
An unexpected exception is never converted into a certain typed failure.
57+
Input/output schema errors and interruption leave no result; the launcher owns
58+
completion classification. Standalone test invocation is a debugging proof,
59+
not a production supervisor or durable task.
60+
61+
## Limits and next action
62+
63+
Native packaging supports the host OS/architecture only. Builder image build,
64+
SBOM, replacement release subjects and internal library preparation return
65+
unsupported. Docker execution is not advertised. No cluster, installation,
66+
active binding, Task or external domain effect is created by these tests.
67+
Temporary agent processes are closed by test cleanup.
68+
69+
Finish core/CLI/agent CI together, then implement the CLI's generic native
70+
supervisor and Orchestration's installed-binding invocation. Qualify deadline,
71+
cancellation, recovery and reconnect behavior before repeating through k3d.
72+
Reconcile cross-host clock policy first: this native harness preserves the
73+
original absolute deadline; core #474 currently describes clock-offset budgeting.
74+
Go agent execution, domain adoption, infrastructure and handbook changes remain
75+
later checkpoints. Earlier milestone records are historical, not current framing.

0 commit comments

Comments
 (0)