nullrun 0.16.7__tar.gz → 0.16.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nullrun-0.16.7 → nullrun-0.16.8}/CHANGELOG.md +115 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/PKG-INFO +1 -1
- {nullrun-0.16.7 → nullrun-0.16.8}/pyproject.toml +1 -1
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/__version__.py +1 -1
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/runtime.py +123 -23
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/transport.py +69 -6
- {nullrun-0.16.7 → nullrun-0.16.8}/uv.lock +1 -1
- {nullrun-0.16.7 → nullrun-0.16.8}/.codecov.yml +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/.dockerignore +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/.gitignore +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/.pre-commit-config.yaml +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/Dockerfile +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/LICENSE +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/Makefile +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/README.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-A001.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-A002.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-A003.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-A004.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B001.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B002.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B003.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B004.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-B005.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-C000.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-C001.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-C003.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-C004.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-CH001.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-L001.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-O001.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-P001.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-R001.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-R002.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-T001.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-W002.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-W003.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/NR-W004.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/docs/errors/README.md +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/hatch_build.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/__init__.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/_handle.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/_registry.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/_singleton.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/actions.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/audit.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/breaker/__init__.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/breaker/circuit_breaker.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/breaker/exceptions.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/business_impact.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/capabilities.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/context.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/decorators.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/extractor.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/__init__.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/_safe_patch.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/auto.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/auto_requests.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/autogen.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/crewai.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/langgraph.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/instrumentation/llama_index.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/integrations/__init__.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/integrations/fastapi.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/messages.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/observability/__init__.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/observability/error_hooks.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/observability/status.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/py.typed +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/toolbox/__init__.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/toolbox/langgraph.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/toolbox/mcp.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/tracing.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/transport_websocket.py +0 -0
- {nullrun-0.16.7 → nullrun-0.16.8}/src/nullrun/uuid7.py +0 -0
|
@@ -1,3 +1,118 @@
|
|
|
1
|
+
## [0.16.8] - 2026-09-11
|
|
2
|
+
|
|
3
|
+
Patch release — closes the NR-A015 wire-shape gap on the SDK side. The
|
|
4
|
+
`/execute` `require_approval` arm (backend v3.79+) mints a fresh
|
|
5
|
+
server-side execution_id for the approval row and echoes it via
|
|
6
|
+
`reservation_id`. Pre-fix `runtime.execute` captured that id into the
|
|
7
|
+
contextvar AFTER `/gate` calls but not after `/execute`, so the post-
|
|
8
|
+
approval `/execute` re-fire sent the stale pre-arm execution_id;
|
|
9
|
+
`consume_approved`'s `WHERE execution_id = $3` predicate missed the
|
|
10
|
+
freshly-stamped row and fell through to the terminal
|
|
11
|
+
`APPROVAL_REPLAY_REJECTED` branch. This release wires the
|
|
12
|
+
post-`/execute` capture and syncs the kwargs dict so the re-fire uses
|
|
13
|
+
the freshly-minted id.
|
|
14
|
+
|
|
15
|
+
Also includes the AUTH-01 / HEART-01 sweep from the 24-driver pass:
|
|
16
|
+
reclassification of httpx transport errors on the auth path, plus a
|
|
17
|
+
public `Runtime.heartbeat()` wrapper.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **DEF-EXECUTE-CAPTURE-WIRING** — `runtime.execute` now calls
|
|
22
|
+
`_capture_server_minted_execution_id(result)` immediately after
|
|
23
|
+
`_transport.execute(...)` and syncs the re-fire kwargs dict to the
|
|
24
|
+
captured id (`src/nullrun/runtime.py`). The post-approval re-fire
|
|
25
|
+
now sends the freshly-minted execution_id stamped on the approval
|
|
26
|
+
row, so `consume_approved`'s `WHERE execution_id = $3` predicate
|
|
27
|
+
matches. Closes the SDK-side leg of NR-A015 on the `/execute`
|
|
28
|
+
require_approval arm.
|
|
29
|
+
- **DEF-WAIT-FOR-APPROVAL-EXEC-ID** — both
|
|
30
|
+
`_wait_for_approval_resolution` call-sites (`check_workflow_budget`
|
|
31
|
+
+ `runtime.execute`) now pass the captured server-minted
|
|
32
|
+
execution_id from the contextvar (with the prior
|
|
33
|
+
`org_id`/`workflow_id` sentinel as fallback) instead of the
|
|
34
|
+
`workflow_id` sentinel (`src/nullrun/runtime.py`). Diagnostic
|
|
35
|
+
improvement only — the WS handler matches on `approval_id` — but
|
|
36
|
+
log lines + entry metadata now reflect the server-minted id.
|
|
37
|
+
- **DEF-AUTH-01** — `NullRunRuntime.__init__` auth path no longer
|
|
38
|
+
reclassifies `httpx.RequestError` as `NullRunAuthenticationError`.
|
|
39
|
+
The defensive duplicate arm in `__init__` (backstop for a code path
|
|
40
|
+
that no longer exists) is removed; the real arm in `_authenticate`
|
|
41
|
+
now raises `NullRunTransportError(source=NETWORK_ERROR, endpoint="auth")`
|
|
42
|
+
— matching the convention used by `Transport.heartbeat` for the
|
|
43
|
+
same condition on `/heartbeat`. The previous wrap misled operators:
|
|
44
|
+
a network failure looked like an auth failure, even though the
|
|
45
|
+
message itself acknowledged "this is a transport failure (not an
|
|
46
|
+
auth failure)".
|
|
47
|
+
|
|
48
|
+
**Back-compat**: `NullRunTransportError` and `NullRunAuthenticationError`
|
|
49
|
+
are siblings under `NullRunInfrastructureError`, so the parent class
|
|
50
|
+
still catches both. Cookbook code that branches on
|
|
51
|
+
`except NullRunAuthenticationError:` for retry will need to also
|
|
52
|
+
catch `NullRunTransportError`. Two existing tests
|
|
53
|
+
(`test_authenticate_network_error_raises` in `test_runtime.py` and
|
|
54
|
+
`test_runtime_branches.py`) were locking in the old misclassification
|
|
55
|
+
and have been updated to assert the correct class.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- **DEF-HEART-01** — `NullRunRuntime.heartbeat(chain_id)` public method
|
|
60
|
+
added (thin forwarder to `Transport.heartbeat`). Mirrors the
|
|
61
|
+
`chain_end` / `cancel_execution` pattern. Use for single-shot chain
|
|
62
|
+
TTL extensions; `Runtime.ping_chain()` remains the wall-clock
|
|
63
|
+
scheduler variant. Pure addition — no existing API surface changes.
|
|
64
|
+
|
|
65
|
+
- **`tests/test_2026_09_11_execute_capture_wires_execution_id.py`** (220 lines). Two regression tests pinning the fix:
|
|
66
|
+
- `test_execute_captures_reservation_id_from_response` — verifies the contextvar updates from the `/execute` response and the re-fire uses the captured id (not the stale pre-call one).
|
|
67
|
+
- `test_execute_wait_for_approval_receives_captured_eid` — verifies the WS resolution handler receives the captured execution_id.
|
|
68
|
+
- **`tests/test_2026_09_11_auth_heartbeat_sweep.py`** (~190 lines, 7 tests). Regression tests for AUTH-01 + HEART-01:
|
|
69
|
+
- AUTH-01: `test_auth_connect_error_raises_transport_error_not_auth`, `test_auth_timeout_raises_transport_error_not_auth`, `test_auth_error_class_no_longer_catches_network_error` (back-compat parent-class check).
|
|
70
|
+
- HEART-01: `test_heartbeat_method_exists_on_public_api`, `test_heartbeat_forwards_chain_id_to_transport`, `test_heartbeat_passes_through_transport_error`, `test_ping_chain_still_works_after_heartbeat_added`.
|
|
71
|
+
|
|
72
|
+
### Compatibility
|
|
73
|
+
|
|
74
|
+
Pure reliability fix — no wire-format change. `/gate`, `/execute`,
|
|
75
|
+
`/track`, `/cancel` payloads are byte-identical to 0.16.7. Backend
|
|
76
|
+
v3.79+ is required for the wire-shape contract (the `reservation_id`
|
|
77
|
+
echo is the v3.79+ field that closes the gap); pre-v3.79 backends
|
|
78
|
+
silently fall through the capture (helper is fail-OPEN on malformed
|
|
79
|
+
values), preserving the pre-fix behaviour for un-deployed backends.
|
|
80
|
+
|
|
81
|
+
### Why this is needed
|
|
82
|
+
|
|
83
|
+
**NR-A015 (execute capture)** — the user-facing symptom was a
|
|
84
|
+
post-approval `/execute` re-fire landing on `APPROVAL_REPLAY_REJECTED`
|
|
85
|
+
because the SDK stamped the pre-arm `execution_id` into the
|
|
86
|
+
re-fire's kwargs dict, but `consume_approved`'s `WHERE execution_id
|
|
87
|
+
= $3` predicate had to match the freshly-minted id from the
|
|
88
|
+
approval-row bind (backend v3.79+). The terminal error was a
|
|
89
|
+
typed `NullRunApprovalReplayRejectedError(NR-A015)` — operators had
|
|
90
|
+
no signal that the re-fire was sending a stale id rather than a
|
|
91
|
+
truly-replayed call. 0.16.8 captures the `reservation_id` echo
|
|
92
|
+
from `/execute`'s response into the same contextvar that `/gate`
|
|
93
|
+
already uses, and re-emits the captured id on the re-fire kwargs
|
|
94
|
+
dict.
|
|
95
|
+
|
|
96
|
+
**AUTH-01 (transport reclassification)** — operators reading
|
|
97
|
+
`NullRunAuthenticationError` from a failed `__init__` were led to
|
|
98
|
+
rotate the API key because the class name suggested auth failure.
|
|
99
|
+
Pre-fix, the duplicate arm in `NullRunRuntime.__init__` rewrapped
|
|
100
|
+
`httpx.RequestError` as `NullRunAuthenticationError` (with the
|
|
101
|
+
"this is a transport failure (not an auth failure)" wording in the
|
|
102
|
+
message itself — a smoke signal the wrap was wrong). 0.16.8 raises
|
|
103
|
+
`NullRunTransportError(source=NETWORK_ERROR, endpoint="auth")`
|
|
104
|
+
matching the `Transport.heartbeat` convention. Catch-block semantics
|
|
105
|
+
in cookbooks now need `except (NullRunAuthenticationError,
|
|
106
|
+
NullRunTransportError):` for full coverage under
|
|
107
|
+
`NullRunInfrastructureError`.
|
|
108
|
+
|
|
109
|
+
**HEART-01 (public API)** — single-shot chain TTL extensions had to
|
|
110
|
+
reach through `runtime._transport.heartbeat(...)` because
|
|
111
|
+
`NullRunRuntime` exposed only the wall-clock `ping_chain()` scheduler.
|
|
112
|
+
0.16.8 adds `NullRunRuntime.heartbeat(chain_id)` as a thin
|
|
113
|
+
forwarder to `Transport.heartbeat`, matching the chain_end /
|
|
114
|
+
cancel_execution pattern.
|
|
115
|
+
|
|
1
116
|
## [0.16.7] - 2026-09-10
|
|
2
117
|
|
|
3
118
|
Patch release — closes the typed-exception / catalog-coverage gaps surfaced by the 0.16.6 backend hardening. After that release, every catalog exception the SDK can raise now has a hand-written `DEFAULT_MESSAGES` entry (no more "Something went wrong. Please try again." fallback), and `@protect`-decorated sites surface the real exception type instead of rewriting it into a generic `NullRunBlockedException`. The `@protect` block path in `runtime.execute` now dispatches the actual catalog code through `format_user_message`, so wire-error codes (NR-A012, NR-A016, NR-EX01, …) reach users with actionable wording. No wire-format change.
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
name = "nullrun"
|
|
7
7
|
# Full release history lives in CHANGELOG.md; only the current version
|
|
8
8
|
# is pinned here.
|
|
9
|
-
version = "0.16.
|
|
9
|
+
version = "0.16.8"
|
|
10
10
|
# Kept under the 200-char preview threshold so the full line is visible
|
|
11
11
|
# without an "expand" click. The headline is the canonical §1 statement
|
|
12
12
|
# from positioning.md — "runtime decision layer for tool-using AI agents"
|
|
@@ -767,15 +767,14 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
|
|
|
767
767
|
# Test mode: skip all network calls
|
|
768
768
|
self._transport.start()
|
|
769
769
|
else:
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
) from e
|
|
770
|
+
# AUTH-01 (2026-09-11): previously this arm caught ``httpx.RequestError``
|
|
771
|
+
# and re-raised ``NullRunAuthenticationError``, misclassifying network
|
|
772
|
+
# failures as auth failures. Arm B in ``_authenticate`` already catches
|
|
773
|
+
# the same condition with the correct class (``NullRunTransportError``);
|
|
774
|
+
# this defensive duplicate was a backstop for a code path that no longer
|
|
775
|
+
# exists between ``_authenticate()`` and ``self._transport.start()``.
|
|
776
|
+
# Remove: arm B is sufficient.
|
|
777
|
+
self._authenticate()
|
|
779
778
|
self._transport.start()
|
|
780
779
|
# Start remote polling unless disabled (internal `polling=False`
|
|
781
780
|
# for tests/CI). Production always polls.
|
|
@@ -1252,19 +1251,22 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
|
|
|
1252
1251
|
)
|
|
1253
1252
|
raise err
|
|
1254
1253
|
except httpx.RequestError as e:
|
|
1255
|
-
#
|
|
1256
|
-
|
|
1254
|
+
# AUTH-01 (2026-09-11): reclassify httpx.RequestError as
|
|
1255
|
+
# ``NullRunTransportError`` instead of ``NullRunAuthenticationError``.
|
|
1256
|
+
# The previous wrap misled operators — the same condition (DNS failure,
|
|
1257
|
+
# connection refused, TLS handshake error, request timeout) is correctly
|
|
1258
|
+
# classified as ``NullRunTransportError(NETWORK_ERROR, "auth")`` by
|
|
1259
|
+
# ``Transport.heartbeat`` (transport.py:2077) and the rest of the SDK.
|
|
1260
|
+
# The ``user_action`` previously embedded here noted "This is a
|
|
1261
|
+
# transport failure (not an auth failure)" — the class should match
|
|
1262
|
+
# the message. ``NullRunTransportError.__init__`` already sets
|
|
1263
|
+
# ``error_code="NR-B001"`` (transport.py:233) and the standard
|
|
1264
|
+
# retryable ``user_action`` (transport.py:234-237).
|
|
1265
|
+
err = NullRunTransportError(
|
|
1257
1266
|
f"Auth request failed: {e}. Cannot establish secure connection to NullRun. "
|
|
1258
1267
|
f"Refusing to operate in unprotected mode.",
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
"Could not reach the NullRun backend at "
|
|
1262
|
-
f"{self.api_url}. Check network connectivity and the "
|
|
1263
|
-
"configured api_url. This is a transport failure (not "
|
|
1264
|
-
"an auth failure) — the API key may be valid, the "
|
|
1265
|
-
"backend is just unreachable."
|
|
1266
|
-
),
|
|
1267
|
-
cause=e,
|
|
1268
|
+
source=TransportErrorSource.NETWORK_ERROR,
|
|
1269
|
+
endpoint="auth",
|
|
1268
1270
|
)
|
|
1269
1271
|
self._emit_sdk_error(err, stage="auth")
|
|
1270
1272
|
raise err from e
|
|
@@ -2207,10 +2209,26 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
|
|
|
2207
2209
|
f"check_workflow_budget: require_approval id={approval_id} -- "
|
|
2208
2210
|
f"waiting for WS push (timeout={server_timeout if server_timeout is not None else 'env-default'})"
|
|
2209
2211
|
)
|
|
2212
|
+
# 2026-09-11: pass the actual server-minted execution_id
|
|
2213
|
+
# (captured above from the /gate response) into the WS
|
|
2214
|
+
# wait so the entry's metadata + diagnostic log lines
|
|
2215
|
+
# reflect the same id the server stamped on the
|
|
2216
|
+
# approval row. Pre-fix this string fell back to
|
|
2217
|
+
# ``str(self.organization_id)`` which made the
|
|
2218
|
+
# ``__nullrun_unknown__`` sentinel leak into exception
|
|
2219
|
+
# payloads (demo
|
|
2220
|
+
# langgraph_openai_approval_demo.py prints
|
|
2221
|
+
# ``execution_id=exc.workflow_id``). The handler matches
|
|
2222
|
+
# purely on ``approval_id``, so this is diagnostic-only
|
|
2223
|
+
# — captured on the consumer side.
|
|
2224
|
+
from nullrun.context import get_server_minted_execution_id
|
|
2225
|
+
|
|
2226
|
+
_captured_eid = get_server_minted_execution_id()
|
|
2210
2227
|
result = self._wait_for_approval_resolution(
|
|
2211
2228
|
approval_id=approval_id,
|
|
2212
2229
|
workflow_id=workflow_id,
|
|
2213
|
-
execution_id=
|
|
2230
|
+
execution_id=_captured_eid
|
|
2231
|
+
or str(self.organization_id or "local"),
|
|
2214
2232
|
timeout_seconds=server_timeout,
|
|
2215
2233
|
)
|
|
2216
2234
|
outcome = (result.get("outcome") or "").lower()
|
|
@@ -2342,6 +2360,35 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
|
|
|
2342
2360
|
|
|
2343
2361
|
return stop
|
|
2344
2362
|
|
|
2363
|
+
def heartbeat(self, chain_id: str) -> dict[str, Any]:
|
|
2364
|
+
"""POST /api/v1/heartbeat — extend a chain's idle TTL.
|
|
2365
|
+
|
|
2366
|
+
Single-shot wrapper around ``Transport.heartbeat`` matching the
|
|
2367
|
+
``chain_end`` / ``cancel_execution`` public-API pattern
|
|
2368
|
+
(DEF-HEART-01, 2026-09-11). Use this for one-off TTL extensions;
|
|
2369
|
+
use ``ping_chain`` when you want a wall-clock scheduler that calls
|
|
2370
|
+
this method every N seconds.
|
|
2371
|
+
|
|
2372
|
+
The wire body is ``{"chain_id": chain_id}`` — HMAC headers carry
|
|
2373
|
+
``organization_id`` + ``trace_id`` automatically via
|
|
2374
|
+
``_build_signed_headers``, so no extra kwargs are needed at the
|
|
2375
|
+
transport layer (mirrors the simpler heartbeat shape vs. chain_end's
|
|
2376
|
+
``organization_id``/``trace_id`` injection).
|
|
2377
|
+
|
|
2378
|
+
The transport layer already raises ``NullRunTransportError(
|
|
2379
|
+
NETWORK_ERROR, "heartbeat")`` for network errors (transport.py:2077),
|
|
2380
|
+
so no reclassification is needed at this layer.
|
|
2381
|
+
|
|
2382
|
+
Args:
|
|
2383
|
+
chain_id: Active chain_id (UUID v4) registered via
|
|
2384
|
+
``with chain(chain_id, op="start")``.
|
|
2385
|
+
|
|
2386
|
+
Returns:
|
|
2387
|
+
Parsed JSON dict (typically ``{"status": "ok", "chain_id": ...,
|
|
2388
|
+
"last_active": ts}``).
|
|
2389
|
+
"""
|
|
2390
|
+
return self._transport.heartbeat(chain_id)
|
|
2391
|
+
|
|
2345
2392
|
def cancel_execution(self, execution_id: str, reason: str | None = None) -> dict[str, Any]:
|
|
2346
2393
|
"""Cancel an in-flight execution via /api/v1/cancel
|
|
2347
2394
|
.
|
|
@@ -2379,7 +2426,21 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
|
|
|
2379
2426
|
Returns:
|
|
2380
2427
|
Parsed JSON dict.
|
|
2381
2428
|
"""
|
|
2382
|
-
|
|
2429
|
+
# DEF-CHAIN-END-ORG-ID (2026-09-11): ``Transport.chain_end`` pre-fix
|
|
2430
|
+
# sent only ``{chain_id, chain_op, execution_id}`` to /gate and the
|
|
2431
|
+
# backend rejected with 422 ``missing field 'organization_id'``.
|
|
2432
|
+
# Fix: forward ``self.organization_id`` (set in ``_authenticate``)
|
|
2433
|
+
# and the contextvar trace_id so the SDK builds a complete
|
|
2434
|
+
# ``GateRequest`` body. ``trace_id`` is sourced from the contextvar
|
|
2435
|
+
# to match the rest of the SDK's wire-shape policy (one trace id
|
|
2436
|
+
# per logical chain).
|
|
2437
|
+
from nullrun.context import get_trace_id
|
|
2438
|
+
|
|
2439
|
+
return self._transport.chain_end(
|
|
2440
|
+
chain_id,
|
|
2441
|
+
organization_id=self.organization_id,
|
|
2442
|
+
trace_id=get_trace_id(),
|
|
2443
|
+
)
|
|
2383
2444
|
|
|
2384
2445
|
def approximate_budget(self) -> dict[str, Any]:
|
|
2385
2446
|
"""UI-only budget estimate via GET /api/v1/budget/approximate
|
|
@@ -2963,6 +3024,34 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
|
|
|
2963
3024
|
execute_kwargs["action_digest"] = action_digest
|
|
2964
3025
|
result = self._transport.execute(**execute_kwargs)
|
|
2965
3026
|
|
|
3027
|
+
# 2026-09-11: DEF-EXECUTE-CAPTURE-WIRING. The /execute
|
|
3028
|
+
# require_approval arm mints a FRESH execution_id (server-
|
|
3029
|
+
# side) for the approval row + writes the binding, then
|
|
3030
|
+
# echoes the new id back via ``reservation_id`` (mirrored by
|
|
3031
|
+
# the backend's GateResponse::require_approval constructor —
|
|
3032
|
+
# see backend/src/enforcement/gate_wire_adapter.rs v3.79+).
|
|
3033
|
+
# Without this capture below, the contextvar stays at the
|
|
3034
|
+
# /gate-minted value, and the post-approval /execute re-fire
|
|
3035
|
+
# (line ~3037) sends the OLD execution_id back to the
|
|
3036
|
+
# server. ``consume_approved``'s ``WHERE execution_id = $3``
|
|
3037
|
+
# predicate then misses the row stamped with the freshly-
|
|
3038
|
+
# minted one; the diagnostic SELECT walks all alternatives
|
|
3039
|
+
# without match and falls through to the terminal
|
|
3040
|
+
# replay-race branch (``APPROVAL_REPLAY_REJECTED``) — the
|
|
3041
|
+
# SDK raises NR-A015. Capture here is fail-OPEN (drops
|
|
3042
|
+
# malformed values silently via the helper's UUID parse),
|
|
3043
|
+
# matching ``check_workflow_budget``'s behaviour.
|
|
3044
|
+
_capture_server_minted_execution_id(result)
|
|
3045
|
+
|
|
3046
|
+
# Sync the kwargs dict to the captured id so the post-approval
|
|
3047
|
+
# re-fire (line ~3083, ``self._transport.execute(**execute_kwargs)``)
|
|
3048
|
+
# uses the freshly-minted execution_id. The contextvar update
|
|
3049
|
+
# alone is not enough — the re-fire path does NOT re-read from
|
|
3050
|
+
# the contextvar; it reuses the kwargs built before /execute.
|
|
3051
|
+
_captured_after_execute = get_server_minted_execution_id()
|
|
3052
|
+
if _captured_after_execute is not None:
|
|
3053
|
+
execute_kwargs["execution_id"] = _captured_after_execute
|
|
3054
|
+
|
|
2966
3055
|
# Update metrics (thread-safe)
|
|
2967
3056
|
metrics.inc_runtime("execute_calls")
|
|
2968
3057
|
|
|
@@ -2986,10 +3075,21 @@ class NullRunRuntime(metaclass=_NullRunRuntimeMeta):
|
|
|
2986
3075
|
log_prefix="runtime.execute",
|
|
2987
3076
|
)
|
|
2988
3077
|
|
|
3078
|
+
# 2026-09-11: same rationale as in
|
|
3079
|
+
# ``check_workflow_budget`` above — the entry's
|
|
3080
|
+
# ``execution_id`` slot should reflect the server-minted
|
|
3081
|
+
# id stamped on the approval row (captured via
|
|
3082
|
+
# ``_capture_server_minted_execution_id(result)`` at line
|
|
3083
|
+
# ~2968), not the workflow_id sentinel. The WS handler
|
|
3084
|
+
# matches on approval_id only, so this is diagnostic.
|
|
3085
|
+
from nullrun.context import get_server_minted_execution_id
|
|
3086
|
+
|
|
3087
|
+
_captured_eid = get_server_minted_execution_id()
|
|
2989
3088
|
approval_result = self._wait_for_approval_resolution(
|
|
2990
3089
|
approval_id=str(approval_id),
|
|
2991
3090
|
workflow_id=workflow_id or UNKNOWN_WORKFLOW_ID,
|
|
2992
|
-
execution_id=
|
|
3091
|
+
execution_id=_captured_eid
|
|
3092
|
+
or str(workflow_id or UNKNOWN_WORKFLOW_ID),
|
|
2993
3093
|
timeout_seconds=server_timeout,
|
|
2994
3094
|
)
|
|
2995
3095
|
outcome = str(approval_result.get("outcome") or "").lower()
|
|
@@ -33,6 +33,7 @@ from nullrun.breaker.exceptions import (
|
|
|
33
33
|
NullRunAuthenticationError,
|
|
34
34
|
NullRunBackendError,
|
|
35
35
|
NullRunBlockedException,
|
|
36
|
+
NullRunConfigError,
|
|
36
37
|
NullRunDecision,
|
|
37
38
|
NullRunExecutionNotFoundError,
|
|
38
39
|
NullRunInfrastructureError,
|
|
@@ -2088,6 +2089,10 @@ class Transport:
|
|
|
2088
2089
|
def chain_end(
|
|
2089
2090
|
self,
|
|
2090
2091
|
chain_id: str,
|
|
2092
|
+
*,
|
|
2093
|
+
organization_id: str | None = None,
|
|
2094
|
+
trace_id: str | None = None,
|
|
2095
|
+
operation_id: str | None = None,
|
|
2091
2096
|
) -> dict[str, Any]:
|
|
2092
2097
|
"""Close a chain explicitly via /api/v1/gate with chain_op=end
|
|
2093
2098
|
.
|
|
@@ -2105,20 +2110,78 @@ class Transport:
|
|
|
2105
2110
|
|
|
2106
2111
|
Args:
|
|
2107
2112
|
chain_id: Chain to close.
|
|
2113
|
+
organization_id: Organization identifier (REQUIRED on the
|
|
2114
|
+
wire — ``GateRequest`` deserialization fails with
|
|
2115
|
+
422 ``missing field 'organization_id'`` without it,
|
|
2116
|
+
see ``backend/src/proxy/http/gate/internal.rs:156``).
|
|
2117
|
+
``Runtime.chain_end`` always passes
|
|
2118
|
+
``self.organization_id`` from ``_authenticate``.
|
|
2119
|
+
trace_id: Distributed trace ID. Auto-generated UUIDv4 if
|
|
2120
|
+
not provided.
|
|
2121
|
+
operation_id: Idempotency key — set explicitly when the
|
|
2122
|
+
caller wants the backend's per-``operation_id``
|
|
2123
|
+
dedup (matches ``runtime.check_workflow_budget``
|
|
2124
|
+
behaviour). Auto-generated UUIDv4 if not provided.
|
|
2108
2125
|
|
|
2109
2126
|
Returns:
|
|
2110
2127
|
Parsed JSON dict (typically ``{"decision": "allow"
|
|
2111
2128
|
"chain_id":...}``).
|
|
2112
2129
|
"""
|
|
2113
|
-
#
|
|
2130
|
+
# DEF-CHAIN-END-ORG-ID (2026-09-11): ``Transport.chain_end`` pre-fix
|
|
2131
|
+
# POSTed only ``{chain_id, chain_op, execution_id}`` to /gate. The
|
|
2132
|
+
# backend's ``GateRequest`` struct
|
|
2133
|
+
# (backend/src/proxy/http/gate/internal.rs:156) marks
|
|
2134
|
+
# ``organization_id``, ``execution_id``, ``trace_id``, ``mode`` as
|
|
2135
|
+
# REQUIRED — the deserializer validates them BEFORE chain_op-specific
|
|
2136
|
+
# dispatch, so even the ``chain_op=end`` control-plane path
|
|
2137
|
+
# returns 422 ``missing field 'organization_id'`` on the old
|
|
2138
|
+
# body. Live wire trace against api.nullrun.io confirms the
|
|
2139
|
+
# 422. Fix: build the same full GateRequest body every other
|
|
2140
|
+
# /gate caller builds (``check`` at transport.py:1481, the
|
|
2141
|
+
# capture site at runtime.py:1953).
|
|
2142
|
+
if organization_id is None:
|
|
2143
|
+
raise NullRunConfigError(
|
|
2144
|
+
"Transport.chain_end requires organization_id — "
|
|
2145
|
+
"NullRunRuntime.chain_end always passes it from "
|
|
2146
|
+
"_authenticate; a direct Transport.chain_end call must "
|
|
2147
|
+
"pass organization_id explicitly. The backend's GateRequest "
|
|
2148
|
+
"struct (backend/src/proxy/http/gate/internal.rs:156) "
|
|
2149
|
+
"rejects requests without organization_id with 422 "
|
|
2150
|
+
"VALIDATION_ERROR."
|
|
2151
|
+
)
|
|
2152
|
+
# v0.16.1 (Phase-1+ wire-shape): the backend's
|
|
2153
|
+
# ``backend/src/proxy/http/gate/gate.rs:148`` version-gate
|
|
2154
|
+
# fail-CLOSED-rejects any proto>=3 /gate call that omits
|
|
2155
|
+
# ``action_digest``. ``chain_end`` is a control-plane op with
|
|
2156
|
+
# no business_impact, so we emit the same NoImpact sentinel
|
|
2157
|
+
# digest that ``runtime.check_workflow_budget`` produces at
|
|
2158
|
+
# runtime.py:1978.
|
|
2159
|
+
from nullrun.business_impact import (
|
|
2160
|
+
BusinessImpact as _BusinessImpact,
|
|
2161
|
+
)
|
|
2162
|
+
from nullrun.business_impact import (
|
|
2163
|
+
compute_action_digest as _compute_action_digest,
|
|
2164
|
+
)
|
|
2165
|
+
|
|
2114
2166
|
request = {
|
|
2167
|
+
"organization_id": organization_id,
|
|
2168
|
+
# Fresh UUIDv4 per call (canonical hyphenated form so it
|
|
2169
|
+
# parses through the backend's ``Uuid::parse_str`` if it
|
|
2170
|
+
# ever reads it on this path).
|
|
2171
|
+
"execution_id": str(uuid.uuid4()),
|
|
2172
|
+
"trace_id": trace_id or str(uuid.uuid4()),
|
|
2173
|
+
"tool": None,
|
|
2174
|
+
"input": None,
|
|
2175
|
+
# ``mode="auto"`` — chain_end is a control-plane operation,
|
|
2176
|
+
# not a budget-consuming call. The orchestrator's
|
|
2177
|
+
# ``gate_reserve_v3`` Lua call receives ``chain_op=End``
|
|
2178
|
+
# and skips the budget reserve path, so the mode string
|
|
2179
|
+
# only needs to satisfy GateRequest's required-field check.
|
|
2180
|
+
"mode": "auto",
|
|
2181
|
+
"operation_id": operation_id or str(uuid.uuid4()),
|
|
2115
2182
|
"chain_id": chain_id,
|
|
2116
2183
|
"chain_op": "end",
|
|
2117
|
-
|
|
2118
|
-
# even on chain_end — the handler reads it but does not
|
|
2119
|
-
# mint a reservation for op=end. Use a fresh uuidv7
|
|
2120
|
-
# call (the server ignores it on this path).
|
|
2121
|
-
"execution_id": uuid.uuid4().hex,
|
|
2184
|
+
"action_digest": _compute_action_digest(_BusinessImpact.no_impact()),
|
|
2122
2185
|
}
|
|
2123
2186
|
# 2026-07-06 (bug-fix): same body-before-headers reorder as
|
|
2124
2187
|
body = _signed_request_body(request)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|