Skip to content

Commit 5bf6627

Browse files
Merge pull request #98 from codefly-dev/issue-97-report-the-artifact-a-native-start-ran-runtime
feat(runtime): report the artifact a native start ran
2 parents 953142e + 297cdab commit 5bf6627

11 files changed

Lines changed: 1313 additions & 92 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ anything from it.
127127
| `builder.go` | `Create` scaffolding, OpenAPI endpoint loading, Docker + kustomize recipes |
128128
| `uvsources.go` | the assembled build context: `[tool.uv.sources]` paths leaving the project |
129129
| `uvgitsources.go` | the git commits `uv.lock` pins, declared for the caller to mirror into the build |
130+
| `startedartifact.go` | what a native start ran, reported before *every* `StartResponse` (`runtime-started-artifact/v1`) |
130131
| `probes.go`, `probe.py` | endpoint health translated to Kubernetes probes / a local probe |
131132
| `templates/` | **what ships** — embedded via `go:embed`, rendered on `Create` |
132133
| `base/` | a checked-in sample service, embedded nowhere |
@@ -195,6 +196,5 @@ carried here:
195196
`AGENTS.md` beside what it describes, or into `.claude/skills/`. The cap and
196197
each skill's frontmatter are asserted in `conformance_test.go`, so `go test
197198
./...` enforces them with no workflow change.
198-
- There is no `CLAUDE.md`. If one is added, it is a one-line `@AGENTS.md`
199-
pointer — one canonical source, never a second copy to drift.
199+
- There is no `CLAUDE.md`; if one is added, it is a one-line `@AGENTS.md` pointer.
200200
- Treat this file as code: the PR that changes a process updates it.

‎conformance_test.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,68 @@ func TestNixBackendIsAssertedInCI(t *testing.T) {
420420
"so a runner that lost nix skips instead of failing")
421421
}
422422
}
423+
424+
// TestEveryStartResponseNamesWhatItRan protects the trap in
425+
// runtime-started-artifact/v1: core CONSUMES the report at each
426+
// `StartResponse`, so a Start path that answers STARTED without going through
427+
// `started()` stamps no artifact, and a native run then refuses a service that
428+
// is up (cli#993). The failure is silent here — the agent builds, the suite
429+
// passes, the service serves — and only shows up as a refused delivery, so the
430+
// invariant is asserted on the source rather than left to review.
431+
//
432+
// `started()` is the one legal caller. A new path that needs its own response
433+
// calls it too; a path that must NOT report is a failure, and failures go
434+
// through StartError, which discards the report by contract.
435+
func TestEveryStartResponseNamesWhatItRan(t *testing.T) {
436+
files, err := runtimeGoFiles(".")
437+
if err != nil {
438+
t.Fatalf("enumerate runtime source: %v", err)
439+
}
440+
sites := 0
441+
reported := false
442+
for _, name := range files {
443+
src, err := os.ReadFile(name)
444+
if err != nil {
445+
t.Fatalf("read %s: %v", name, err)
446+
}
447+
content := string(src)
448+
sites += strings.Count(content, "Runtime.StartResponse()")
449+
450+
// The one legal call site, and the report that must precede it in the
451+
// same body.
452+
body := startedBody(content)
453+
if body == "" {
454+
continue
455+
}
456+
if !strings.Contains(body, "Runtime.StartResponse()") {
457+
t.Errorf("%s: started() does not answer STARTED", name)
458+
}
459+
if index := strings.Index(body, "ReportStartedArtifact("); index >= 0 && index < strings.Index(body, "Runtime.StartResponse()") {
460+
reported = true
461+
} else {
462+
t.Errorf("%s: started() answers STARTED without naming what it ran first; StartResponse consumes the report, so the order is the behaviour", name)
463+
}
464+
}
465+
if sites != 1 {
466+
t.Errorf("found %d StartResponse call sites, want exactly 1 (started()); a second one is a start that names no build", sites)
467+
}
468+
if !reported {
469+
t.Error("no started() reporting the artifact was found in the runtime source")
470+
}
471+
}
472+
473+
// startedBody is the source of `func (s *Runtime) started()`, from its opening
474+
// brace to the first line that closes it at column zero. Crude, and enough: it
475+
// is one short function whose body is what the assertion above is about.
476+
func startedBody(content string) string {
477+
const signature = "func (s *Runtime) started() "
478+
start := strings.Index(content, signature)
479+
if start < 0 {
480+
return ""
481+
}
482+
rest := content[start:]
483+
if end := strings.Index(rest, "\n}\n"); end >= 0 {
484+
return rest[:end]
485+
}
486+
return rest
487+
}

‎main.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,12 @@ func (s *Service) GetAgentInformation(ctx context.Context, _ *agentv0.AgentInfor
259259
// requires the live advertisement, not the linked core version.
260260
advertisement.Contract = contract.Current()
261261
advertisement.Contract.Capabilities = append(advertisement.Contract.Capabilities, contract.DeploymentCompositionProvenance)
262+
// The Runtime names what a native start ran on its StartStatus
263+
// (startedartifact.go), so a consumer pinning the build a process runs
264+
// reads it here instead of deriving a path from the python layout. The
265+
// capability is advertised because this agent calls
266+
// ReportStartedArtifact; an agent that does not must not advertise it.
267+
advertisement.Contract.Capabilities = append(advertisement.Contract.Capabilities, contract.RuntimeStartedArtifact)
262268
return advertisement, nil
263269
}
264270

‎nix_backend_test.go‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package main
55
import (
66
"bytes"
77
"context"
8+
"os"
9+
"path/filepath"
810
"strings"
911
"testing"
1012

@@ -39,3 +41,48 @@ func TestNixBackendToolchain(t *testing.T) {
3941
testmatrix.Only("nix"),
4042
)
4143
}
44+
45+
// TestNixBackendNamesTheInterpreterItWouldRun closes the backend half of
46+
// runtime-started-artifact/v1.
47+
//
48+
// The CLI's native delivery no-ops only for a container runtime, so a nix run
49+
// goes through it and needs a report — and under nix both `uv` and the python
50+
// it resolves come from the devShell, not from the host. The claim that asking
51+
// uv "answers for every backend" is therefore asserted here rather than
52+
// reasoned about: the probe runs through the materialized devShell and must
53+
// name an absolute, regular file, which is what the consumer digests.
54+
func TestNixBackendNamesTheInterpreterItWouldRun(t *testing.T) {
55+
dir := t.TempDir()
56+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "src"), 0o755))
57+
// Dependency-free: the interpreter is what is under test, and resolving a
58+
// wheel would only make the nix job slower.
59+
require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"),
60+
[]byte("[project]\nname = \"nix-probe\"\nversion = \"0.0.0\"\nrequires-python = \">=3.10\"\n"), 0o644))
61+
require.NoError(t, os.WriteFile(filepath.Join(dir, "src", "main.py"), []byte("app = None\n"), 0o644))
62+
require.NoError(t, ensureNixFlake(dir))
63+
64+
runtime := NewRuntime(NewService())
65+
runtime.Location = dir
66+
runtime.Logger = runtime.Wool
67+
runtime.Service.SourceLocation = dir
68+
69+
testmatrix.ForEachEnvironment(t, dir,
70+
func(t *testing.T, env runners.RunnerEnvironment) {
71+
// The environment the probe observes, as Init leaves it.
72+
sync, err := env.NewProcess("uv", "sync")
73+
require.NoError(t, err)
74+
sync.WithDir(dir)
75+
var synced bytes.Buffer
76+
sync.WithOutput(&synced)
77+
require.NoError(t, sync.Run(context.Background()), "uv sync in the devShell: %s", synced.String())
78+
79+
interpreter, err := runtime.probeInterpreter(context.Background(), env, nil)
80+
require.NoError(t, err)
81+
require.True(t, filepath.IsAbs(interpreter), "%s is not absolute", interpreter)
82+
info, err := os.Stat(interpreter)
83+
require.NoError(t, err, "the consumer could not read %s", interpreter)
84+
require.True(t, info.Mode().IsRegular(), "%s is not a regular file", interpreter)
85+
},
86+
testmatrix.Only("nix"),
87+
)
88+
}

‎runtime.go‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ type Runtime struct {
6666
runnerEnvironment runners.RunnerEnvironment
6767
runner *runnerHandle
6868

69+
// interpreter is the absolute path of the python `uv run` executes the app
70+
// with, as uv resolved it for the launch that produced s.runner (see
71+
// startedartifact.go). The launch that publishes the handle publishes it,
72+
// a Start that keeps that handle re-reports it unchanged, and the teardown
73+
// that drops the environment drops it too — a stale one would name a
74+
// program nothing is running. Guarded by runnerMu, like the handle it
75+
// describes.
76+
interpreter string
77+
6978
// runnerMu serializes the runner lifecycle. Start, Stop and the supervisor
7079
// goroutines are concurrent and all replace s.runner; without it two
7180
// overlapping Starts can each stop the same process and leave one of their
@@ -659,7 +668,12 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt
659668
// an Init between two identical Starts can republish the ports,
660669
// and the process bound to the old one is not what this request
661670
// describes however unchanged the request is.
662-
return s.Base.Runtime.StartResponse()
671+
//
672+
// It still names what it runs: StartResponse consumes the report,
673+
// so keeping a process without describing it again would stamp no
674+
// artifact on this start and have a native run refuse a service
675+
// that is up and unchanged.
676+
return s.started()
663677
default:
664678
// Different inputs only reach the service through a new process:
665679
// --reload re-executes the app inside the process it was launched
@@ -722,6 +736,13 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt
722736
proc.WithEnvironmentVariables(ctx, resources.Env("CODEFLY_CONNECT_PORT", s.connectPort))
723737
}
724738

739+
// What this launch runs, asked of uv in the environment this process is
740+
// being given — the overrides above included, since one of them can move
741+
// the project environment uv resolves (startedartifact.go). It is a
742+
// property of the launch, so it is resolved here rather than in Init and
743+
// re-reported unchanged by a later Start that keeps this process.
744+
s.interpreter = s.launchedInterpreter(ctx, startEnvs)
745+
725746
// The process must outlive this RPC, so it runs under a background context
726747
// — cancellable, so Stop, Destroy and a replacement can tell the supervisor
727748
// below that the exit it is about to see was asked for.
@@ -767,13 +788,27 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt
767788
// STARTED is recorded before the supervisor is armed: revoking a status
768789
// that was never committed would leave the orchestrator reading STARTED for
769790
// a process that is already gone.
770-
resp, err := s.Base.Runtime.StartResponse()
791+
resp, err := s.started()
771792
go s.superviseRunner(runningContext, handle)
772793

773794
s.Wool.Debug("start done")
774795
return resp, err
775796
}
776797

798+
// started names what this start ran and answers STARTED.
799+
//
800+
// Every successful Start returns through here, because core consumes the
801+
// report at each StartResponse: an agent that names its build at the first
802+
// start and not at the restart reports nothing for the restart, which a
803+
// consumer refuses rather than carrying the previous process's answer over to
804+
// the one now running. The failure paths deliberately do not go through it —
805+
// StartError discards any report, so a build named for a process that never
806+
// served is never stamped on the attempt that does.
807+
func (s *Runtime) started() (*runtimev0.StartResponse, error) {
808+
s.Base.Runtime.ReportStartedArtifact(s.startedArtifact())
809+
return s.Base.Runtime.StartResponse()
810+
}
811+
777812
// defaultServeTimeout bounds how long Start waits for the service to answer.
778813
// It covers `uv run` resolving the environment, the app import and its
779814
// lifespan startup; a service that has not answered by then is not coming up.
@@ -1279,6 +1314,7 @@ func (s *Runtime) endExecution(ctx context.Context) (bool, error) {
12791314
// a fresh one rather than hand Code and the REPL a dead handle.
12801315
released = true
12811316
s.runnerEnvironment = nil
1317+
s.interpreter = ""
12821318
s.FastAPI.Service.SetActiveEnvironment(nil)
12831319
}
12841320
}

‎serve_readiness_test.go‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ func TestStartedMeansTheEndpointAnswers(t *testing.T) {
5858
t.Fatal("STARTED was returned before anything listened on the endpoint")
5959
}
6060
require.GreaterOrEqual(t, time.Since(began), 600*time.Millisecond)
61-
require.Len(t, env.started(), 1)
62-
require.False(t, env.started()[0].isStopped())
61+
require.Len(t, uvicornProcs(env), 1)
62+
require.False(t, uvicornProcs(env)[0].isStopped())
6363
}
6464

6565
// TestStartAcceptsAnyHTTPAnswer: a redirect or an error status is still an
@@ -87,10 +87,10 @@ func TestStartFailsWhenTheAppExitsBeforeServing(t *testing.T) {
8787
runtime.serveAddress = "http://" + closedAddress(t)
8888

8989
go func() {
90-
for len(env.started()) == 0 || !env.started()[0].isStarted() {
90+
for len(uvicornProcs(env)) == 0 || !uvicornProcs(env)[0].isStarted() {
9191
time.Sleep(10 * time.Millisecond)
9292
}
93-
proc := env.started()[0]
93+
proc := uvicornProcs(env)[0]
9494
_, _ = proc.output.Write([]byte("ModuleNotFoundError: No module named 'src.main'\n"))
9595
proc.die(errors.New("exit status 1"))
9696
}()
@@ -114,16 +114,16 @@ func TestStartFailsWhenTheEndpointNeverAnswers(t *testing.T) {
114114
require.NoError(t, err)
115115
require.Equal(t, runtimev0.StartStatus_ERROR, resp.GetStatus().GetState())
116116
require.Contains(t, resp.GetStatus().GetMessage(), "did not answer within")
117-
require.Len(t, env.started(), 1)
118-
require.True(t, env.started()[0].isStopped(), "the process that never served must be stopped")
117+
require.Len(t, uvicornProcs(env), 1)
118+
require.True(t, uvicornProcs(env)[0].isStopped(), "the process that never served must be stopped")
119119
require.Nil(t, currentRunner(runtime))
120120

121121
// The next Start is a fresh attempt, not an answer from the failed one.
122122
runtime.serveAddress = ""
123123
resp, err = runtime.Start(context.Background(), &runtimev0.StartRequest{})
124124
require.NoError(t, err)
125125
require.Equal(t, runtimev0.StartStatus_STARTED, resp.GetStatus().GetState(), resp.GetStatus().GetMessage())
126-
require.Len(t, env.started(), 2)
126+
require.Len(t, uvicornProcs(env), 2)
127127
}
128128

129129
// TestStartRefusesAConnectFaceNobodyServes: with connect-endpoint on, an app

0 commit comments

Comments
 (0)