|
1 | | -# `codefly.runnable/v1` — launcher and Python harness |
2 | | - |
3 | | -Core defines the public protocol in |
4 | | -[`runnable_invocation.proto`](https://github.com/codefly-dev/core/blob/b3470f0096cd818d6a90c67dc31e9ad09824d822/proto/codefly/base/v0/runnable_invocation.proto) |
5 | | -and supplies `PrepareInvocation`, `EncodeInvocation`, `InvocationEnvironment`, |
6 | | -`ParseResult` and `Complete`. The Python process implements this wire format; |
7 | | -Go qualification tests encode real requests with core and parse the actual |
8 | | -Python result with core. The earlier agent-specific framing is obsolete. |
| 1 | +# `codefly.runnable.served/v1` — the harness a Python runnable serves |
| 2 | + |
| 3 | +Core defines the contract in |
| 4 | +[`runnable/served.go`](https://github.com/codefly-dev/core/blob/main/runnable/served.go) |
| 5 | +and [`runnable_invocation.proto`](https://github.com/codefly-dev/core/blob/main/proto/codefly/base/v0/runnable_invocation.proto), |
| 6 | +and supplies `PrepareInvocation`, `ParseResult`, `ClassifyServed` and |
| 7 | +`ServedOutcomeIsCertain`. This harness implements the owner's half; the Go |
| 8 | +qualification tests call the real package over HTTP the way the durable-work |
| 9 | +runtime's invoker calls it and classify every answer with core's own |
| 10 | +`ClassifyServed`, so a harness that drifted from the contract fails there rather |
| 11 | +than in a live composition. |
| 12 | + |
| 13 | +**A runnable is called, not launched.** The launcher framing this replaced — |
| 14 | +two document paths in the environment, a request read off disk, a result renamed |
| 15 | +into place, and an exit code a launcher interpreted — went with the native |
| 16 | +placement (core#681). A package naming `codefly.runnable/v1` is refused by name. |
9 | 17 |
|
10 | 18 | ## Transport |
11 | 19 |
|
12 | | -One invocation is one process, with package-relative argv and the unpacked |
13 | | -artifact root as its working directory. No shell expansion is involved. |
| 20 | +The package's command starts a server. The one fact it cannot derive is where to |
| 21 | +listen: the port is allocated by whatever placed it, and a harness choosing its |
| 22 | +own would be right on one machine for ten minutes. |
14 | 23 |
|
15 | 24 | | Environment variable | Value | |
16 | 25 | | --- | --- | |
17 | | -| `CODEFLY__RUNNABLE_PROTOCOL` | `codefly.runnable/v1` | |
18 | | -| `CODEFLY__RUNNABLE_INVOCATION` | absolute path to the launcher's request | |
19 | | -| `CODEFLY__RUNNABLE_RESULT` | absolute path for the harness's atomic result | |
20 | | - |
21 | | -The documents use proto3 JSON with snake_case field names. `input` and `output` |
22 | | -are base64 encodings of exact UTF-8 JSON objects. Bounds apply to decoded bytes, |
23 | | -including whitespace, and preserve signed 64-bit integers. The harness caps its |
24 | | -request read at base64-expanded input capacity plus 64 KiB of framing. |
25 | | - |
26 | | -```json |
27 | | -{ |
28 | | - "protocol": "codefly.runnable/v1", |
29 | | - "runnable": {"name":"word-count","module":"proof","workspace":"proof","version":"0.0.1"}, |
30 | | - "invocation_id": "inv-1", |
31 | | - "intent_id": "intent-1", |
32 | | - "issued_at": "2026-09-13T10:00:00Z", |
33 | | - "deadline": "2026-09-13T10:01:00Z", |
34 | | - "input": "eyJ0ZXh0Ijoib25lIHR3byB0aHJlZSJ9" |
35 | | -} |
36 | | -``` |
| 26 | +| `CODEFLY__RUNNABLE_ADDRESS` | the allocated `host:port`, required | |
37 | 27 |
|
38 | | -This input decodes to `{"text":"one two three"}`. An invocation must match the |
39 | | -release embedded by the Builder. Standalone generation, which has no owning |
40 | | -workspace, checks the declaration's name and version. `invocation_id` and |
41 | | -`intent_id` are required and at most 128 characters. `effect_id` is required for |
42 | | -`receipt` recovery and optional for `recompute`. |
| 28 | +One call is `POST` to a procedure core pins, with the bounded input document as |
| 29 | +the whole body and no Codefly envelope around it — a field Codefly added to an |
| 30 | +owner's body would be a field the owner never described. |
43 | 31 |
|
44 | | -## Results and certainty |
| 32 | +| Route | Answers | |
| 33 | +| --- | --- | |
| 34 | +| `/codefly.runnable.v0.Runnable/Invoke` | one call: the bounded output document, 200 | |
| 35 | +| `/codefly.runnable.v0.Runnable/Lookup` | what an effect committed; served only by a `receipt` package | |
45 | 36 |
|
46 | | -A successful result for the example is: |
| 37 | +The per-call facts that are not the body travel as headers, spelled exactly as |
| 38 | +core spells them. `pkg/contract`'s `TestTheFramingIsOneContract` holds the Python |
| 39 | +literals equal to core's constants, because nothing at build time connects them. |
47 | 40 |
|
48 | | -```json |
49 | | -{"protocol":"codefly.runnable/v1","invocation_id":"inv-1","status":"SUCCEEDED","output":"eyJjb3VudCI6M30="} |
50 | | -``` |
| 41 | +| Header | Meaning | |
| 42 | +| --- | --- | |
| 43 | +| `X-Codefly-Work-Context` | the caller's minted capability, carried verbatim — **required** | |
| 44 | +| `Codefly-Runnable-Effect-Id` | the effect identity a receipt is keyed by; required for `receipt` recovery | |
| 45 | +| `Codefly-Runnable-Deadline` | when the caller stops waiting, RFC 3339 with nanoseconds | |
| 46 | +| `Codefly-Runnable-Failure-Code` | on the answer: the operation's own typed failure code | |
| 47 | + |
| 48 | +A call carrying no Work Context is **refused**, not run under whatever identity |
| 49 | +the process happens to have: that is the state core's required identity slot |
| 50 | +exists to remove. The harness never parses or reconstructs the capability — the |
| 51 | +handler reads it from `context.invocation.work_context` and forwards it to |
| 52 | +whatever it calls in turn, and that is the whole of what either does with it. |
51 | 53 |
|
52 | | -The output decodes to `{"count":3}`. The harness validates the output schema and |
53 | | -byte limit before atomically renaming the result into place. It reports a certain |
54 | | -operation failure only when the handler explicitly raises: |
| 54 | +## What an answer proves |
| 55 | + |
| 56 | +Only the handler's own `HandlerFailure` proves that no effect committed, and it |
| 57 | +is the only thing that sets the failure-code header: |
55 | 58 |
|
56 | 59 | ```python |
57 | 60 | from codefly_runnable import HandlerFailure |
58 | | -raise HandlerFailure("unavailable", "The operation was refused") |
| 61 | +raise HandlerFailure("card_declined", "the issuer declined") |
59 | 62 | ``` |
60 | 63 |
|
61 | | -This writes `status: FAILED` with `error: {code, message}` and no output. Use it |
62 | | -only when the operation knows its effect's disposition. An unexpected exception, |
63 | | -invalid payload, invalid return, import failure or timeout writes no result. |
64 | | -When the generated contract declares signal cancellation, a handled interruption |
65 | | -writes `status: INTERRUPTED` without output. Core classifies it as `CANCELED`, |
66 | | -which leaves the effect uncertain; it is never a certain operation failure. |
67 | | -A contract declaring no cancellation does not install interruption handlers. |
68 | | - |
69 | | -To prevent an earlier result from surviving an outcome that writes nothing, the harness removes any document at the |
70 | | -result path before it reads the request, and refuses to start if it cannot. A |
71 | | -document at that path is always this invocation's, so a launcher reusing the |
72 | | -path cannot read an earlier attempt's result as this one's. The result is |
73 | | -written 0644: which accounts may read it is the facility's choice. Exit codes |
74 | | -are diagnostics, not portable completion outcomes: |
75 | | - |
76 | | -| Exit | Diagnostic | |
77 | | -| --- | --- | |
78 | | -| 0 | validated success, unless author code bypassed the harness | |
79 | | -| 64 | input schema rejection before author import | |
80 | | -| 65 | invalid or oversized output, or result write failure | |
81 | | -| 66 | explicit failure or unexpected handler/import exception | |
82 | | -| 67 | harness deadline timer | |
83 | | -| 68 | harness interruption | |
84 | | -| 69 | invalid framing or environment | |
85 | | - |
86 | | -The launcher calls core `Complete`. A valid result wins; otherwise a launcher |
87 | | -termination reason takes precedence, then an invalid result, then a nonzero |
88 | | -exit/signal, then exit zero with no result. A harness exit 67 by itself is |
89 | | -`CRASHED`; the launcher must record its own deadline termination to classify |
90 | | -`TIMED_OUT`. Only `SUCCEEDED` and explicit `FAILED` are certain. |
91 | | - |
92 | | -## Deadlines and logs |
93 | | - |
94 | | -For the native checkpoint, the harness preserves the original absolute deadline |
95 | | -on the same host. Expired requests never import author code. The timer covers |
96 | | -imports and the handler, and a swallowed signal cannot produce success. The |
97 | | -launcher remains responsible for terminating the process group and descendants. |
98 | | -Core #474 describes a clock-offset budget based on `deadline - issued_at`; the |
99 | | -cross-host clock policy needs reconciliation before Kubernetes qualification. |
100 | | -No distributed deadline or cancellation guarantee is claimed here. |
101 | | - |
102 | | -`stdout` and `stderr` carry diagnostics only. The harness's own terminal |
103 | | -diagnostic is written after the handler's bound is released, so a handler that |
104 | | -exhausts the budget cannot erase the single record of why an uncertain |
105 | | -invocation ended. The generated harness uses the |
106 | | -release's `max_log_bytes` (4 MiB by default), bounding Python, native-library and |
107 | | -inherited subprocess writes. It may append one truncation marker. The launcher |
108 | | -must independently enforce its exact transport bound and record truncation. |
| 64 | +Everything else leaves the effect **unproven**, because the harness cannot know |
| 65 | +which side of its effect a handler stopped on. A caller that read a bare status |
| 66 | +as proof would stop looking for a receipt that exists. |
| 67 | + |
| 68 | +| The handler | Answer | What the caller concludes | |
| 69 | +| --- | --- | --- | |
| 70 | +| returned output the contract admits | `200`, the output document | proven committed | |
| 71 | +| raised `HandlerFailure` | `422` + the failure-code header | proven not committed | |
| 72 | +| raised anything else | `500`, no failure code | unproven | |
| 73 | +| was still running at the deadline | `504`, no failure code | unproven | |
| 74 | +| returned an answer the contract refuses | `500`, no failure code | unproven | |
| 75 | +| was never reached (payload, identity, effect id) | `400`, no failure code | unproven | |
| 76 | + |
| 77 | +The statuses are for humans and proxies: core classifies on the failure-code |
| 78 | +header and the answer document, never on a status class. |
| 79 | + |
| 80 | +## Deadlines |
| 81 | + |
| 82 | +`Codefly-Runnable-Deadline` bounds the call, measured from when the harness reads |
| 83 | +it rather than from an instant the caller stamped, so a clock offset neither |
| 84 | +shortens nor extends the work the caller asked for. A caller may shorten the |
| 85 | +declared timeout and never overrule it: the budget is the smaller of the two, and |
| 86 | +the declared one applies when no header is sent. A deadline already in the past |
| 87 | +is refused before the handler runs. |
| 88 | + |
| 89 | +The handler runs on its own thread, because Python cannot raise into one. When |
| 90 | +the budget passes the harness answers and the handler is still running; what it |
| 91 | +would have returned is no longer that call's outcome. |
| 92 | + |
| 93 | +## Signals and logs |
| 94 | + |
| 95 | +A signal stops the harness taking new calls and gives the calls in flight |
| 96 | +`SHUTDOWN_GRACE_SECONDS` to finish. Cutting them off would turn every one of them |
| 97 | +into an unproven effect at once. |
| 98 | + |
| 99 | +`stdout` and `stderr` carry diagnostics only, never completion data: a handler |
| 100 | +that printed its output would be indistinguishable from a library that printed a |
| 101 | +warning. The harness does **not** bound them. Per-call stream bounding is not |
| 102 | +something a server can do — concurrent calls share the process's descriptors — |
| 103 | +and `max_log_bytes` describes what the placement capturing those streams |
| 104 | +enforces, which is where it belongs. |
| 105 | + |
| 106 | +## The receipt route |
| 107 | + |
| 108 | +A package declaring `recovery: receipt` serves `Lookup`, and its handler module |
| 109 | +defines `receipt_of(context, input)` beside `handle`. Returning the output |
| 110 | +document answers 200; returning `None` answers 404, which is **not** an error: |
| 111 | +absent is "not yet known", never "no", and a caller reads it as inconclusive |
| 112 | +rather than as permission to invoke again. |
| 113 | + |
| 114 | +The function is required at startup rather than at the call that needed it: an |
| 115 | +owner that cannot answer what an effect committed leaves every uncertain outcome |
| 116 | +uncertain forever, and that is not something to discover during a recovery. A |
| 117 | +`recompute` package has nothing to look up, so the route is absent rather than |
| 118 | +present and answering "never". |
109 | 119 |
|
110 | 120 | ## Payload schema |
111 | 121 |
|
112 | 122 | `object`, `array`, `string`, signed 64-bit `integer` and `boolean` are supported. |
113 | 123 | `optional` and `nullable` remain distinct. There is no coercion: `3.0` is not an |
114 | | -integer, `1` is not a boolean, and undeclared keys are errors. Empty schemas accept |
115 | | -exactly `{}`. Validation errors identify the offending input or output path. |
| 124 | +integer, `1` is not a boolean, undeclared keys are errors, and a repeated key is |
| 125 | +refused rather than resolved to its last value. Empty schemas accept exactly |
| 126 | +`{}`. Validation errors identify the offending input or output path. Both |
| 127 | +payloads are bounded by the declared `max-input-bytes` / `max-output-bytes`, |
| 128 | +measured on the document that is actually sent. |
0 commit comments