diff --git a/README.md b/README.md index 03d3722..35324f9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The Codefly language agent for typed, finite Go operations: generate a handler and harness, compile portable native artifacts, and emit container build recipes. -**Status: the agent process, the Builder load path, typed generation and the invocation harness are implemented. No release is published yet.** The `runnable-go` executable serves the agent lifecycle over gRPC and advertises `BUILDER`, so a runnable can be loaded, scaffolded and generated through the real agent. It advertises no Runtime: a Runnable's invocation process is supervised by its caller. [Issue #2](https://github.com/codefly-dev/runnable-go/issues/2) tracks the rest of the execution lifecycle: native packaging, build evidence and native/k3d qualification. +**Status: the agent process, the Builder load path, typed generation and the invocation harness are implemented. No release is published yet.** The `runnable-go` executable serves the agent lifecycle over gRPC and advertises `BUILDER`, so a runnable can be loaded, scaffolded and generated through the real agent. It advertises no Runtime: a Runnable is called, not supervised. [Issue #2](https://github.com/codefly-dev/runnable-go/issues/2) tracks the rest of the execution lifecycle: native packaging, build evidence and native/k3d qualification. `pkg/generate` turns a runnable's declared contract into the typed Go bindings and the author handler scaffold. The bounded profile maps to `string`, `int64`, `bool`, generated structs and `List`, and the contract's independent `optional` (the key may be absent) and `nullable` (the value may be null) declarations map to four distinct Go spellings, one per combination, so neither state can stand for the other. Generated files carry a `//go:build go1.24` constraint: absence is kept by the `encoding/json` `omitzero` option, which an older toolchain ignores in silence rather than rejecting. @@ -10,7 +10,7 @@ The Codefly language agent for typed, finite Go operations: generate a handler a `pkg/agent` serves that Builder. `Load` resolves the `RunnableLocation` the CLI sends and re-derives both the release identity and the directory from the workspace declaration, so a request naming one release while pointing at another's directory is refused rather than generated into. `Create` scaffolds the author's handler and `go.mod` without overwriting either, then writes the generated bindings and harness beside them, bound to the release `Load` resolved. `RunnableBuildInputs` and `Package` report `UNSUPPORTED`, the contract's own word for a phase an agent does not implement: this agent generates the harness but neither compiles nor archives it, so an artifact digest and a launch command could only be guessed. -`harness.go` and `codefly/main.go` are the generated invocation harness: the `codefly.runnable/v1` framing, the payload validation the bindings do not do, and the process lifecycle a launcher observes. Unlike the Python agent, which ships a harness package into the artifact, a Go harness is generated as standard-library-only source and compiled into the runnable — a contract carried beside a compiled binary could be edited after the build that measured it, while one compiled in is covered by the digest of the artifact that carries it. The harness shares the author's package so the caller's invocation identity reaches `Handle` through `InvocationFrom(ctx)`; the executable is a separate `main` because an imported package cannot also be one. +`harness.go` and `codefly/main.go` are the generated invocation harness: it **serves** the operation over Connect/HTTP JSON on the procedure core pins, carrying the payload validation the bindings do not do and the call lifecycle a caller observes. A call is the bounded input document as the whole body, with the Work Context, the effect identity and the deadline as core's own headers; a success answers with the bounded output document the same way. The launcher framing it replaced — `codefly.runnable/v1`, two document paths in the environment and a result file — is gone with the native placement it existed for. Unlike the Python agent, which ships a harness package into the artifact, a Go harness is generated as standard-library-only source and compiled into the runnable — a contract carried beside a compiled binary could be edited after the build that measured it, while one compiled in is covered by the digest of the artifact that carries it. The harness shares the author's package so the caller's invocation identity reaches `Handle` through `InvocationFrom(ctx)`; the executable is a separate `main` because an imported package cannot also be one. Bindings do not replace payload validation. The generated types carry the value types, the null states and the array states and nothing more, so the harness enforces required fields, undeclared keys, the signed 64-bit integer range and the refusal to coerce against the raw payload before decoding input or accepting output. @@ -31,9 +31,9 @@ The Go agent uses the same language-neutral handoff as Python. Adding it must no ## Shared baseline and delivery -This repository builds against `codefly-dev/core v0.3.31`. [Core PR #471](https://github.com/codefly-dev/core/pull/471) introduced `runnable.codefly.yaml`, agent kind `codefly:runnable` (`Agent_RUNNABLE`) and the immutable `RunnablePackage` and `RunnableBinding` contracts; [#473](https://github.com/codefly-dev/core/pull/473) and [#474](https://github.com/codefly-dev/core/pull/474) then closed [core #472](https://github.com/codefly-dev/core/issues/472), carrying a `RunnableLocation` through `Builder.Load`, adding `Builder.RunnableBuildInputs`, returning the native launch command from `Builder.Package`, and freezing the `codefly.runnable/v1` invocation framing. The agent name is `go`; distribution uses the `runnable-go` prefix. +This repository builds against `codefly-dev/core v0.3.31`. [Core PR #471](https://github.com/codefly-dev/core/pull/471) introduced `runnable.codefly.yaml`, agent kind `codefly:runnable` (`Agent_RUNNABLE`) and the immutable `RunnablePackage` and `RunnableBinding` contracts; [#473](https://github.com/codefly-dev/core/pull/473) and [#474](https://github.com/codefly-dev/core/pull/474) then closed [core #472](https://github.com/codefly-dev/core/issues/472), carrying a `RunnableLocation` through `Builder.Load`, adding `Builder.RunnableBuildInputs`, returning the archive's start command from `Builder.Package`, and freezing the invocation contract, which is now `codefly.runnable.served/v1`. The agent name is `go`; distribution uses the `runnable-go` prefix. -The agent/CLI interface and the invocation framing are now merged in core, and [runnable-python](https://github.com/codefly-dev/runnable-python) has qualified them with a working harness and native packaging. This repository implements that same proven contract — reusing its framing rather than defining a Go-specific one — and repeats the native and actual Kubernetes invocation path with a separate generated Go example. +The agent/CLI interface and the invocation framing are now merged in core, and [runnable-python](https://github.com/codefly-dev/runnable-python) has qualified them with a working harness and native packaging. This repository implements that same proven contract — reusing its seam rather than defining a Go-specific one — and both harnesses serve the identical routes and headers, which a test reads from core rather than restating. Qualification includes real typed I/O, cross-language schema compatibility, failure/interruption, immutable version selection, reconnect behavior and scoped cleanup. Direct binary execution or a manually submitted Kubernetes Job alone does not establish the durable execution path. diff --git a/go.mod b/go.mod index 15f80f4..7f1a7d4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/codefly-dev/runnable-go go 1.27.0 require ( - github.com/codefly-dev/core v0.5.1 + github.com/codefly-dev/core v0.5.11-0.20260928114826-ae201a2288f1 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 ) @@ -83,8 +83,8 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.2.1 // indirect - github.com/tklauser/go-sysconf v0.3.16 // indirect - github.com/tklauser/numcpus v0.11.0 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/go.sum b/go.sum index b84dad6..1a9f766 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codefly-dev/core v0.5.1 h1:iDoG1cIm28GhL6msVyTMgInHwV+an28HaaEbTNrnCLs= github.com/codefly-dev/core v0.5.1/go.mod h1:uG0HQy5iWnkuWUPa1LbbiPfEgzuxi1SzEKwcFPC6eoA= +github.com/codefly-dev/core v0.5.11-0.20260928114826-ae201a2288f1 h1:rIswIHavnUvU+SMsDro+eplJn+6NdKnSs1VXJNXA8co= +github.com/codefly-dev/core v0.5.11-0.20260928114826-ae201a2288f1/go.mod h1:SVF+t040X+9pAXAqBnNE/JkXwmw9peVuKnHKFSOzyzk= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -234,8 +236,12 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 5ee97eb..ffc51a7 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -28,7 +28,7 @@ import ( // readMe is what a human or model reads to decide what it may ask of this // process, so it states the current surface rather than the roadmap. const readMe = `Codefly Runnable agent for Go: typed handler scaffolding, typed ` + - `bindings for the bounded contract profile and the ` + resources.RunnableProtocolV1 + + `bindings for the bounded contract profile and the ` + resources.RunnableServedProtocolV1 + ` invocation harness, generated into the runnable's own Go module, through ` + `Builder gRPC. diff --git a/pkg/generate/bindings_test.go b/pkg/generate/bindings_test.go index 3a1b262..51ef3d6 100644 --- a/pkg/generate/bindings_test.go +++ b/pkg/generate/bindings_test.go @@ -16,7 +16,7 @@ func field(name string, t resources.RunnableFieldType) *resources.RunnableField func contractOf(input, output []*resources.RunnableField) *resources.RunnableContract { return &resources.RunnableContract{ - Protocol: resources.RunnableProtocolV1, + Protocol: resources.RunnableServedProtocolV1, Input: &resources.RunnableSchema{Fields: input}, Output: &resources.RunnableSchema{Fields: output}, } diff --git a/pkg/generate/harness.go b/pkg/generate/harness.go index 0d2a3be..e87c3a5 100644 --- a/pkg/generate/harness.go +++ b/pkg/generate/harness.go @@ -7,6 +7,7 @@ import ( basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/resources" + corerunnable "github.com/codefly-dev/core/runnable" ) // Files of the generated invocation harness. @@ -16,7 +17,7 @@ const ( // invocation identity can reach the handler through its context without an // exported setter the author could call by mistake. HarnessFile = "harness.go" - // CommandDirectory holds the executable a launcher starts. The Go tool + // CommandDirectory holds the executable that serves the runnable. The Go tool // ignores a directory whose name begins with a dot or an underscore, so // this one cannot be spelled the way the Python agent spells its generated // directory. @@ -25,9 +26,9 @@ const ( CommandFile = "main.go" ) -// Harness renders the invocation harness of a runnable: the protocol framing, -// the payload validation the bindings do not do, and the process lifecycle a -// launcher observes. +// Harness renders the invocation harness of a runnable: the served routes, the +// payload validation the bindings do not do, and the call lifecycle a caller +// observes. // // Every fact the harness enforces is rendered into it rather than read at // runtime from a document beside it. A Go runnable is compiled, so a contract @@ -64,11 +65,15 @@ func Harness(pkg string, runnable *resources.Runnable, identity *basev0.Runnable b.WriteString("\n// The framing this harness implements, and the bounds and policies its\n") b.WriteString("// declaration sets. They are constants so the binary cannot be pointed at a\n") b.WriteString("// contract other than the one the build measured.\nconst (\n") - fmt.Fprintf(&b, "\tharnessProtocol = %q\n", resources.RunnableProtocolV1) + fmt.Fprintf(&b, "\tharnessProtocol = %q\n", resources.RunnableServedProtocolV1) fmt.Fprintf(&b, "\tharnessMaxInputBytes = %d\n", runnable.Execution.MaxInputBytes()) fmt.Fprintf(&b, "\tharnessMaxOutputBytes = %d\n", runnable.Execution.MaxOutputBytes()) fmt.Fprintf(&b, "\tharnessRecovery = %q\n", string(runnable.Execution.Recovery)) fmt.Fprintf(&b, "\tharnessCancellation = %q\n", string(runnable.Execution.Cancellation)) + // The declared timeout bounds one call. It is compiled in for the same + // reason the bounds are: a caller may shorten it with a deadline header and + // may never overrule the author with a longer one. + fmt.Fprintf(&b, "\tharnessDeclaredTimeout = %d * time.Nanosecond\n", runnable.Execution.GetTimeout()) b.WriteString(")\n") b.WriteString("\n// harnessRelease is the release this package implements. An invocation\n") @@ -86,12 +91,21 @@ func Harness(pkg string, runnable *resources.Runnable, identity *basev0.Runnable b.WriteString(renderFields(runnable.Contract.Output.Fields, 0)) b.WriteString("\n") b.WriteString(harnessBody) + // The receipt route is the one place the two shapes of the harness differ. + // A package whose uncertain outcome is resolved by recomputing has no + // receipt to serve and no ReceiptOf for the route to call, so rendering it + // anyway would not compile. + if runnable.Execution.Recovery == resources.RunnableRecoveryReceipt { + b.WriteString(harnessReceiptBody) + } else { + b.WriteString(harnessNoReceiptBody) + } return format.Source([]byte(b.String())) } -// Command renders the executable a launcher starts. It is a separate main -// package because the harness shares the author's package, and a package that -// is imported cannot also be a main. +// Command renders the executable that serves this runnable. It is a separate +// main package because the harness shares the author's package, and a package +// that is imported cannot also be a main. func Command(pkg string) ([]byte, error) { if err := validatePackageName(pkg); err != nil { return nil, err @@ -99,7 +113,7 @@ func Command(pkg string) ([]byte, error) { var b strings.Builder fmt.Fprintf(&b, "//go:build %s\n\n", GoRequirement) b.WriteString("// Code generated by runnable-go. DO NOT EDIT.\n\n") - b.WriteString("// Command codefly runs one invocation of the runnable.\npackage main\n\n") + b.WriteString("// Command codefly serves the runnable's operation.\npackage main\n\n") fmt.Fprintf(&b, "import (\n\t\"os\"\n\n\t%q\n)\n\n", pkg) fmt.Fprintf(&b, "func main() {\n\tos.Exit(%s.Main())\n}\n", pkg) return format.Source([]byte(b.String())) @@ -147,17 +161,15 @@ func renderField(field *resources.RunnableField, depth int) string { const harnessImports = `import ( "bytes" "context" - "encoding/base64" "encoding/json" "errors" "fmt" "io" - "io/fs" + "net/http" "os" "os/signal" - "path/filepath" - "regexp" "strings" + "sync" "syscall" "time" ) @@ -171,36 +183,35 @@ const harnessImports = `import ( // // It carries no backquote, being itself a Go raw string literal. const harnessBody = ` -// The environment a launcher adds to this process. Nothing else in it is part -// of the framing. +// The address this harness listens on. It is the one fact it cannot derive: +// the port is allocated by whatever placed it, and a harness choosing its own +// would be a port that is right on one machine for ten minutes. +const harnessEnvAddress = "` + corerunnable.ListenAddressEnv + `" + +// The routes it serves, and the headers one call's per-call facts travel under. +// All six are core's constants (codefly-dev/core runnable/served.go), restated +// here because a generated harness carries no import of core: the binding that +// dials this harness names the same strings, and a harness serving one spelling +// while its binding names another is an unreachable owner rather than a visible +// mistake. const ( - harnessEnvProtocol = "CODEFLY__RUNNABLE_PROTOCOL" - harnessEnvInvocationPath = "CODEFLY__RUNNABLE_INVOCATION" - harnessEnvResultPath = "CODEFLY__RUNNABLE_RESULT" -) + harnessInvokeProcedure = "` + corerunnable.ServedInvokeProcedure + `" + harnessLookupProcedure = "` + corerunnable.ServedLookupProcedure + `" -// Exit codes are diagnostics, not outcomes: a launcher classifies an -// invocation from the result document, its own termination reason and the exit -// status together. These are the codes the Python harness reports for the same -// conditions, so one launcher reads both. -const ( - harnessExitCompleted = 0 - harnessExitInvalidInput = 64 - harnessExitInvalidOutput = 65 - harnessExitFailed = 66 - harnessExitTimeout = 67 - harnessExitInterrupted = 68 - harnessExitProtocol = 69 + harnessWorkContextHeader = "` + corerunnable.WorkContextHeader + `" + harnessEffectHeader = "` + corerunnable.EffectHeader + `" + harnessDeadlineHeader = "` + corerunnable.DeadlineHeader + `" + harnessFailureCodeHeader = "` + corerunnable.FailureCodeHeader + `" ) // harnessEnvelopeBytes is how much framing is read around the payload bound, so // an oversized request is refused without being read whole. const harnessEnvelopeBytes = 64 * 1024 -// harnessResultMode leaves to the facility which accounts may read the result. -// A temporary file is private to this process, so the mode is set before the -// rename rather than inherited from it. -const harnessResultMode = 0o644 +// harnessShutdownGrace is how long a call in flight has to finish after a +// signal. A handler interrupted mid-effect leaves it unproven, so the process +// waits rather than making every call in flight unproven at once. +const harnessShutdownGrace = 30 * time.Second // Release identifies a runnable release. type Release struct { @@ -210,15 +221,20 @@ type Release struct { Version string } -// Invocation is what the caller said about this run. The identity is the +// Invocation is what the caller said about this call. The identity is the // caller's: an effect recorded under EffectID is the one the caller looks up // when an outcome is uncertain. +// +// WorkContext is the caller's minted capability, carried verbatim. It is never +// parsed or reconstructed here — a handler forwards it to whatever it calls in +// turn, and that is the whole of what this harness does with it. type Invocation struct { - ID string - IntentID string - EffectID string - Release Release - Deadline time.Time + ID string + IntentID string + EffectID string + Release Release + Deadline time.Time + WorkContext string } type harnessInvocationKey struct{} @@ -230,8 +246,11 @@ func InvocationFrom(ctx context.Context) (Invocation, bool) { } // Failure is an operation's explicit, certain failure: the handler knows what -// became of the effect. Every other error leaves the effect unproven, so it -// writes no result at all rather than a failure a caller would trust. +// became of the effect. It is the ONLY thing that sets the failure-code header, +// and that header is the only thing that proves to a caller that nothing +// committed. Every other error — a panic, a deadline, an output that does not +// validate — leaves the effect unproven, because the harness cannot know which +// side of its effect the handler stopped on. type Failure struct { Code string Message string @@ -246,337 +265,203 @@ func Fail(code string, message string) error { return &Failure{Code: code, Message: message} } -// harnessOutcome is what one invocation produced, before it becomes a result -// document and an exit code. -type harnessOutcome struct { - code int - encoded []byte - failure *Failure - diagnostic string -} - -// Main runs one invocation and returns the process exit code. +// Main serves this runnable until it is signalled, and returns the process exit +// code. One call carries one invocation; nothing here is per-process state, so +// concurrent calls are independent. func Main() int { - // Interruption is taken from the first instant of the process, not from - // the moment the handler starts. A launcher may signal at any time, and - // between exec and that moment the default disposition would kill a - // package that declares it can stop cleanly. - // - // A declaration claiming no cancellation installs no handler, so the - // process dies on the signal and the launcher sees that, rather than this - // harness reporting an interruption it did not handle. - interrupted := make(chan os.Signal, 1) - if harnessCancellation == "signal" { - signal.Notify(interrupted, os.Interrupt, syscall.SIGTERM) - defer signal.Stop(interrupted) - } - - if os.Getenv(harnessEnvProtocol) != harnessProtocol { - return harnessRefuse(fmt.Sprintf("%s is %q, not %q", harnessEnvProtocol, os.Getenv(harnessEnvProtocol), harnessProtocol)) - } - invocationPath, err := harnessAbsolutePath(harnessEnvInvocationPath) - if err != nil { - return harnessRefuse(err.Error()) - } - resultPath, err := harnessAbsolutePath(harnessEnvResultPath) - if err != nil { - return harnessRefuse(err.Error()) - } - // A result at this path is this invocation's or it is nothing. Several - // uncertain outcomes write none, so an earlier attempt's document left - // here would be read as this run's proven result. A path the harness - // cannot clear is one it cannot own, so it refuses to start rather than - // work toward a result it may not be able to report. - if err := os.Remove(resultPath); err != nil && !errors.Is(err, fs.ErrNotExist) { - return harnessRefuse(fmt.Sprintf("clear the result path: %v", err)) + address := os.Getenv(harnessEnvAddress) + if address == "" { + fmt.Fprintf(os.Stderr, "[codefly] %s is required: the port is allocated by whatever placed this runnable\n", harnessEnvAddress) + return 1 } - raw, err := harnessReadRequest(invocationPath) - if err != nil { - return harnessRefuse(err.Error()) - } - request, err := harnessParseRequest(raw) - if err != nil { - return harnessRefuse(err.Error()) - } + mux := http.NewServeMux() + mux.HandleFunc(harnessInvokeProcedure, harnessServeInvoke) + harnessServeReceipts(mux) + server := &http.Server{Addr: address, Handler: mux, ReadHeaderTimeout: 10 * time.Second} - result := harnessInvoke(request, interrupted) - if result.diagnostic != "" { - fmt.Fprintf(os.Stderr, "[codefly] %s\n", result.diagnostic) - } - document, reported := harnessResultDocument(request, result) - if !reported { - return result.code - } - if err := harnessWriteResult(resultPath, document); err != nil { - fmt.Fprintf(os.Stderr, "[codefly] invalid_output: %v\n", err) - return harnessExitInvalidOutput - } - return result.code -} + signalled := make(chan os.Signal, 1) + signal.Notify(signalled, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signalled) -// harnessRefuse reports a request or an environment that does not satisfy the -// framing. Nothing there is bound to an invocation identity, so there is no -// result to write: the launcher reads the exit code alone. -func harnessRefuse(message string) int { - fmt.Fprintf(os.Stderr, "[codefly] %s\n", message) - return harnessExitProtocol -} + var once sync.Once + failed := make(chan error, 1) + go func() { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + failed <- err + } + }() -func harnessAbsolutePath(variable string) (string, error) { - value := os.Getenv(variable) - if value == "" { - return "", fmt.Errorf("%s is required: the launcher chooses both document paths", variable) - } - if !filepath.IsAbs(value) { - return "", fmt.Errorf("%s must be an absolute path", variable) + code := 0 + select { + case err := <-failed: + fmt.Fprintf(os.Stderr, "[codefly] serve: %v\n", err) + code = 1 + case <-signalled: + // Calls already in flight are given their grace: cutting them off would + // turn every one of them into an unproven effect at once, which is the + // outcome this harness spends the rest of its code avoiding. + ctx, cancel := context.WithTimeout(context.Background(), harnessShutdownGrace) + defer cancel() + once.Do(func() { + if err := server.Shutdown(ctx); err != nil { + fmt.Fprintf(os.Stderr, "[codefly] shutdown: %v\n", err) + code = 1 + } + }) } - return value, nil + return code } -// harnessReadRequest reads at most the payload bound plus the framing around -// it. The bound is on the decoded payload, so the encoded document is read to -// the base64 expansion of that bound. -func harnessReadRequest(path string) ([]byte, error) { - file, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open the invocation: %v", err) +// harnessServeInvoke answers one call. The body is the bounded input document +// itself, with no envelope around it, and a success answers with the bounded +// output document the same way. +func harnessServeInvoke(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + harnessUnproven(w, http.StatusMethodNotAllowed, "unimplemented", "the operation is called with POST") + return } - defer file.Close() - limit := 4*((harnessMaxInputBytes+2)/3) + harnessEnvelopeBytes - raw, err := io.ReadAll(io.LimitReader(file, int64(limit)+1)) + request, err := harnessReadCall(r) if err != nil { - return nil, fmt.Errorf("read the invocation: %v", err) + // Nothing reached the handler, so nothing committed — but "proven not + // committed" is the owner asserting it in the operation's own + // vocabulary, and a refused request is not the operation speaking. A + // caller reads this as unproven and retries under its budget, which is + // harmless for a request that never ran. + harnessUnproven(w, http.StatusBadRequest, "invalid_argument", err.Error()) + return } - if len(raw) > limit { - return nil, errors.New("request exceeds the payload plus envelope byte bound") + + result := harnessRun(r.Context(), request) + switch { + case result.failure != nil: + harnessProvenFailure(w, result.failure) + case result.code == harnessExitCompleted: + harnessAnswer(w, result.encoded) + case result.code == harnessExitTimeout: + harnessUnproven(w, http.StatusGatewayTimeout, "deadline_exceeded", result.diagnostic) + case result.code == harnessExitInvalidInput: + harnessUnproven(w, http.StatusBadRequest, "invalid_argument", result.diagnostic) + default: + harnessUnproven(w, http.StatusInternalServerError, "internal", result.diagnostic) + } + if result.diagnostic != "" { + fmt.Fprintf(os.Stderr, "[codefly] %s\n", result.diagnostic) } - return raw, nil } -// harnessRequest is one validated invocation document. -type harnessRequest struct { +// harnessCall is one validated call. +type harnessCall struct { invocation Invocation - payload []byte + input Input decoded map[string]any budget time.Duration } -var harnessInstant = regexp.MustCompile("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]{1,9})?(Z|[+-][0-9]{2}:[0-9]{2})$") - -// harnessRequestFields is the closed set of keys an invocation may carry. A key -// outside it is a launcher writing a framing this harness does not implement, -// which is refused rather than ignored. -var harnessRequestFields = map[string]bool{ - "protocol": true, "runnable": true, "invocation_id": true, "intent_id": true, - "effect_id": true, "issued_at": true, "deadline": true, "input": true, -} - -func harnessParseRequest(raw []byte) (harnessRequest, error) { - var empty harnessRequest - value, err := harnessDecode(raw) - if err != nil { - return empty, fmt.Errorf("request is not UTF-8 JSON: %v", err) - } - document, ok := value.(map[string]any) - if !ok { - return empty, errors.New("request must be an object") - } - if document["protocol"] != harnessProtocol { - return empty, fmt.Errorf("request protocol %v is not %q", document["protocol"], harnessProtocol) - } - for key := range document { - if !harnessRequestFields[key] { - return empty, fmt.Errorf("request carries the undeclared field %q", key) - } - } - id, err := harnessIdentifier(document, "invocation_id", true) +// harnessReadCall reads and validates the per-call facts and the bounded body. +func harnessReadCall(r *http.Request) (harnessCall, error) { + var empty harnessCall + limit := harnessMaxInputBytes + harnessEnvelopeBytes + raw, err := io.ReadAll(io.LimitReader(r.Body, int64(limit)+1)) if err != nil { - return empty, err + return empty, fmt.Errorf("read the request: %v", err) } - intent, err := harnessIdentifier(document, "intent_id", true) - if err != nil { - return empty, err + if len(raw) > harnessMaxInputBytes { + return empty, fmt.Errorf("input is %d bytes, over the declared %d byte bound", len(raw), harnessMaxInputBytes) } - effect, err := harnessIdentifier(document, "effect_id", false) - if err != nil { - return empty, err + + workContext := r.Header.Get(harnessWorkContextHeader) + if workContext == "" { + // A call with no identity is the one thing the contract exists to + // prevent, so it is refused rather than run under whatever identity + // this process happens to have. + return empty, errors.New("the call carries no Work Context") } - // An effect identity is what an uncertain outcome of an effectful - // operation is resolved with, so a package declaring receipt recovery - // cannot run an invocation that carries none. + effect := r.Header.Get(harnessEffectHeader) if harnessRecovery == "receipt" && effect == "" { - return empty, errors.New("effect_id is required for receipt recovery") - } - release, err := harnessReleaseOf(document["runnable"]) - if err != nil { - return empty, err + return empty, errors.New("an effect identity is required for receipt recovery") } - issued, err := harnessTimestamp(document, "issued_at") - if err != nil { - return empty, err - } - deadline, err := harnessTimestamp(document, "deadline") - if err != nil { - return empty, err - } - if !deadline.After(issued) { - return empty, errors.New("request.deadline must be after issued_at") - } - payload, err := harnessPayload(document["input"]) - if err != nil { - return empty, err - } - decoded, err := harnessDecode(payload) - if err != nil { - return empty, fmt.Errorf("request.input is not UTF-8 JSON: %v", err) - } - object, ok := decoded.(map[string]any) - if !ok { - return empty, errors.New("request.input must decode to one JSON object") - } - return harnessRequest{ - invocation: Invocation{ID: id, IntentID: intent, EffectID: effect, Release: release, Deadline: deadline}, - payload: payload, - decoded: object, - // The budget is the span the caller allowed, measured from the moment - // this harness read the document rather than against the local clock. - // An offset between the launcher's clock and this one then neither - // shortens nor extends the work the caller asked for. - budget: deadline.Sub(issued), - }, nil -} -func harnessIdentifier(document map[string]any, key string, required bool) (string, error) { - raw, carried := document[key] - if !carried { - if required { - return "", fmt.Errorf("request.%s is required", key) + // The deadline bounds the work. It is optional on the wire because a caller + // may not have one; absent, the declared timeout is the bound, which is the + // author's own statement about how long this operation may run. + budget := harnessDeclaredTimeout + if raw := r.Header.Get(harnessDeadlineHeader); raw != "" { + deadline, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return empty, fmt.Errorf("%s is not an RFC 3339 instant", harnessDeadlineHeader) } - return "", nil - } - value, ok := raw.(string) - if !ok || len(value) > 128 || (required && value == "") { - bound := "0" - if required { - bound = "1" + // Measured from now rather than from an instant the caller stamped, so + // an offset between the two clocks neither shortens nor extends the + // work the caller asked for beyond what it actually still allows. + if budget = time.Until(deadline); budget <= 0 { + return empty, errors.New("the deadline had passed before the call was read") } - return "", fmt.Errorf("request.%s must contain %s to 128 characters", key, bound) - } - return value, nil -} - -func harnessReleaseOf(raw any) (Release, error) { - var empty Release - document, ok := raw.(map[string]any) - if !ok { - return empty, errors.New("request.runnable requires name, module, workspace and version") - } - fields := map[string]string{} - for _, key := range []string{"name", "module", "workspace", "version"} { - value, carried := document[key].(string) - if !carried || value == "" { - return empty, errors.New("request.runnable identity fields must be nonempty strings") + if budget > harnessDeclaredTimeout { + budget = harnessDeclaredTimeout } - fields[key] = value - } - if len(document) != len(fields) { - return empty, errors.New("request.runnable requires name, module, workspace and version") - } - release := Release{Name: fields["name"], Module: fields["module"], Workspace: fields["workspace"], Version: fields["version"]} - expected := harnessRelease - // Standalone generation has no resolved workspace to compare against. - if expected.Workspace == "" { - expected.Workspace = release.Workspace - } - if release != expected { - return empty, errors.New("request names another Runnable release") } - return release, nil -} -func harnessTimestamp(document map[string]any, key string) (time.Time, error) { - raw, ok := document[key].(string) - if !ok || raw == "" { - return time.Time{}, fmt.Errorf("request.%s is required", key) - } - if !harnessInstant.MatchString(raw) { - return time.Time{}, fmt.Errorf("request.%s is not an RFC 3339 instant", key) - } - value, err := time.Parse(time.RFC3339Nano, raw) + decoded, err := harnessDecode(raw) if err != nil { - return time.Time{}, fmt.Errorf("request.%s is not an RFC 3339 instant", key) + return empty, fmt.Errorf("request body is not UTF-8 JSON: %v", err) } - return value.UTC(), nil -} - -func harnessPayload(raw any) ([]byte, error) { - encoded, ok := raw.(string) + object, ok := decoded.(map[string]any) if !ok { - return nil, errors.New("request.input must be base64 text") - } - payload, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return nil, fmt.Errorf("request.input is not base64: %v", err) + return empty, errors.New("request body must be one JSON object") } - if len(payload) > harnessMaxInputBytes { - return nil, fmt.Errorf("input is %d bytes, over the declared %d byte bound", len(payload), harnessMaxInputBytes) - } - return payload, nil -} - -// harnessInvoke validates the payload, runs the handler under the caller's -// budget and validates what it returned. -func harnessInvoke(request harnessRequest, interrupted chan os.Signal) harnessOutcome { - if err := harnessValidate(harnessInputSchema, request.decoded, "input"); err != nil { - return harnessOutcome{code: harnessExitInvalidInput, diagnostic: "invalid_input: " + err.Error()} + if err := harnessValidate(harnessInputSchema, object, "input"); err != nil { + return empty, err } var in Input // The schema admitted the payload, so a binding refusing it is a state the // contract forbids that the schema alone does not describe. - if err := json.Unmarshal(request.payload, &in); err != nil { - return harnessOutcome{code: harnessExitInvalidInput, diagnostic: "invalid_input: " + err.Error()} - } + if err := json.Unmarshal(raw, &in); err != nil { + return empty, fmt.Errorf("input: %v", err) + } + return harnessCall{ + invocation: Invocation{ + EffectID: effect, + Release: harnessRelease, + Deadline: time.Now().Add(budget), + WorkContext: workContext, + }, + input: in, + decoded: object, + budget: budget, + }, nil +} - result, out := harnessRun(request, in, interrupted) - if result.code != harnessExitCompleted { - return result - } - encoded, err := json.Marshal(out) - if err != nil { - return harnessOutcome{code: harnessExitInvalidOutput, diagnostic: "invalid_output: " + err.Error()} - } - if len(encoded) > harnessMaxOutputBytes { - return harnessOutcome{code: harnessExitInvalidOutput, - diagnostic: fmt.Sprintf("invalid_output: output is %d bytes, over the declared %d byte bound", len(encoded), harnessMaxOutputBytes)} - } - decoded, err := harnessDecode(encoded) - if err != nil { - return harnessOutcome{code: harnessExitInvalidOutput, diagnostic: "invalid_output: " + err.Error()} - } - object, ok := decoded.(map[string]any) - if !ok { - return harnessOutcome{code: harnessExitInvalidOutput, diagnostic: "invalid_output: output must be one JSON object"} - } - if err := harnessValidate(harnessOutputSchema, object, "output"); err != nil { - return harnessOutcome{code: harnessExitInvalidOutput, diagnostic: "invalid_output: " + err.Error()} - } - result.encoded = encoded - return result +// harnessOutcome is what one call produced, before it becomes a response. +type harnessOutcome struct { + code int + encoded []byte + failure *Failure + diagnostic string } -// harnessRun calls the handler under the caller's budget, and under the -// interruption the declaration says this operation can take. +// Outcomes a call can have. They are no longer process exit codes — nothing +// exits per call — but the distinctions are the ones a caller acts on, so they +// are kept rather than collapsed into an error string. +const ( + harnessExitCompleted = 0 + harnessExitInvalidInput = 64 + harnessExitInvalidOutput = 65 + harnessExitFailed = 66 + harnessExitTimeout = 67 +) + +// harnessRun calls the handler under the caller's budget and validates what it +// returned. // // The handler runs on its own goroutine because Go cannot raise into one: when -// the budget passes or a signal arrives, the process reports that and exits -// while the handler is still running. What it would have returned is no longer -// this invocation's outcome, which is why the reason that ended the wait wins -// over anything the handler produces afterwards. -func harnessRun(request harnessRequest, in Input, interrupted chan os.Signal) (harnessOutcome, Output) { - ctx, cancel := context.WithTimeout(context.Background(), request.budget) +// the budget passes, this answers and the handler is still running. What it +// would have returned is no longer this call's outcome, which is why the +// deadline wins over anything the handler produces afterwards. +func harnessRun(parent context.Context, call harnessCall) harnessOutcome { + ctx, cancel := context.WithTimeout(parent, call.budget) defer cancel() - ctx = context.WithValue(ctx, harnessInvocationKey{}, request.invocation) + ctx = context.WithValue(ctx, harnessInvocationKey{}, call.invocation) type handled struct { outcome harnessOutcome @@ -585,14 +470,14 @@ func harnessRun(request harnessRequest, in Input, interrupted chan os.Signal) (h completed := make(chan handled, 1) go func() { defer func() { - // A panic leaves the effect unproven, not failed, so it writes no - // result. + // A panic leaves the effect unproven, not failed: the handler never + // said which side of its effect it stopped on. if recovered := recover(); recovered != nil { - completed <- handled{outcome: harnessOutcome{code: harnessExitFailed, - diagnostic: fmt.Sprintf("failed: the handler panicked: %v", recovered)}} + completed <- handled{outcome: harnessOutcome{code: harnessExitInvalidOutput, + diagnostic: fmt.Sprintf("the handler panicked: %v", recovered)}} } }() - out, err := Handle(ctx, in) + out, err := Handle(ctx, call.input) var failure *Failure switch { case err == nil: @@ -601,83 +486,82 @@ func harnessRun(request harnessRequest, in Input, interrupted chan os.Signal) (h completed <- handled{outcome: harnessOutcome{code: harnessExitFailed, failure: failure, diagnostic: "failed: " + failure.Error()}} default: - completed <- handled{outcome: harnessOutcome{code: harnessExitFailed, diagnostic: "failed: " + err.Error()}} + // An untyped error is not the operation asserting anything, so it + // stays unproven rather than being reported as a failure a caller + // would read as proof no effect committed. + completed <- handled{outcome: harnessOutcome{code: harnessExitInvalidOutput, + diagnostic: "the handler returned an untyped error: " + err.Error()}} } }() - var zero Output - timedOut := harnessOutcome{code: harnessExitTimeout, diagnostic: "timeout: the deadline passed"} - stopped := harnessOutcome{code: harnessExitInterrupted, diagnostic: "interrupted: the invocation was interrupted"} select { case result := <-completed: - // A handler that swallowed the signal and returned anyway does not - // turn an interruption into a completion. The deadline needs no such - // check: once the budget passes this select has already returned, so a - // result arriving here is one the handler produced while it still had - // the time. Re-reading the clock instead would discard a completion - // that beat the deadline but reached this line after it. - select { - case <-interrupted: - return stopped, zero - default: + if result.outcome.code != harnessExitCompleted { + return result.outcome } - return result.outcome, result.output - case <-interrupted: - return stopped, zero + encoded, err := harnessEncodeOutput(result.output) + if err != nil { + return harnessOutcome{code: harnessExitInvalidOutput, diagnostic: err.Error()} + } + result.outcome.encoded = encoded + return result.outcome case <-ctx.Done(): - return timedOut, zero - } -} - -// harnessResultDocument renders what the harness proved, and reports whether -// there is anything to write. Only a validated success, an explicit failure and -// a handled interruption are reported; every other outcome leaves the effect -// unproven and writes nothing. -func harnessResultDocument(request harnessRequest, result harnessOutcome) (map[string]any, bool) { - document := map[string]any{"protocol": harnessProtocol, "invocation_id": request.invocation.ID} - switch { - case result.code == harnessExitCompleted: - document["status"] = "SUCCEEDED" - document["output"] = base64.StdEncoding.EncodeToString(result.encoded) - case result.failure != nil: - document["status"] = "FAILED" - document["error"] = map[string]any{"code": result.failure.Code, "message": result.failure.Message} - case result.code == harnessExitInterrupted: - document["status"] = "INTERRUPTED" - default: - return nil, false + return harnessOutcome{code: harnessExitTimeout, diagnostic: "the deadline passed"} } - return document, true } -// harnessWriteResult renames a complete document onto the result path, so a -// document that exists is never a partial one. -func harnessWriteResult(path string, document map[string]any) error { - encoded, err := json.Marshal(document) +// harnessEncodeOutput renders and validates what the handler returned, so an +// answer a caller accepts is one that matches the installed contract. +func harnessEncodeOutput(out Output) ([]byte, error) { + encoded, err := json.Marshal(out) if err != nil { - return err + return nil, fmt.Errorf("output: %v", err) } - staging, err := os.CreateTemp(filepath.Dir(path), ".result-") - if err != nil { - return err + if len(encoded) > harnessMaxOutputBytes { + return nil, fmt.Errorf("output is %d bytes, over the declared %d byte bound", len(encoded), harnessMaxOutputBytes) } - name := staging.Name() - defer os.Remove(name) - if _, err := staging.Write(encoded); err != nil { - staging.Close() - return err + decoded, err := harnessDecode(encoded) + if err != nil { + return nil, fmt.Errorf("output: %v", err) } - if err := staging.Sync(); err != nil { - staging.Close() - return err + object, ok := decoded.(map[string]any) + if !ok { + return nil, errors.New("output must be one JSON object") } - if err := staging.Close(); err != nil { - return err + if err := harnessValidate(harnessOutputSchema, object, "output"); err != nil { + return nil, err } - if err := os.Chmod(name, harnessResultMode); err != nil { - return err + return encoded, nil +} + +// harnessAnswer writes a success: the bounded output document itself, with no +// envelope around it. +func harnessAnswer(w http.ResponseWriter, encoded []byte) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(encoded) +} + +// harnessProvenFailure writes the operation's own typed failure. The header is +// what makes it PROVEN that nothing committed — a caller reads it before the +// status, and without it no status proves anything — so it is set here and +// nowhere else in this harness. +func harnessProvenFailure(w http.ResponseWriter, failure *Failure) { + w.Header().Set(harnessFailureCodeHeader, failure.Code) + harnessUnproven(w, http.StatusUnprocessableEntity, "failed_precondition", failure.Message) +} + +// harnessUnproven writes an error answer in the Connect JSON shape. On its own +// it proves nothing about the effect, which is the point: a caller that cannot +// see the operation's own assertion must treat the attempt as unresolved. +func harnessUnproven(w http.ResponseWriter, status int, code string, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + encoded, err := json.Marshal(map[string]any{"code": code, "message": message}) + if err != nil { + return } - return os.Rename(name, path) + w.Write(encoded) } // harnessField is one field of the bounded profile. @@ -857,3 +741,59 @@ func harnessDecodeValue(decoder *json.Decoder) (any, error) { return array, nil } ` + +// harnessReceiptBody is rendered only for a package declaring receipt recovery: +// it is the receipt route and the Receipt type the author implements ReceiptOf +// against. A recompute package has no receipt to serve, and rendering a route +// that called a ReceiptOf the author never wrote would not compile. +const harnessReceiptBody = ` +// Receipt is what an operation committed under one effect identity. A handler +// of a receipt-recovery operation implements ReceiptOf to answer a recovery: +// found reports the committed output, and absent is not an error — it is the +// answer the recovery exists to get, and it never means the effect did not +// happen. +type Receipt struct { + Output Output + Found bool +} +// harnessServeLookup answers a recovery: what did this effect commit? Absent is +// answered with 404 and is never an error, because a caller reads an absent +// receipt as inconclusive rather than as permission to invoke again. +func harnessServeLookup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + harnessUnproven(w, http.StatusMethodNotAllowed, "unimplemented", "the receipt route is called with POST") + return + } + request, err := harnessReadCall(r) + if err != nil { + harnessUnproven(w, http.StatusBadRequest, "invalid_argument", err.Error()) + return + } + ctx := context.WithValue(r.Context(), harnessInvocationKey{}, request.invocation) + receipt, err := ReceiptOf(ctx, request.input) + if err != nil { + harnessUnproven(w, http.StatusInternalServerError, "internal", "receipt: "+err.Error()) + return + } + if !receipt.Found { + harnessUnproven(w, http.StatusNotFound, "not_found", "no receipt for this effect") + return + } + encoded, err := harnessEncodeOutput(receipt.Output) + if err != nil { + harnessUnproven(w, http.StatusInternalServerError, "internal", "receipt: "+err.Error()) + return + } + harnessAnswer(w, encoded) +} +func harnessServeReceipts(mux *http.ServeMux) { + mux.HandleFunc(harnessLookupProcedure, harnessServeLookup) +} +` + +// harnessNoReceiptBody is its counterpart: a package whose uncertain outcome is +// resolved by recomputing has nothing to look up, so the route is absent rather +// than present and answering "never". +const harnessNoReceiptBody = ` +func harnessServeReceipts(mux *http.ServeMux) {} +` diff --git a/pkg/generate/harness_test.go b/pkg/generate/harness_test.go index a817cbb..e2c7c11 100644 --- a/pkg/generate/harness_test.go +++ b/pkg/generate/harness_test.go @@ -24,45 +24,66 @@ func harnessSource(t *testing.T, runnable *resources.Runnable, identity *basev0. return string(source) } -// The framing is one contract across the agents that implement it, and it is -// core's. A Go harness that spelled an environment variable or an exit code -// differently from the Python one would need a launcher of its own, which is -// the thing the shared protocol exists to prevent. +// The seam is one contract across the agents that implement it, and it is +// core's. A Go harness that spelled a header, a route or the listen-address +// variable differently from the Python one would need a caller of its own, +// which is the thing the shared protocol exists to prevent — and it would fail +// as an unreachable owner rather than as a visible mistake in a string. // // The values are read from core here rather than repeated, so a rename in core // fails this test instead of silently splitting the two harnesses apart. -func TestTheFramingIsOneContract(t *testing.T) { - source := harnessSource(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), release()) +func TestTheServedSeamIsOneContract(t *testing.T) { + source := harnessSource(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryReceipt), release()) for _, value := range []string{ - corerunnable.EnvProtocol, - corerunnable.EnvInvocationPath, - corerunnable.EnvResultPath, - resources.RunnableProtocolV1, + corerunnable.ListenAddressEnv, + corerunnable.ServedInvokeProcedure, + corerunnable.ServedLookupProcedure, + corerunnable.WorkContextHeader, + corerunnable.EffectHeader, + corerunnable.DeadlineHeader, + corerunnable.FailureCodeHeader, + resources.RunnableServedProtocolV1, } { if !strings.Contains(source, fmt.Sprintf("%q", value)) { - t.Errorf("the harness does not carry %q, so it reads a framing no core launcher writes", value) + t.Errorf("the harness does not carry %q, so it serves a seam no caller dials", value) } } - // The exit codes the Python harness reports for the same conditions. They - // are diagnostics rather than outcomes, which is exactly why they have to - // agree: an operator reading 64 must learn the same thing from either. - for name, code := range map[string]int{ - "harnessExitCompleted": 0, - "harnessExitInvalidInput": 64, - "harnessExitInvalidOutput": 65, - "harnessExitFailed": 66, - "harnessExitTimeout": 67, - "harnessExitInterrupted": 68, - "harnessExitProtocol": 69, + // The framing this replaced. A harness still reading two document paths out + // of the environment would start, serve nothing, and look healthy. + for _, gone := range []string{ + "CODEFLY__RUNNABLE_PROTOCOL", + "CODEFLY__RUNNABLE_INVOCATION", + "CODEFLY__RUNNABLE_RESULT", } { - if !declares(source, name, fmt.Sprint(code)) { - t.Errorf("the harness does not declare %s = %d", name, code) + if strings.Contains(source, gone) { + t.Errorf("the harness still carries %s, which no caller writes", gone) } } } +// The failure-code header is the only thing that proves to a caller that no +// effect committed, so exactly one place in the harness may set it: the +// handler's own declared Failure. A harness that set it anywhere else would +// assert on the handler's behalf that its effect did not happen. +func TestOnlyADeclaredFailureSetsTheProvenHeader(t *testing.T) { + source := harnessSource(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryReceipt), release()) + + sets := 0 + for _, line := range strings.Split(source, "\n") { + if strings.Contains(line, "Set(harnessFailureCodeHeader") { + sets++ + } + } + if sets != 1 { + t.Errorf("the harness sets the failure-code header in %d places; exactly one — the handler's own Failure — may", sets) + } + if !strings.Contains(source, "func harnessProvenFailure(w http.ResponseWriter, failure *Failure)") { + t.Error("the one place that sets it is no longer the declared-failure path") + } +} + // The harness refuses an invocation naming another release, which is what lets // two versions of one runnable be installed at once without either answering // the other's calls. It can only do that if the release it was built for is in diff --git a/pkg/generate/invocation_test.go b/pkg/generate/invocation_test.go deleted file mode 100644 index def7ef6..0000000 --- a/pkg/generate/invocation_test.go +++ /dev/null @@ -1,849 +0,0 @@ -package generate_test - -import ( - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "syscall" - "testing" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/core/resources" - corerunnable "github.com/codefly-dev/core/runnable" - "google.golang.org/protobuf/types/known/timestamppb" - - "github.com/codefly-dev/runnable-go/pkg/generate" -) - -// The harness is qualified against core rather than against this package's own -// idea of the framing: every request is the document core's launcher writes, -// and every outcome is the one core's Complete classifies. A harness that -// agreed only with its own tests would agree with no launcher. - -const ( - releaseName = "word-count" - releaseModule = "proof" - releaseWorkspace = "proof" - releaseVersion = "0.0.1" - packagePkg = "wordcount" -) - -func release() *basev0.RunnableIdentity { - return &basev0.RunnableIdentity{ - Name: releaseName, - Module: releaseModule, - Workspace: releaseWorkspace, - Version: releaseVersion, - } -} - -// declaration is the runnable the proof package implements: every value type of -// the bounded profile, and optional and nullable declared apart. -func declaration(t *testing.T, cancellation resources.RunnableCancellation, recovery resources.RunnableRecovery) *resources.Runnable { - t.Helper() - runnable := &resources.Runnable{ - Kind: resources.RunnableKind, - Name: releaseName, - Version: releaseVersion, - Agent: &resources.Agent{ - Kind: resources.RunnableAgent, Name: "go", Publisher: "codefly.dev", Version: "0.0.1", - }, - Contract: contractOf( - []*resources.RunnableField{ - field("text", resources.RunnableFieldString), - {Name: "repeat", Type: resources.RunnableFieldInteger, Optional: true}, - {Name: "note", Type: resources.RunnableFieldString, Nullable: true}, - {Name: "options", Type: resources.RunnableFieldObject, Optional: true, Fields: []*resources.RunnableField{ - field("limit", resources.RunnableFieldInteger), - }}, - {Name: "tags", Type: resources.RunnableFieldArray, Items: field("", resources.RunnableFieldString)}, - }, - []*resources.RunnableField{ - field("total", resources.RunnableFieldInteger), - field("echo", resources.RunnableFieldString), - }, - ), - Entrypoint: &resources.RunnableEntrypoint{Handler: "handler.go"}, - Execution: &resources.RunnableExecution{ - Facilities: []resources.RunnableFacility{resources.RunnableFacilityNative}, - Timeout: "5m", - Cancellation: cancellation, - Recovery: recovery, - }, - } - runnable.SetModule(releaseModule) - if err := runnable.Validate(); err != nil { - t.Fatalf("declaration is not valid: %v", err) - } - return runnable -} - -// compiled is a generated runnable built the way a launcher would receive it. -type compiled struct { - runnable *resources.Runnable - pkg *basev0.RunnablePackage - binary string -} - -// compile generates the runnable, writes the author's handler and builds the -// executable a launcher starts. The module resolves nothing from the network: -// generated Go depends on the standard library alone, which is what lets a -// package be built and archived without a proxy. -func compile(t *testing.T, runnable *resources.Runnable, handler string) compiled { - t.Helper() - dir := t.TempDir() - - if err := os.WriteFile(filepath.Join(dir, "handler.go"), []byte(handler), 0o600); err != nil { - t.Fatalf("writing the handler: %v", err) - } - // Scaffold writes the module declaration and leaves the handler alone. - if err := generate.Scaffold(runnable, dir); err != nil { - t.Fatalf("Scaffold: %v", err) - } - if err := generate.GenerateForRelease(runnable, dir, release()); err != nil { - t.Fatalf("GenerateForRelease: %v", err) - } - - binary := filepath.Join(t.TempDir(), "runnable") - build := exec.Command("go", "build", "-o", binary, "./"+generate.CommandDirectory) - build.Dir = dir - build.Env = append(os.Environ(), "GOFLAGS=-mod=mod", "GOPROXY=off") - if output, err := build.CombinedOutput(); err != nil { - source, _ := os.ReadFile(filepath.Join(dir, generate.HarnessFile)) - t.Fatalf("building the generated runnable: %v\n%s\n--- harness ---\n%s", err, output, source) - } - return compiled{runnable: runnable, pkg: corePackageOf(t, runnable), binary: binary} -} - -// corePackageOf assembles the immutable package descriptor core validates an -// invocation against, from the same declaration the harness was generated from. -// The build and artifact facts are stand-ins: this agent does not package yet, -// and none of them reaches the harness. -func corePackageOf(t *testing.T, runnable *resources.Runnable) *basev0.RunnablePackage { - t.Helper() - digest := "sha256:" + strings.Repeat("ab", 32) - agent, err := runnable.Agent.Proto() - if err != nil { - t.Fatalf("agent proto: %v", err) - } - prepared, err := corerunnable.PreparePackage(&basev0.RunnablePackage{ - Schema: corerunnable.PackageSchemaV1, - Identity: release(), - Agent: agent, - Contract: runnable.Contract.Proto(), - Execution: runnable.Execution.Proto(), - Build: &basev0.RunnableBuild{ - Handler: &basev0.RunnableInputDigest{Path: "handler.go", Digest: digest}, - Inputs: []*basev0.RunnableInputDigest{{Path: generate.ModuleFile, Digest: digest}}, - HarnessDigest: digest, - Toolchain: "go" + strings.TrimPrefix(runtime.Version(), "go"), - ConfigurationDigest: digest, - }, - Artifacts: []*basev0.RunnableArtifact{{ - Kind: basev0.RunnableArtifact_NATIVE, - Platform: runtime.GOOS + "/" + runtime.GOARCH, - Reference: "word-count.tar.gz", - Digest: digest, - Command: []string{generate.CommandDirectory}, - }}, - }) - if err != nil { - t.Fatalf("PreparePackage: %v", err) - } - return prepared -} - -// invocationFor is the document a launcher writes for one payload. -func invocationFor(t *testing.T, payload string, budget time.Duration) *basev0.RunnableInvocation { - t.Helper() - issued := time.Now().UTC() - return &basev0.RunnableInvocation{ - Protocol: resources.RunnableProtocolV1, - Runnable: release(), - InvocationId: "inv-1", - IntentId: "intent-1", - IssuedAt: timestamppb.New(issued), - Deadline: timestamppb.New(issued.Add(budget)), - Input: []byte(payload), - } -} - -// observed is what a launcher saw of one invocation. -type observed struct { - exit int - signal string - stdout string - stderr string - result []byte - present bool - resultMode os.FileMode - completion *basev0.RunnableCompletion -} - -// launch runs the invocation the way core's launcher does: the encoded document -// on disk, the framing in the environment, and the process observed rather than -// interpreted. ended is how the launcher itself ended the process. -func launch(t *testing.T, built compiled, inv *basev0.RunnableInvocation, ended corerunnable.EndReason, interrupt bool) observed { - t.Helper() - prepared, err := corerunnable.PrepareInvocation(inv, built.pkg) - if err != nil { - t.Fatalf("PrepareInvocation: %v", err) - } - document, err := corerunnable.EncodeInvocation(prepared) - if err != nil { - t.Fatalf("EncodeInvocation: %v", err) - } - dir := t.TempDir() - invocationPath := filepath.Join(dir, "invocation.json") - resultPath := filepath.Join(dir, "result.json") - if err := os.WriteFile(invocationPath, document, 0o600); err != nil { - t.Fatalf("writing the invocation: %v", err) - } - return runProcess(t, built, prepared, invocationPath, resultPath, - corerunnable.InvocationEnvironment(prepared, invocationPath, resultPath), ended, interrupt) -} - -// launchRaw runs a document core would never write, to prove the harness -// refuses it rather than trusting its launcher. -func launchRaw(t *testing.T, built compiled, document string, environment map[string]string) observed { - t.Helper() - dir := t.TempDir() - invocationPath := filepath.Join(dir, "invocation.json") - resultPath := filepath.Join(dir, "result.json") - if err := os.WriteFile(invocationPath, []byte(document), 0o600); err != nil { - t.Fatalf("writing the invocation: %v", err) - } - full := map[string]string{ - "CODEFLY__RUNNABLE_PROTOCOL": resources.RunnableProtocolV1, - "CODEFLY__RUNNABLE_INVOCATION": invocationPath, - "CODEFLY__RUNNABLE_RESULT": resultPath, - } - for key, value := range environment { - if value == "" { - delete(full, key) - continue - } - full[key] = value - } - return runProcess(t, built, nil, invocationPath, resultPath, full, corerunnable.EndedOnItsOwn, false) -} - -// readyMarker is the file a handler under test writes when it is running. The -// launcher signals on the marker rather than after a delay, so an interruption -// test proves what the harness does with a signal rather than how fast the -// process started. -const readyMarker = "started" - -func runProcess( - t *testing.T, - built compiled, - inv *basev0.RunnableInvocation, - invocationPath string, - resultPath string, - environment map[string]string, - ended corerunnable.EndReason, - interrupt bool, -) observed { - t.Helper() - process := exec.Command(built.binary) - process.Dir = filepath.Dir(invocationPath) - process.Env = []string{} - for key, value := range environment { - process.Env = append(process.Env, key+"="+value) - } - stdout, stderr := &strings.Builder{}, &strings.Builder{} - process.Stdout, process.Stderr = stdout, stderr - - start := time.Now().UTC() - if err := process.Start(); err != nil { - t.Fatalf("starting the runnable: %v", err) - } - if interrupt { - signalled := make(chan struct{}) - go func() { - defer close(signalled) - marker := filepath.Join(process.Dir, readyMarker) - for range 4000 { - if _, err := os.Stat(marker); err == nil { - _ = process.Process.Signal(syscall.SIGTERM) - return - } - time.Sleep(5 * time.Millisecond) - } - }() - defer func() { <-signalled }() - } - waitErr := process.Wait() - end := time.Now().UTC() - - seen := observed{stdout: stdout.String(), stderr: stderr.String()} - seen.exit = process.ProcessState.ExitCode() - if status, ok := process.ProcessState.Sys().(syscall.WaitStatus); ok && status.Signaled() { - seen.signal = status.Signal().String() - } - var exitErr *exec.ExitError - if waitErr != nil && !errors.As(waitErr, &exitErr) { - t.Fatalf("running the runnable: %v", waitErr) - } - - if info, err := os.Stat(resultPath); err == nil { - seen.present = true - seen.resultMode = info.Mode().Perm() - if seen.result, err = os.ReadFile(resultPath); err != nil { - t.Fatalf("reading the result: %v", err) - } - } - if inv == nil { - return seen - } - completion, err := corerunnable.Complete(inv, built.pkg, corerunnable.Observation{ - StartedAt: start, - EndedAt: end, - ResultPresent: seen.present, - Result: seen.result, - ExitCode: int32(seen.exit), - Signal: seen.signal, - Ended: ended, - }) - if err != nil { - t.Fatalf("Complete: %v", err) - } - seen.completion = completion - return seen -} - -// echoHandler returns the text it was given, so the payload decides the output -// and a stale or fabricated result cannot pass for this invocation's. -const echoHandler = `package wordcount - -import "context" - -func Handle(ctx context.Context, in Input) (Output, error) { - total := int64(len(in.Tags)) - if repeat, ok := in.Repeat.Get(); ok { - total += repeat - } - return Output{Total: total, Echo: in.Text}, nil -} -` - -func decodeOutput(t *testing.T, completion *basev0.RunnableCompletion) map[string]any { - t.Helper() - if completion.GetResult() == nil { - t.Fatalf("completion carries no result: %s / %s", completion.GetOutcome(), completion.GetMessage()) - } - var output map[string]any - if err := json.Unmarshal(completion.GetResult().GetOutput(), &output); err != nil { - t.Fatalf("decoding the output: %v", err) - } - return output -} - -// A generated runnable answers a real invocation, and core recognizes the -// answer as this invocation's proven success. -func TestAGeneratedRunnableCompletesAnInvocation(t *testing.T) { - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), echoHandler) - - seen := launch(t, built, invocationFor(t, `{"text":"hello","note":null,"tags":["a","b"],"repeat":5}`, time.Minute), - corerunnable.EndedOnItsOwn, false) - - if seen.exit != 0 { - t.Fatalf("exit = %d, want 0\nstderr:\n%s", seen.exit, seen.stderr) - } - if got := seen.completion.GetOutcome(); got != basev0.RunnableCompletion_SUCCEEDED { - t.Fatalf("outcome = %s (%s)", got, seen.completion.GetMessage()) - } - output := decodeOutput(t, seen.completion) - if output["echo"] != "hello" { - t.Errorf("echo = %v, want the input text: the output does not depend on the payload", output["echo"]) - } - if output["total"] != float64(7) { - t.Errorf("total = %v, want 7", output["total"]) - } - // The result is the launcher's to read; the facility decides which - // accounts may. - if seen.resultMode != 0o644 { - t.Errorf("result mode = %v, want 0644", seen.resultMode) - } - if !corerunnable.OutcomeIsCertain(seen.completion.GetOutcome()) { - t.Error("a validated success is a certain outcome") - } -} - -// The completion is data and the streams are diagnostics. A handler writing to -// both must not be able to put anything into the result document, nor the -// result onto a stream. -func TestLogsAreSeparateFromTheCompletion(t *testing.T) { - const chatty = `package wordcount - -import ( - "context" - "fmt" - "os" -) - -func Handle(ctx context.Context, in Input) (Output, error) { - fmt.Println("stdout diagnostic") - fmt.Fprintln(os.Stderr, "stderr diagnostic") - return Output{Total: 1, Echo: in.Text}, nil -} -` - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), chatty) - seen := launch(t, built, invocationFor(t, `{"text":"x","note":null,"tags":[]}`, time.Minute), corerunnable.EndedOnItsOwn, false) - - if seen.completion.GetOutcome() != basev0.RunnableCompletion_SUCCEEDED { - t.Fatalf("outcome = %s (%s)", seen.completion.GetOutcome(), seen.completion.GetMessage()) - } - if !strings.Contains(seen.stdout, "stdout diagnostic") || !strings.Contains(seen.stderr, "stderr diagnostic") { - t.Errorf("a handler's diagnostics did not reach the streams\nstdout: %q\nstderr: %q", seen.stdout, seen.stderr) - } - if strings.Contains(seen.stdout, "\"protocol\"") { - t.Errorf("the result document was written to stdout: %q", seen.stdout) - } - if strings.Contains(string(seen.result), "diagnostic") { - t.Errorf("a diagnostic reached the completion: %s", seen.result) - } -} - -// The bindings carry value types and null states; they do not carry required -// keys, undeclared keys, the integer range or the refusal to coerce. Every one -// of those is the harness's, and each is an invalid input rather than a -// completion. -func TestThePayloadIsValidatedAgainstTheContract(t *testing.T) { - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), echoHandler) - - for _, test := range []struct{ name, input, reason string }{ - {"a required key is absent", `{"note":null,"tags":[]}`, "input.text is required"}, - {"an undeclared key", `{"text":"a","note":null,"tags":[],"extra":1}`, "input.extra is not declared by the contract"}, - {"null for a key that is not nullable", `{"text":null,"note":null,"tags":[]}`, "input.text is null"}, - {"a fraction is not an integer", `{"text":"a","note":null,"tags":[],"repeat":3.0}`, "input.repeat must be an integer, got number"}, - {"a string is not an integer", `{"text":"a","note":null,"tags":[],"repeat":"3"}`, "input.repeat must be an integer, got string"}, - {"a boolean is not an integer", `{"text":"a","note":null,"tags":[],"repeat":true}`, "input.repeat must be an integer, got boolean"}, - {"an integer is not a string", `{"text":3,"note":null,"tags":[]}`, "input.text must be a string, got integer"}, - {"beyond signed 64 bits", `{"text":"a","note":null,"tags":[],"repeat":9223372036854775808}`, "outside the signed 64-bit integer range"}, - {"an element of an array", `{"text":"a","note":null,"tags":["a",2]}`, "input.tags[1] must be a string, got integer"}, - {"a nested object field", `{"text":"a","note":null,"tags":[],"options":{"limit":"x"}}`, "input.options.limit must be an integer"}, - {"an undeclared nested key", `{"text":"a","note":null,"tags":[],"options":{"limit":1,"other":2}}`, "input.options.other is not declared"}, - } { - t.Run(test.name, func(t *testing.T) { - seen := launch(t, built, invocationFor(t, test.input, time.Minute), corerunnable.EndedOnItsOwn, false) - if seen.exit != 64 { - t.Fatalf("exit = %d, want 64\nstderr:\n%s", seen.exit, seen.stderr) - } - if seen.present { - t.Errorf("a refused payload wrote a result: %s", seen.result) - } - if !strings.Contains(seen.stderr, test.reason) { - t.Errorf("stderr does not name the reason %q:\n%s", test.reason, seen.stderr) - } - }) - } - - // The boundary itself is inside the profile, so the refusal above is the - // range and not the digits. - seen := launch(t, built, invocationFor(t, `{"text":"a","note":null,"tags":[],"repeat":9223372036854775807}`, time.Minute), - corerunnable.EndedOnItsOwn, false) - if seen.exit != 0 { - t.Fatalf("the largest signed 64-bit integer was refused: exit %d\n%s", seen.exit, seen.stderr) - } -} - -// Framing this harness does not implement is refused before the handler runs, -// and nothing is bound to an invocation identity, so no result is written. -func TestInvalidFramingIsRefusedBeforeTheHandlerRuns(t *testing.T) { - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), echoHandler) - valid := `{"protocol":"codefly.runnable/v1","runnable":{"name":"word-count","module":"proof","workspace":"proof","version":"0.0.1"},` + - `"invocation_id":"inv-1","intent_id":"intent-1","issued_at":"2026-09-13T10:00:00Z","deadline":"2026-09-13T10:01:00Z","input":%q}` - encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"a","note":null,"tags":[]}`)) - - for _, test := range []struct { - name string - document string - environment map[string]string - reason string - }{ - { - name: "another release", - document: strings.Replace(fmt.Sprintf(valid, encoded), `"version":"0.0.1"`, `"version":"9.0.0"`, 1), - reason: "another Runnable release", - }, - { - name: "an undeclared request field", - document: strings.Replace(fmt.Sprintf(valid, encoded), `"invocation_id"`, `"unknown":1,"invocation_id"`, 1), - reason: "undeclared field", - }, - { - name: "a duplicate JSON key", - document: strings.Replace(fmt.Sprintf(valid, encoded), `"intent_id":"intent-1"`, - `"intent_id":"intent-1","intent_id":"intent-2"`, 1), - reason: "duplicate JSON key", - }, - { - name: "a deadline that is not after the instant it was issued", - document: strings.Replace(fmt.Sprintf(valid, encoded), `"deadline":"2026-09-13T10:01:00Z"`, `"deadline":"2026-09-13T10:00:00Z"`, 1), - reason: "must be after issued_at", - }, - { - name: "an identifier beyond 128 characters", - document: strings.Replace(fmt.Sprintf(valid, encoded), `"inv-1"`, `"`+strings.Repeat("i", 129)+`"`, 1), - reason: "invocation_id must contain 1 to 128 characters", - }, - { - name: "an input that is not base64", - document: fmt.Sprintf(valid, "not-base64!"), - reason: "not base64", - }, - { - name: "an input that is not an object", - document: fmt.Sprintf(valid, base64.StdEncoding.EncodeToString([]byte(`[1,2]`))), - reason: "must decode to one JSON object", - }, - { - name: "another protocol", - document: fmt.Sprintf(valid, encoded), - environment: map[string]string{"CODEFLY__RUNNABLE_PROTOCOL": "codefly.runnable/v2"}, - reason: "not \"codefly.runnable/v1\"", - }, - { - name: "no result path", - document: fmt.Sprintf(valid, encoded), - environment: map[string]string{"CODEFLY__RUNNABLE_RESULT": ""}, - reason: "CODEFLY__RUNNABLE_RESULT is required", - }, - { - name: "a relative document path", - document: fmt.Sprintf(valid, encoded), - environment: map[string]string{"CODEFLY__RUNNABLE_INVOCATION": "invocation.json"}, - reason: "must be an absolute path", - }, - } { - t.Run(test.name, func(t *testing.T) { - seen := launchRaw(t, built, test.document, test.environment) - if seen.exit != 69 { - t.Fatalf("exit = %d, want 69\nstderr:\n%s", seen.exit, seen.stderr) - } - if seen.present { - t.Errorf("refused framing wrote a result: %s", seen.result) - } - if !strings.Contains(seen.stderr, test.reason) { - t.Errorf("stderr does not name the reason %q:\n%s", test.reason, seen.stderr) - } - }) - } -} - -// Only an explicit failure says what became of the effect. Everything else a -// handler can do leaves it unproven, and an unproven effect writes no result -// rather than one a caller would act on. -func TestOnlyAnExplicitFailureIsCertain(t *testing.T) { - const failing = `package wordcount - -import ( - "context" - "errors" -) - -func Handle(ctx context.Context, in Input) (Output, error) { - switch in.Text { - case "refuse": - return Output{}, Fail("unavailable", "the operation was refused") - case "panic": - panic("the handler panicked") - } - return Output{}, errors.New("something unexpected happened") -} -` - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), failing) - - refused := launch(t, built, invocationFor(t, `{"text":"refuse","note":null,"tags":[]}`, time.Minute), corerunnable.EndedOnItsOwn, false) - if refused.exit != 66 { - t.Fatalf("exit = %d, want 66\nstderr:\n%s", refused.exit, refused.stderr) - } - if got := refused.completion.GetOutcome(); got != basev0.RunnableCompletion_FAILED { - t.Fatalf("outcome = %s, want FAILED (%s)", got, refused.completion.GetMessage()) - } - if code := refused.completion.GetResult().GetError().GetCode(); code != "unavailable" { - t.Errorf("failure code = %q, want the handler's own", code) - } - if !corerunnable.OutcomeIsCertain(refused.completion.GetOutcome()) { - t.Error("an explicit failure is a certain outcome") - } - - for _, test := range []struct{ name, text string }{ - {"an unexpected error", "boom"}, - {"a panic", "panic"}, - } { - t.Run(test.name, func(t *testing.T) { - seen := launch(t, built, invocationFor(t, fmt.Sprintf(`{"text":%q,"note":null,"tags":[]}`, test.text), time.Minute), - corerunnable.EndedOnItsOwn, false) - if seen.present { - t.Fatalf("an unproven effect wrote a result: %s", seen.result) - } - if seen.exit != 66 { - t.Errorf("exit = %d, want 66\nstderr:\n%s", seen.exit, seen.stderr) - } - if corerunnable.OutcomeIsCertain(seen.completion.GetOutcome()) { - t.Errorf("outcome %s is certain, but nothing proved what became of the effect", seen.completion.GetOutcome()) - } - }) - } -} - -// The budget is the caller's. A handler that outlives it does not get to report -// a completion, and the launcher's own deadline is what makes the outcome a -// timeout rather than a crash. -func TestTheDeadlineEndsTheInvocation(t *testing.T) { - const slow = `package wordcount - -import ( - "context" - "time" -) - -func Handle(ctx context.Context, in Input) (Output, error) { - time.Sleep(30 * time.Second) - return Output{Total: 1, Echo: in.Text}, nil -} -` - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), slow) - - seen := launch(t, built, invocationFor(t, `{"text":"a","note":null,"tags":[]}`, 300*time.Millisecond), - corerunnable.EndedOnDeadline, false) - - if seen.exit != 67 { - t.Fatalf("exit = %d, want 67\nstderr:\n%s", seen.exit, seen.stderr) - } - if seen.present { - t.Errorf("a timed-out invocation wrote a result: %s", seen.result) - } - if got := seen.completion.GetOutcome(); got != basev0.RunnableCompletion_TIMED_OUT { - t.Errorf("outcome = %s, want TIMED_OUT (%s)", got, seen.completion.GetMessage()) - } -} - -// A handler that swallows the deadline does not turn it into a completion: the -// reason the wait ended outlives whatever the handler returns afterwards. -func TestASwallowedDeadlineIsStillATimeout(t *testing.T) { - const swallowing = `package wordcount - -import ( - "context" - "time" -) - -func Handle(ctx context.Context, in Input) (Output, error) { - <-ctx.Done() - time.Sleep(50 * time.Millisecond) - return Output{Total: 1, Echo: in.Text}, nil -} -` - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), swallowing) - - seen := launch(t, built, invocationFor(t, `{"text":"a","note":null,"tags":[]}`, 300*time.Millisecond), - corerunnable.EndedOnDeadline, false) - - if seen.exit != 67 { - t.Fatalf("exit = %d, want 67\nstderr:\n%s", seen.exit, seen.stderr) - } - if seen.present { - t.Errorf("a swallowed deadline still reported a completion: %s", seen.result) - } -} - -// An interruption is reported only by a package that declares it can take one. -// A declaration claiming no cancellation installs no handler, so the process -// dies on the signal and the launcher sees that rather than a claim the harness -// cannot support. -func TestAnInterruptionIsReportedOnlyWhereDeclared(t *testing.T) { - const waiting = `package wordcount - -import ( - "context" - "os" - "time" -) - -func Handle(ctx context.Context, in Input) (Output, error) { - if err := os.WriteFile("started", []byte("x"), 0o600); err != nil { - return Output{}, err - } - time.Sleep(30 * time.Second) - return Output{Total: 1, Echo: in.Text}, nil -} -` - t.Run("declared", func(t *testing.T) { - built := compile(t, declaration(t, resources.RunnableCancellationSignal, resources.RunnableRecoveryRecompute), waiting) - seen := launch(t, built, invocationFor(t, `{"text":"a","note":null,"tags":[]}`, time.Minute), - corerunnable.EndedOnCancel, true) - - if seen.exit != 68 { - t.Fatalf("exit = %d, want 68\nstderr:\n%s", seen.exit, seen.stderr) - } - if !seen.present { - t.Fatal("a handled interruption reported no result, so the launcher cannot tell it was handled") - } - if got := seen.completion.GetOutcome(); got != basev0.RunnableCompletion_CANCELED { - t.Errorf("outcome = %s, want CANCELED (%s)", got, seen.completion.GetMessage()) - } - // An interruption the harness handled is still an uncertain end: it - // says the work stopped, not what became of the effect. - if corerunnable.OutcomeIsCertain(seen.completion.GetOutcome()) { - t.Error("a handled interruption is not a certain outcome") - } - }) - - t.Run("not declared", func(t *testing.T) { - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), waiting) - seen := launch(t, built, invocationFor(t, `{"text":"a","note":null,"tags":[]}`, time.Minute), - corerunnable.EndedOnCancel, true) - - if seen.signal == "" { - t.Fatalf("the process was not ended by the signal: exit %d", seen.exit) - } - if seen.present { - t.Errorf("a package declaring no cancellation claimed a handled interruption: %s", seen.result) - } - }) -} - -// A result at the path is this invocation's or it is nothing. An earlier -// attempt's document left there would otherwise be read as this run's proven -// result, which is what makes a retried uncertain invocation dangerous. -func TestAnEarlierResultIsClearedBeforeTheRequestIsRead(t *testing.T) { - const failing = `package wordcount - -import ( - "context" - "errors" -) - -func Handle(ctx context.Context, in Input) (Output, error) { - return Output{}, errors.New("this attempt proves nothing") -} -` - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), failing) - - inv := invocationFor(t, `{"text":"a","note":null,"tags":[]}`, time.Minute) - prepared, err := corerunnable.PrepareInvocation(inv, built.pkg) - if err != nil { - t.Fatalf("PrepareInvocation: %v", err) - } - document, err := corerunnable.EncodeInvocation(prepared) - if err != nil { - t.Fatalf("EncodeInvocation: %v", err) - } - dir := t.TempDir() - invocationPath := filepath.Join(dir, "invocation.json") - resultPath := filepath.Join(dir, "result.json") - if err := os.WriteFile(invocationPath, document, 0o600); err != nil { - t.Fatalf("writing the invocation: %v", err) - } - stale := `{"protocol":"codefly.runnable/v1","invocation_id":"inv-1","status":"SUCCEEDED","output":"e30="}` - if err := os.WriteFile(resultPath, []byte(stale), 0o600); err != nil { - t.Fatalf("writing the stale result: %v", err) - } - - seen := runProcess(t, built, prepared, invocationPath, resultPath, - corerunnable.InvocationEnvironment(prepared, invocationPath, resultPath), corerunnable.EndedOnItsOwn, false) - - if seen.present { - t.Fatalf("an earlier attempt's result survived an outcome that proves nothing: %s", seen.result) - } - if got := seen.completion.GetOutcome(); corerunnable.OutcomeIsCertain(got) { - t.Errorf("outcome = %s, which reads an earlier attempt's document as this run's", got) - } -} - -// The output bound is the declaration's, and an output over it is not a -// completion the launcher may record. -func TestAnOversizedOutputIsRefused(t *testing.T) { - const verbose = `package wordcount - -import ( - "context" - "strings" -) - -func Handle(ctx context.Context, in Input) (Output, error) { - return Output{Total: 1, Echo: strings.Repeat("x", 4096)}, nil -} -` - runnable := declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute) - runnable.Execution.Payload = &resources.RunnablePayload{MaxInputBytes: 65536, MaxOutputBytes: 256} - built := compile(t, runnable, verbose) - - seen := launch(t, built, invocationFor(t, `{"text":"a","note":null,"tags":[]}`, time.Minute), corerunnable.EndedOnItsOwn, false) - - if seen.exit != 65 { - t.Fatalf("exit = %d, want 65\nstderr:\n%s", seen.exit, seen.stderr) - } - if seen.present { - t.Errorf("an oversized output was reported as a completion: %s", seen.result) - } - if !strings.Contains(seen.stderr, "over the declared 256 byte bound") { - t.Errorf("stderr does not name the bound:\n%s", seen.stderr) - } -} - -// A package whose uncertain outcome is resolved by looking up an effect cannot -// run an invocation that carries no effect identity to look up. -func TestReceiptRecoveryRequiresAnEffectIdentity(t *testing.T) { - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryReceipt), echoHandler) - - document := `{"protocol":"codefly.runnable/v1","runnable":{"name":"word-count","module":"proof","workspace":"proof","version":"0.0.1"},` + - `"invocation_id":"inv-1","intent_id":"intent-1","issued_at":"2026-09-13T10:00:00Z","deadline":"2026-09-13T10:01:00Z","input":"` + - base64.StdEncoding.EncodeToString([]byte(`{"text":"a","note":null,"tags":[]}`)) + `"}` - - seen := launchRaw(t, built, document, nil) - if seen.exit != 69 { - t.Fatalf("exit = %d, want 69\nstderr:\n%s", seen.exit, seen.stderr) - } - if !strings.Contains(seen.stderr, "effect_id is required for receipt recovery") { - t.Errorf("stderr does not name the reason:\n%s", seen.stderr) - } -} - -// The handler is given the caller's identity, not one it invents: an effect -// recorded under it is the one the caller looks up. -func TestTheHandlerSeesTheCallersInvocation(t *testing.T) { - const reporting = `package wordcount - -import ( - "context" - "errors" -) - -func Handle(ctx context.Context, in Input) (Output, error) { - invocation, ok := InvocationFrom(ctx) - if !ok { - return Output{}, errors.New("the handler was given no invocation") - } - if invocation.ID != "inv-1" || invocation.IntentID != "intent-1" { - return Output{}, errors.New("the invocation identity is not the caller's: " + invocation.ID + "/" + invocation.IntentID) - } - if invocation.Release.Version != "0.0.1" || invocation.Release.Workspace != "proof" { - return Output{}, errors.New("the release is not the one this package implements") - } - if _, hasDeadline := ctx.Deadline(); !hasDeadline { - return Output{}, errors.New("the handler was given no deadline") - } - return Output{Total: 1, Echo: invocation.ID}, nil -} -` - built := compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), reporting) - seen := launch(t, built, invocationFor(t, `{"text":"a","note":null,"tags":[]}`, time.Minute), corerunnable.EndedOnItsOwn, false) - - if seen.completion.GetOutcome() != basev0.RunnableCompletion_SUCCEEDED { - t.Fatalf("outcome = %s (%s)\nstderr:\n%s", seen.completion.GetOutcome(), seen.completion.GetMessage(), seen.stderr) - } - if output := decodeOutput(t, seen.completion); output["echo"] != "inv-1" { - t.Errorf("echo = %v, want the invocation identity", output["echo"]) - } -} diff --git a/pkg/generate/scaffold_test.go b/pkg/generate/scaffold_test.go new file mode 100644 index 0000000..a60a98c --- /dev/null +++ b/pkg/generate/scaffold_test.go @@ -0,0 +1,166 @@ +package generate_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + corerunnable "github.com/codefly-dev/core/runnable" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/codefly-dev/runnable-go/pkg/generate" +) + +// The harness is qualified against core rather than against this package's own +// idea of the contract: every call carries core's own header spellings and is +// sent to the procedure core pins, and every outcome is the one core's +// ClassifyServed concludes. A harness that agreed only with its own tests would +// agree with no caller. + +const ( + releaseName = "word-count" + releaseModule = "proof" + releaseWorkspace = "proof" + releaseVersion = "0.0.1" + packagePkg = "wordcount" +) + +func release() *basev0.RunnableIdentity { + return &basev0.RunnableIdentity{ + Name: releaseName, + Module: releaseModule, + Workspace: releaseWorkspace, + Version: releaseVersion, + } +} + +// declaration is the runnable the proof package implements: every value type of +// the bounded profile, and optional and nullable declared apart. +func declaration(t *testing.T, cancellation resources.RunnableCancellation, recovery resources.RunnableRecovery) *resources.Runnable { + t.Helper() + runnable := &resources.Runnable{ + Kind: resources.RunnableKind, + Name: releaseName, + Version: releaseVersion, + Agent: &resources.Agent{ + Kind: resources.RunnableAgent, Name: "go", Publisher: "codefly.dev", Version: "0.0.1", + }, + Contract: contractOf( + []*resources.RunnableField{ + field("text", resources.RunnableFieldString), + {Name: "repeat", Type: resources.RunnableFieldInteger, Optional: true}, + {Name: "note", Type: resources.RunnableFieldString, Nullable: true}, + {Name: "options", Type: resources.RunnableFieldObject, Optional: true, Fields: []*resources.RunnableField{ + field("limit", resources.RunnableFieldInteger), + }}, + {Name: "tags", Type: resources.RunnableFieldArray, Items: field("", resources.RunnableFieldString)}, + }, + []*resources.RunnableField{ + field("total", resources.RunnableFieldInteger), + field("echo", resources.RunnableFieldString), + }, + ), + Entrypoint: &resources.RunnableEntrypoint{Handler: "handler.go"}, + Execution: &resources.RunnableExecution{ + Facilities: []resources.RunnableFacility{resources.RunnableFacilityGenerated}, + Timeout: "5m", + Cancellation: cancellation, + Recovery: recovery, + }, + } + runnable.SetModule(releaseModule) + if err := runnable.Validate(); err != nil { + t.Fatalf("declaration is not valid: %v", err) + } + return runnable +} + +// compiled is a generated runnable built the way a placement would receive it. +type compiled struct { + runnable *resources.Runnable + pkg *basev0.RunnablePackage + binary string +} + +// compile generates the runnable, writes the author's handler and builds the +// executable that serves it. The module resolves nothing from the network: +// generated Go depends on the standard library alone, which is what lets a +// package be built and archived without a proxy. +func compile(t *testing.T, runnable *resources.Runnable, handler string) compiled { + t.Helper() + dir := t.TempDir() + + if err := os.WriteFile(filepath.Join(dir, "handler.go"), []byte(handler), 0o600); err != nil { + t.Fatalf("writing the handler: %v", err) + } + // Scaffold writes the module declaration and leaves the handler alone. + if err := generate.Scaffold(runnable, dir); err != nil { + t.Fatalf("Scaffold: %v", err) + } + if err := generate.GenerateForRelease(runnable, dir, release()); err != nil { + t.Fatalf("GenerateForRelease: %v", err) + } + + binary := filepath.Join(t.TempDir(), "runnable") + build := exec.Command("go", "build", "-o", binary, "./"+generate.CommandDirectory) + build.Dir = dir + build.Env = append(os.Environ(), "GOFLAGS=-mod=mod", "GOPROXY=off") + if output, err := build.CombinedOutput(); err != nil { + source, _ := os.ReadFile(filepath.Join(dir, generate.HarnessFile)) + t.Fatalf("building the generated runnable: %v\n%s\n--- harness ---\n%s", err, output, source) + } + return compiled{runnable: runnable, pkg: corePackageOf(t, runnable), binary: binary} +} + +// corePackageOf assembles the immutable package descriptor core validates an +// invocation against, from the same declaration the harness was generated from. +// The build and artifact facts are stand-ins: this agent does not package yet, +// and none of them reaches the harness. +func corePackageOf(t *testing.T, runnable *resources.Runnable) *basev0.RunnablePackage { + t.Helper() + digest := "sha256:" + strings.Repeat("ab", 32) + agent, err := runnable.Agent.Proto() + if err != nil { + t.Fatalf("agent proto: %v", err) + } + prepared, err := corerunnable.PreparePackage(&basev0.RunnablePackage{ + Schema: corerunnable.PackageSchemaV1, + Identity: release(), + Agent: agent, + Contract: runnable.Contract.Proto(), + Execution: runnable.Execution.Proto(), + Build: &basev0.RunnableBuild{ + Handler: &basev0.RunnableInputDigest{Path: "handler.go", Digest: digest}, + Inputs: []*basev0.RunnableInputDigest{{Path: generate.ModuleFile, Digest: digest}}, + HarnessDigest: digest, + Toolchain: "go" + strings.TrimPrefix(runtime.Version(), "go"), + ConfigurationDigest: digest, + }, + Artifacts: []*basev0.RunnableArtifact{{ + Kind: basev0.RunnableArtifact_ARCHIVE, + Platform: runtime.GOOS + "/" + runtime.GOARCH, + Reference: "word-count.tar.gz", + Digest: digest, + Command: []string{generate.CommandDirectory}, + }}, + }) + if err != nil { + t.Fatalf("PreparePackage: %v", err) + } + return prepared +} + +// resultDocument renders a RunnableResult as the proto3 JSON core parses. +func resultDocument(t *testing.T, result *basev0.RunnableResult) []byte { + t.Helper() + document, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(result) + if err != nil { + t.Fatalf("encoding the result: %v", err) + } + return document +} diff --git a/pkg/generate/served_test.go b/pkg/generate/served_test.go new file mode 100644 index 0000000..f160df7 --- /dev/null +++ b/pkg/generate/served_test.go @@ -0,0 +1,384 @@ +package generate_test + +import ( + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + corerunnable "github.com/codefly-dev/core/runnable" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// A generated harness serves; it is not launched. These tests start the real +// built binary, call it over HTTP exactly as the runtime's invoker does — core's +// header spellings, core's procedure, the bounded input as the whole body — and +// classify what came back with core's own ClassifyServed. Nothing here restates +// the contract: every constant is read from core, so a harness that drifted from +// it would fail here rather than in a live composition. + +const workContext = "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSJ9.signed" + +// served is a running generated harness. +type served struct { + built compiled + address string + stderr *strings.Builder +} + +// serve starts the built binary and waits until it answers. The port is chosen +// by the operating system and handed to the harness the way a placement hands +// it one: nothing here picks a number. +func serve(t *testing.T, built compiled) served { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserving a port: %v", err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("releasing the reserved port: %v", err) + } + + stderr := &strings.Builder{} + process := exec.Command(built.binary) + process.Env = []string{corerunnable.ListenAddressEnv + "=" + address} + process.Stderr = stderr + if err := process.Start(); err != nil { + t.Fatalf("starting the runnable: %v", err) + } + t.Cleanup(func() { + _ = process.Process.Signal(os.Interrupt) + done := make(chan struct{}) + go func() { _, _ = process.Process.Wait(); close(done) }() + select { + case <-done: + case <-time.After(10 * time.Second): + _ = process.Process.Kill() + } + }) + + for range 400 { + conn, err := net.DialTimeout("tcp", address, 50*time.Millisecond) + if err == nil { + _ = conn.Close() + return served{built: built, address: address, stderr: stderr} + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("the runnable never answered on %s\n--- stderr ---\n%s", address, stderr.String()) + return served{} +} + +// call sends one call the way the runtime's invoker sends one, and classifies +// the answer with core. +func (s served) call(t *testing.T, procedure string, payload string, headers map[string]string) *basev0.RunnableServedCompletion { + t.Helper() + request, err := http.NewRequest(http.MethodPost, "http://"+s.address+procedure, strings.NewReader(payload)) + if err != nil { + t.Fatalf("building the call: %v", err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set(corerunnable.WorkContextHeader, workContext) + for key, value := range headers { + if value == "" { + request.Header.Del(key) + continue + } + request.Header.Set(key, value) + } + + observed := corerunnable.Call{CalledAt: time.Now().UTC(), AuthorityResolved: true} + response, err := (&http.Client{Timeout: 2 * time.Minute}).Do(request) + observed.AnsweredAt = time.Now().UTC() + if err != nil { + observed.Trouble = err + } else { + defer response.Body.Close() + observed.Answered = true + observed.FailureCode = response.Header.Get(corerunnable.FailureCodeHeader) + body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + t.Fatalf("reading the answer: %v", err) + } + observed.Response = s.resultOf(t, response.StatusCode, string(body)) + if response.StatusCode != http.StatusOK && observed.FailureCode == "" { + observed.Trouble = fmt.Errorf("owner answered %d: %s", response.StatusCode, body) + } + } + completion, err := corerunnable.ClassifyServed(s.invocation(t), s.built.pkg, observed) + if err != nil { + t.Fatalf("ClassifyServed: %v", err) + } + return completion +} + +// resultOf turns the owner's answer into the RunnableResult core classifies. +// A 200 is the bounded output document; anything else answered no output at +// all, and a result invented for it would be a completion the owner never gave. +func (s served) resultOf(t *testing.T, status int, body string) []byte { + t.Helper() + if status != http.StatusOK { + return nil + } + result := &basev0.RunnableResult{ + Protocol: resources.RunnableServedProtocolV1, + InvocationId: "inv-1", + Status: basev0.RunnableResult_SUCCEEDED, + Output: []byte(body), + } + return resultDocument(t, result) +} + +func (s served) invocation(t *testing.T) *basev0.RunnableInvocation { + t.Helper() + issued := time.Now().UTC() + return &basev0.RunnableInvocation{ + Protocol: resources.RunnableServedProtocolV1, + Runnable: release(), + InvocationId: "inv-1", + IntentId: "intent-1", + EffectId: "effect-1", + IssuedAt: timestamppb.New(issued), + Deadline: timestamppb.New(issued.Add(time.Minute)), + Input: []byte(`{"text":"a b","tags":[],"note":null}`), + Identity: &basev0.RunnableInvocationIdentity{ + Carrier: &basev0.RunnableInvocationIdentity_WorkContext{WorkContext: workContext}, + }, + } +} + +// echoHandler counts the words of text and echoes it back. +const echoHandler = `package wordcount + +import ( + "context" + "errors" + "strings" +) + +func Handle(ctx context.Context, in Input) (Output, error) { + if in.Text == "" { + return Output{}, Fail("empty_text", "text has no words") + } + if in.Text == "untyped" { + return Output{}, errors.New("something went wrong") + } + if in.Text == "panic" { + panic("the handler gave up") + } + if in.Text == "slow" { + <-ctx.Done() + return Output{Total: 0, Echo: "never"}, nil + } + return Output{Total: int64(len(strings.Fields(in.Text))), Echo: in.Text}, nil +} +` + +func TestAGeneratedRunnableAnswersACall(t *testing.T) { + harness := serve(t, compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), echoHandler)) + + completion := harness.call(t, corerunnable.ServedInvokeProcedure, + `{"text":"the quick brown fox","tags":[],"note":null}`, nil) + if completion.GetOutcome() != basev0.RunnableServedOutcome_SERVED_SUCCEEDED { + t.Fatalf("outcome %s, message %q\n--- stderr ---\n%s", + completion.GetOutcome(), completion.GetMessage(), harness.stderr.String()) + } + if !corerunnable.ServedOutcomeIsCertain(completion.GetOutcome()) { + t.Fatal("a validated answer must prove the effect committed") + } + if got := string(completion.GetResult().GetOutput()); !strings.Contains(got, `"total":4`) { + t.Fatalf("output %q does not carry the handler's count", got) + } + + // The harness serves; it does not exit after one call. A second call + // proves it, which is the whole difference from the placement this + // replaced. + second := harness.call(t, corerunnable.ServedInvokeProcedure, `{"text":"one","tags":[],"note":null}`, nil) + if second.GetOutcome() != basev0.RunnableServedOutcome_SERVED_SUCCEEDED { + t.Fatalf("the second call did not succeed: %s %q", second.GetOutcome(), second.GetMessage()) + } +} + +// TestOnlyTheHandlersOwnFailureIsProven is the property the whole taxonomy +// rests on. The failure-code header, and nothing else, tells a caller that no +// effect committed — so a harness that set it for a panic, an untyped error or +// a deadline would be asserting on the handler's behalf that its effect did not +// happen, and a caller would stop looking for a receipt that exists. +func TestOnlyTheHandlersOwnFailureIsProven(t *testing.T) { + harness := serve(t, compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), echoHandler)) + + declared := harness.call(t, corerunnable.ServedInvokeProcedure, `{"text":"","tags":[],"note":null}`, nil) + if declared.GetOutcome() != basev0.RunnableServedOutcome_SERVED_OWNER_FAILED { + t.Fatalf("a declared Failure must be SERVED_OWNER_FAILED, got %s (%q)", declared.GetOutcome(), declared.GetMessage()) + } + if declared.GetFailureCode() != "empty_text" { + t.Fatalf("the operation's own code must travel verbatim, got %q", declared.GetFailureCode()) + } + if !corerunnable.ServedOutcomeIsCertain(declared.GetOutcome()) { + t.Fatal("a declared failure proves no effect committed") + } + + for _, tc := range []struct{ name, text string }{ + {"an untyped error", "untyped"}, + {"a panic", "panic"}, + } { + t.Run(tc.name, func(t *testing.T) { + completion := harness.call(t, corerunnable.ServedInvokeProcedure, + `{"text":"`+tc.text+`","tags":[],"note":null}`, nil) + if corerunnable.ServedOutcomeIsCertain(completion.GetOutcome()) { + t.Fatalf("%s must leave the effect unproven, got %s", tc.name, completion.GetOutcome()) + } + if completion.GetFailureCode() != "" { + t.Fatalf("%s must not carry a failure code, got %q", tc.name, completion.GetFailureCode()) + } + }) + } +} + +// A call with no Work Context is the one thing the contract exists to prevent. +// The harness refuses it rather than running under whatever identity its own +// process happens to have, which is the state the required identity slot +// removed. +func TestACallWithNoWorkContextIsRefused(t *testing.T) { + harness := serve(t, compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), echoHandler)) + + completion := harness.call(t, corerunnable.ServedInvokeProcedure, + `{"text":"a b","tags":[],"note":null}`, map[string]string{corerunnable.WorkContextHeader: ""}) + if corerunnable.ServedOutcomeIsCertain(completion.GetOutcome()) { + t.Fatalf("a refused call must not be reported as a proven outcome, got %s", completion.GetOutcome()) + } + if !strings.Contains(harness.stderr.String()+completion.GetMessage(), "Work Context") { + t.Fatalf("the refusal must name the missing Work Context; message %q stderr %q", + completion.GetMessage(), harness.stderr.String()) + } +} + +// The payload is checked against the installed contract before the handler +// runs, so a handler never sees a body the contract forbids. +func TestThePayloadIsCheckedBeforeTheHandlerRuns(t *testing.T) { + harness := serve(t, compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), echoHandler)) + + for _, tc := range []struct{ name, payload string }{ + {"a missing required field", `{"tags":[],"note":null}`}, + {"an undeclared field", `{"text":"a","tags":[],"note":null,"extra":1}`}, + {"the wrong value type", `{"text":1,"tags":[],"note":null}`}, + {"a null where none is declared", `{"text":null,"tags":[],"note":null}`}, + {"a body that is not an object", `["text"]`}, + } { + t.Run(tc.name, func(t *testing.T) { + completion := harness.call(t, corerunnable.ServedInvokeProcedure, tc.payload, nil) + if completion.GetOutcome() == basev0.RunnableServedOutcome_SERVED_SUCCEEDED { + t.Fatalf("%s was accepted", tc.name) + } + if completion.GetFailureCode() != "" { + t.Fatalf("a refused payload is not the operation's own failure, got %q", completion.GetFailureCode()) + } + }) + } +} + +// The deadline header bounds the call, and a handler still running when it +// passes leaves the effect unproven rather than failed. +func TestTheDeadlineHeaderBoundsTheCall(t *testing.T) { + harness := serve(t, compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), echoHandler)) + + deadline := time.Now().UTC().Add(2 * time.Second) + completion := harness.call(t, corerunnable.ServedInvokeProcedure, `{"text":"slow","tags":[],"note":null}`, + map[string]string{corerunnable.DeadlineHeader: deadline.Format(corerunnable.DeadlineFormat)}) + if completion.GetOutcome() == basev0.RunnableServedOutcome_SERVED_SUCCEEDED { + t.Fatal("a handler that ran past the deadline must not report success") + } + if corerunnable.ServedOutcomeIsCertain(completion.GetOutcome()) { + t.Fatalf("a deadline leaves the effect unproven, got %s", completion.GetOutcome()) + } + + // A deadline already in the past is refused before the handler runs: + // spending the attempt would make a timeout look like work that happened. + past := harness.call(t, corerunnable.ServedInvokeProcedure, `{"text":"a b","tags":[],"note":null}`, + map[string]string{corerunnable.DeadlineHeader: time.Now().UTC().Add(-time.Minute).Format(corerunnable.DeadlineFormat)}) + if past.GetOutcome() == basev0.RunnableServedOutcome_SERVED_SUCCEEDED { + t.Fatal("a call whose deadline had passed must not run") + } +} + +// A receipt-recovery operation serves the receipt route, and an absent receipt +// answers without being an error: absent is "not yet known", never "no". +func TestTheReceiptRouteAnswersAbsentWithoutAnError(t *testing.T) { + handler := echoHandler + ` + +func ReceiptOf(ctx context.Context, in Input) (Receipt, error) { + if in.Text == "committed" { + return Receipt{Output: Output{Total: 7, Echo: in.Text}, Found: true}, nil + } + return Receipt{}, nil +} +` + harness := serve(t, compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryReceipt), handler)) + + found := harness.call(t, corerunnable.ServedLookupProcedure, `{"text":"committed","tags":[],"note":null}`, + map[string]string{corerunnable.EffectHeader: "effect-1"}) + if found.GetOutcome() != basev0.RunnableServedOutcome_SERVED_SUCCEEDED { + t.Fatalf("a found receipt answers the operation's own output, got %s (%q)", found.GetOutcome(), found.GetMessage()) + } + + absent := harness.call(t, corerunnable.ServedLookupProcedure, `{"text":"never","tags":[],"note":null}`, + map[string]string{corerunnable.EffectHeader: "effect-2"}) + if absent.GetOutcome() == basev0.RunnableServedOutcome_SERVED_SUCCEEDED { + t.Fatal("an absent receipt is not a committed effect") + } + if corerunnable.ServedOutcomeIsCertain(absent.GetOutcome()) { + t.Fatalf("an absent receipt is inconclusive, never proof; got %s", absent.GetOutcome()) + } + + // An effect identity is what a receipt is keyed by, so a receipt-recovery + // call carrying none is refused rather than run. + none := harness.call(t, corerunnable.ServedInvokeProcedure, `{"text":"a b","tags":[],"note":null}`, + map[string]string{corerunnable.EffectHeader: ""}) + if none.GetOutcome() == basev0.RunnableServedOutcome_SERVED_SUCCEEDED { + t.Fatal("a receipt-recovery call with no effect identity must be refused") + } +} + +// The handler sees the caller's identity, which is the only thing a handler may +// do with a Work Context: forward it. Parsing or reconstructing one is not the +// harness's business and it never does either. +func TestTheHandlerSeesTheCallersIdentity(t *testing.T) { + handler := `package wordcount + +import ( + "context" + "strings" +) + +func Handle(ctx context.Context, in Input) (Output, error) { + invocation, ok := InvocationFrom(ctx) + if !ok { + return Output{}, Fail("no_invocation", "the handler saw no invocation") + } + return Output{ + Total: int64(len(strings.Fields(in.Text))), + Echo: invocation.WorkContext + "|" + invocation.EffectID + "|" + invocation.Release.Name, + }, nil +} +` + harness := serve(t, compile(t, declaration(t, resources.RunnableCancellationNone, resources.RunnableRecoveryRecompute), handler)) + completion := harness.call(t, corerunnable.ServedInvokeProcedure, `{"text":"a b","tags":[],"note":null}`, + map[string]string{corerunnable.EffectHeader: "effect-9"}) + if completion.GetOutcome() != basev0.RunnableServedOutcome_SERVED_SUCCEEDED { + t.Fatalf("outcome %s (%q)", completion.GetOutcome(), completion.GetMessage()) + } + got := string(completion.GetResult().GetOutput()) + for _, want := range []string{workContext, "effect-9", releaseName} { + if !strings.Contains(got, want) { + t.Fatalf("the handler did not see %q; output %s", want, got) + } + } +}