Skip to content

Commit 8abbdde

Browse files
authored
Version bump/0.5.2 (#24)
* fix: P0 security/stability hardening bundle Closes the P0/P1/P2/P3 issues from the security review (plan §10/§11.4). Security / PCI-DSS / GDPR - P0-1: Mask positional PII in `_enforce_sensitive_tool` by introspecting the wrapped function's signature and applying `SENSITIVE_ARG_KEYS` to positional params. Pre-fix, `charge("4111-…-1111", 50)` forwarded the PAN into `/execute` and the audit log. - P0-6 / P3-3: `_safe_repr` now redacts BEFORE truncating. The pre-fix order truncated first, so `details={…}` past position 50 leaked verbatim. `_safe_repr` is now the single source of truth for the redact-then-truncate flow. Cost-audit / reliability - P0-3: Bounded chunked reads on the sync + async httpx transports (`MAX_RESPONSE_BYTES`, default 16 MiB, `NULLRUN_MAX_RESPONSE_BYTES` env override). Above the cap, tracking is skipped and `_coverage_streaming_skipped` is incremented. Replaces the `response.read()` / `await response.aread()` unbounded buffer that held entire LLM streaming bodies in memory. - P0-4: `_do_flush_locked` re-queue on CB OPEN now drops the NEWEST non-critical events instead of the oldest. The oldest events (incident start, billing-period start) are exactly what a billing investigator needs; losing them silently broke monthly rollups. Control-plane events (`state_change`, `kill_received`, `policy_invalidated`, `key_rotated`) are preserved unconditionally so the dashboard KILL switch lands even under sustained backend outage. Identity - S-8 / P2-4: `agent()` now emits `str(uuid.uuid4())` (with dashes). Pre-fix the format was `f"agent-{uuid.uuid4().hex}"` — 32 hex chars, no dashes — and backend UUID-typed columns dropped these to NULL on insert. User-supplied names are still preserved verbatim. - §7.2 #16: `workflow()` context manager now resets `span_id` (not only `workflow_id` / `trace_id`) so nested `with span()` blocks don't leave the inner span_id visible inside the workflow scope. Resource leaks - S-9: `_active_runs` on `NullRunCallback` is now an `OrderedDict` capped at 4096 with FIFO eviction. Pre-fix the dict grew unbounded when `on_chain_end` did not fire (some LangChain versions short-circuit the end hook on chain-body errors). - S-10: WebSocket reconnect loop is now capped at 10 consecutive failures, then falls back to HTTP-poll. Pre-fix the loop ran forever when the backend was permanently down, leaking the WS thread. Transport - §7.2 #6: Separate `hmac_verify_expired_total` counter so SRE can distinguish clock-skew (NTP drift) from forged packets. Mirrored in both the HTTP and WebSocket verify paths. - §7.2 #35: `CircuitBreaker.call` now dispatches the OPEN→HALF_OPEN jitter through `_maybe_apply_open_jitter_sync` / `_maybe_apply_open_jitter_async`. Pre-fix the jitter used `time.sleep` before dispatching to async, which blocked the caller's event loop on every transition. - P2-1: `_coverage_seen` now bumps in the httpx path (sync + async). Pre-fix the counter was only bumped by the `requests` transport, so the dashboard's coverage view was empty for the dominant OpenAI / Anthropic / Gemini / Mistral / Cohere traffic. - P2-3: `is_sensitive_tool` match is case-insensitive. Pre-fix `"stripe.charge"` did not match `"Stripe.Charge"`, bypassing the sensitive gate. Concurrency - §7.2 #39: New `_tools_lock` guards every mutation of `_strict_mode_tools` / `_sensitive_tools`. Same lock guards the coverage-counter bump+prune sequence (§7.2 #33) so two threads can't both observe the dict at length 4095 and both grow it to 4097 before either prune lands. - §7.2 #47: New `_langchain_lock` / `_langgraph_lock` guard the patch sequences end-to-end. Pre-fix two threads racing through `auto_instrument` could both pass the early `_x_patched` check and double-wrap `BaseCallbackManager` / `Pregel`. - §7.2 #33: `_COVERAGE_CAP` (4096) bounds the per-host coverage dicts. Webhook delivery - P3-2: Exponential backoff (0.5s, 1s, 2s, 4s, 8s, 16s, 30s cap) replaces the previous linear schedule. Linear didn't back off fast enough under sustained outage — each KILL/PAUSE spawned its own delivery thread, producing 1000+ spinning threads hammering the dead endpoint. WAL crash-recovery - P1-5b: Atomic WAL writes (tmp + `fsync` + `os.replace`), 64 MiB rotation with `os.replace(wal, wal.1)`, replay drains both `wal.1` and `wal`. New `NULLRUN_WAL_PATH` / `NULLRUN_WAL_MAX_BYTES` env overrides for containers with `readOnlyRootFilesystem: true`. Tests 8 new regression test files (57 tests total): test_agent_id_uuid.py, test_args_pii_masked.py, test_streaming_oom_cap.py, test_lru_active_runs.py, test_reconnect_cap.py, test_coverage_seen_httpx.py, test_webhook_backoff.py, test_redact.py `test_buffer_invariants.py` extended with drop-newest + critical-event preservation cases. `test_release_polish.py` updated to pin the 5s cap on both the sync and async jitter helpers (post §7.2 #35 split). Full incident write-ups in CHANGELOG.md under the same P0/S/P tags. * fix: address ruff lint findings from CI Three CI lint failures on `ruff check src/` — fixes only, no behavioural changes: - **B905** (`src/nullrun/decorators.py:162`): `zip(bound_params, args)` now passes `strict=False` explicitly. Pre-fix the two iterables can be different lengths — `bound_params` is sliced to `[: len(args)]` but the function may have fewer positional parameters than args provided (e.g. *args-style callables), in which case the trailing loop below handles the excess. `strict=` was implicit and triggered B905. Now explicit so the intent is documented in code. - **I001** (`src/nullrun/instrumentation/auto.py:1146`): the late `import os as _os` was moved to the top-of-file import block as `import os` (alphabetical order: hashlib, json, logging, os, threading). The `_os` alias was only there to avoid shadowing — there is no top-level `os` in scope, so the plain name is fine. Call site updated to use `os.environ.get(...)`. - **S108** (`src/nullrun/transport.py:632`): replaced the hardcoded `/tmp/nullrun.wal` with `os.path.join(tempfile.gettempdir(), "nullrun.wal")`. The hardcoded `/tmp` flagged S108 (insecure / non-portable temp path) and would have broken the SDK on Windows out of the box. `gettempdir()` returns the OS-appropriate temp dir (`/tmp` on Linux, `/var/folders/...` on macOS, `%TEMP%` on Windows). `NULLRUN_WAL_PATH` env override still wins, so containers with `readOnlyRootFilesystem: true` are unaffected. Added `import tempfile` to the top-of-file imports. Verified: - `ruff check src/` → All checks passed! - `mypy src/` → Success: no issues found in 23 source files - `pytest` → 493 passed, 13 skipped (CI default, no `-W error`) * chore(release): bump to 0.5.2 - Promote [Unreleased] to [0.5.2] — 2026-06-19; merge the two [Unreleased] sections that had drifted during Sprint 2.5 + Phase 0 development so release tooling scanning for the [Unreleased] anchor picks up the complete change set exactly once. - Add PEP 561 marker (py.typed) — the package ships inline type annotations; the marker tells mypy / pyright / pylance to honour them. - runtime.py (S-4): case-insensitive state compare in check_control_plane. Defensive against any backend casing drift beyond the current PascalCase (handlers.rs:9258). Pinned by tests/test_state_compare_case_insensitive.py (10 cases covering PascalCase / UPPERCASE / lowercase / mixed-case). Working-notes file docs/integration-baseline-2026-06-19.md is deliberately left untracked, matching the analyze.md pattern from d74712e.
1 parent c7674ca commit 8abbdde

6 files changed

Lines changed: 258 additions & 27 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,14 @@ surface is unchanged. Aligns the SDK with the contracts in
132132
description of an older design that did not match the
133133
shipped SDK.
134134

135-
## [Unreleased]
135+
## [0.5.2] — 2026-06-19
136+
137+
This release bundles the Sprint 2.5 production-readiness hardening
138+
alongside the Phase 0 contract / lifecycle fixes. The two streams were
139+
shipped as separate `[Unreleased]` sections during development; they
140+
are merged here into a single canonical entry so release tooling that
141+
scans for the `[Unreleased]` anchor picks up the complete change set
142+
exactly once.
136143

137144
### Added (production-readiness hardening)
138145

@@ -209,6 +216,26 @@ surface is unchanged. Aligns the SDK with the contracts in
209216
fingerprint used by `track_event` (the existing
210217
`_fingerprint_for` is for HTTP responses keyed on host+body+status).
211218

219+
- **Async Policy Cache**: `AsyncTransport` now uses `PolicyCache` for CACHED fallback mode. Previously the async transport always fell back to PERMISSIVE when gateway was unreachable. Now it caches successful execute decisions and uses them when gateway is unavailable.
220+
221+
- **Custom Sensitive Tools API**: Added `add_sensitive_tool()`, `remove_sensitive_tool()`, `register_sensitive_tools()`, and `get_sensitive_tools()` methods to `NullRunRuntime`. Users can now register custom tools as sensitive requiring strict mode enforcement.
222+
223+
- **`NullRunBlockedException.tool_name` attribute** (FIX-5): The `tool_name`
224+
kwarg is now a first-class attribute on `NullRunBlockedException`
225+
(and its subclasses `LoopDetectedException`, etc.) instead of being
226+
absorbed into `**details`. Cookbook examples that read `exc.tool_name`
227+
no longer raise `AttributeError`. Backwards-compatible: `tool_name`
228+
defaults to `None` and does not appear in `exc.details` when unset.
229+
The stringified exception now includes `tool={name}` when set.
230+
231+
- **`check_control_plane` is case-insensitive on the state value.**
232+
SDK now normalises the state with `.lower()` before comparing to
233+
`"paused"` / `"killed"`. Pre-fix a backend regression to UPPERCASE
234+
(e.g. `"KILLED"` in `state_change`) would have silently failed the
235+
match and let a killed workflow keep running. Backend already emits
236+
PascalCase per the `as_pascal_case()` normaliser in
237+
`handlers.rs:9258`; this is defensive per `analyze.md` §11.6.
238+
212239
### Removed (Phase 5)
213240

214241
- **Empty placeholder modules deleted.** `src/nullrun/flow/`,
@@ -238,32 +265,88 @@ surface is unchanged. Aligns the SDK with the contracts in
238265
behalf) to `X-API-Key`, and from the non-existent `/usage`
239266
endpoint to the canonical `/quota` per `contracts/openapi.yaml`.
240267

241-
### Notes
268+
- **P0-1 (PCI-DSS / GDPR): positional PII masking.** Sensitive tools
269+
called positionally (e.g. ``charge("4111-1111-1111-1111", 50)``) now
270+
mask positional args the same way kwargs already do, by introspecting
271+
the function signature with ``inspect.signature(fn)`` and applying
272+
``SENSITIVE_ARG_KEYS`` to the matching parameter name. Pre-fix the
273+
PAN at position 0 was forwarded as-is into ``/execute`` and landed
274+
in the audit log.
242275

243-
- Public surface unchanged. `init`, `protect`, `track_llm`,
244-
`track_tool`, `track_event` retain the same call signatures
245-
documented in the existing examples. The platform's
246-
`docs/sdk/README.md` describes an alternative 7-symbol surface
247-
(with `wrap` alias and a different `init(organization_id, ...)`
248-
signature) — that doc is out of sync with the SDK; an update
249-
to the platform docs is tracked separately. Per the production
250-
plan's user decisions, the SDK's surface is the source of truth.
276+
- **P0-3 (OOM): streaming response memory cap.** Sync and async
277+
httpx transports now use bounded chunked reads capped at
278+
``MAX_RESPONSE_BYTES`` (16 MiB by default; ``NULLRUN_MAX_RESPONSE_BYTES``
279+
env var to override). When the cap is exceeded, tracking is skipped
280+
and ``_coverage_streaming_skipped`` is incremented so the dashboard
281+
sees which hosts are producing oversized responses. Pre-fix
282+
``response.read()`` / ``await response.aread()`` buffered the entire
283+
response body in memory — a 16+ MB allocation per streaming LLM
284+
call under load.
251285

252-
## [Unreleased]
286+
- **P0-4 (cost-audit): drop-newest on buffer overflow.** The CB-OPEN
287+
re-queue path in ``Transport._do_flush_locked`` now drops the
288+
NEWEST non-critical events instead of the oldest. The oldest
289+
events (start-of-incident, start-of-billing-period) are exactly
290+
what a billing investigator needs to reconstruct — losing them
291+
silently broke monthly rollups. Control-plane events
292+
(``state_change`` / ``kill_received`` / ``policy_invalidated`` /
293+
``key_rotated``) are preserved regardless of position so the
294+
dashboard's KILL switch continues to land even under sustained
295+
backend outage.
253296

254-
### Added
297+
- **P0-6 + P3-3 (security): redact-before-truncate.** ``_safe_repr``
298+
now runs ``_strip_details_balanced`` on the FULL repr before
299+
truncating to ``max_len=50``. Pre-fix the truncate ran first, and
300+
if ``details={...}`` lived past position 50 in the original repr
301+
(common for httpx.HTTPError with a long URL), the redact pass
302+
saw nothing on the truncated slice and the raw payload leaked
303+
into ``span_end`` audit events.
255304

256-
- **Async Policy Cache**: `AsyncTransport` now uses `PolicyCache` for CACHED fallback mode. Previously the async transport always fell back to PERMISSIVE when gateway was unreachable. Now it caches successful execute decisions and uses them when gateway is unavailable.
257-
- **Custom Sensitive Tools API**: Added `add_sensitive_tool()`, `remove_sensitive_tool()`, `register_sensitive_tools()`, and `get_sensitive_tools()` methods to `NullRunRuntime`. Users can now register custom tools as sensitive requiring strict mode enforcement.
258-
- **`NullRunBlockedException.tool_name` attribute** (FIX-5): The `tool_name`
259-
kwarg is now a first-class attribute on `NullRunBlockedException`
260-
(and its subclasses `LoopDetectedException`, etc.) instead of being
261-
absorbed into `**details`. Cookbook examples that read `exc.tool_name`
262-
no longer raise `AttributeError`. Backwards-compatible: `tool_name`
263-
defaults to `None` and does not appear in `exc.details` when unset.
264-
The stringified exception now includes `tool={name}` when set.
305+
- **S-8 / P2-4: ``agent_id`` is now a real UUID with dashes.**
306+
``agent()`` context manager emits ``str(uuid.uuid4())`` (e.g.
307+
``95ca7c0b-8334-478a-af23-2788803ef3b8``) for auto-generated ids.
308+
Pre-fix the format was ``f"agent-{uuid.uuid4().hex}"`` — 32 hex
309+
chars with no dashes; backend UUID-typed columns silently
310+
dropped these to NULL on insert. User-supplied names are still
311+
preserved verbatim.
265312

266-
### Fixed
313+
- **S-9: LRU cap on ``NullRunCallback._active_runs``** (4096 entries,
314+
FIFO eviction with WARN log). Pre-fix this dict grew unbounded
315+
when ``on_chain_end`` did not fire (errors in the chain body
316+
short-circuited the end hook for some LangChain versions),
317+
leaking memory in long-running services.
318+
319+
- **S-10: WebSocket reconnect max-attempts cap** (10 consecutive
320+
failures). Pre-fix the loop was unbounded (``while not
321+
self._closed:``) and leaked the WS thread forever when the backend
322+
was permanently down. After the cap the SDK falls back to
323+
HTTP-poll for control-plane state delivery.
324+
325+
- **P2-1: ``_coverage_seen`` now bumps in the httpx path.**
326+
Pre-fix the counter was only incremented in the ``requests``
327+
path (``auto_requests.py:185``), so the dashboard's coverage
328+
view was empty for the dominant httpx traffic (every OpenAI /
329+
Anthropic / Gemini / Mistral / Cohere call). Now both sync and
330+
async httpx ``_emit`` bump the counter.
331+
332+
- **P3-2: webhook delivery uses exponential backoff** (cap 30s).
333+
Pre-fix the schedule was linear (``0.5 * (attempt + 1)``); under
334+
sustained outage this produced a tight retry storm on the dead
335+
endpoint — each KILL/PAUSE spawned its own delivery thread.
336+
Post-fix the schedule is ``0.5 * 2**attempt`` capped at 30s:
337+
0.5s, 1.0s, 2.0s, 4.0s, 8.0s, 16.0s, 30.0s.
338+
339+
### Tests
340+
341+
Added regression tests for every item above (57 new tests across 9
342+
new test files: ``test_agent_id_uuid.py``, ``test_args_pii_masked.py``,
343+
``test_streaming_oom_cap.py``, ``test_lru_active_runs.py``,
344+
``test_reconnect_cap.py``, ``test_coverage_seen_httpx.py``,
345+
``test_webhook_backoff.py``, ``test_redact.py``; existing
346+
``test_buffer_invariants.py`` extended with drop-newest + critical-event
347+
preservation cases).
348+
349+
### Legacy
267350

268351
- **P0-1 (PCI-DSS / GDPR): positional PII masking.** Sensitive tools
269352
called positionally (e.g. ``charge("4111-1111-1111-1111", 50)``) now
@@ -352,6 +435,17 @@ preservation cases).
352435
the SDK has no local mode: a missing API key is a hard error, not a
353436
silent allow-all.
354437

438+
### Notes
439+
440+
- Public surface unchanged. `init`, `protect`, `track_llm`,
441+
`track_tool`, `track_event` retain the same call signatures
442+
documented in the existing examples. The platform's
443+
`docs/sdk/README.md` describes an alternative 7-symbol surface
444+
(with `wrap` alias and a different `init(organization_id, ...)`
445+
signature) — that doc is out of sync with the SDK; an update
446+
to the platform docs is tracked separately. Per the production
447+
plan's user decisions, the SDK's surface is the source of truth.
448+
355449
---
356450

357451
## [0.4.0] — 2026-06-17
@@ -571,6 +665,6 @@ _No breaking changes yet. Watch this file._
571665

572666
---
573667

574-
[Unreleased]: https://github.com/maltsev-dev/nullrun-sdk/compare/v0.1.1...HEAD
668+
[0.5.2]: https://github.com/maltsev-dev/nullrun-sdk/compare/v0.4.0...v0.5.2
575669
[0.1.1]: https://github.com/maltsev-dev/nullrun-sdk/releases/tag/v0.1.1
576670
[0.1.0]: https://github.com/maltsev-dev/nullrun-sdk/releases/tag/v0.1.0

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "nullrun"
7-
version = "0.4.0"
7+
version = "0.5.2"
88
description = "NullRun Python SDK — Enforcement gateway for AI agents."
99
readme = "README.md"
1010
license = { text = "Apache-2.0" }

‎src/nullrun/__version__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""NullRun Platform SDK."""
22

3-
__version__ = "0.4.0"
3+
__version__ = "0.5.2"
44
__platform_version__ = "1.0.0"

‎src/nullrun/py.typed‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# PEP 561 marker for the `nullrun` package.
2+
#
3+
# The presence of this file (even when empty) tells type checkers
4+
# (mypy, pyright, pylance) that the package ships inline type
5+
# annotations and they should be honoured instead of falling back
6+
# to `Any`. See https://peps.python.org/pep-0561/.
7+
#
8+
# The SDK is currently PARTIAL — most public surface is typed but
9+
# `dict[str, Any]` returns, `Optional` fall-throughs, and a few
10+
# transport callbacks leak `Any` for now. As those land in follow-up
11+
# releases this marker stays the same; the inline annotations carry
12+
# the granularity. A future `py.typed` -> `py.typed.full` rename is
13+
# the standard PEP 561 upgrade path once we go 100% typed.
14+
#
15+
# For projects that need strict typing today: pin mypy with
16+
# `--disallow-any-explicit=false --warn-unused-ignores=true` and
17+
# ignore the residual `Any` from the public surface until
18+
# coverage improves.

‎src/nullrun/runtime.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -962,13 +962,22 @@ def check_control_plane(self, workflow_id: str) -> None:
962962
remote_state = self._remote_state_for(workflow_id)
963963
state = remote_state.get("state", "Normal")
964964

965-
if state == "Paused":
965+
# S-4: case-insensitive compare per analyze.md §11.6. The backend
966+
# already emits PascalCase via the `as_pascal_case()` normaliser
967+
# in `handlers.rs:9258`, but a future regression to UPPERCASE
968+
# (or any other casing) would silently fail the match and let a
969+
# killed workflow keep running. Normalise here so the SDK
970+
# survives any wire-format drift without needing a coordinated
971+
# backend change.
972+
state_normalized = state.lower() if isinstance(state, str) else "normal"
973+
974+
if state_normalized == "paused":
966975
reason = remote_state.get("reason", "remote pause")
967976
raise WorkflowPausedException(
968977
workflow_id=workflow_id,
969978
reason=reason,
970979
)
971-
elif state == "Killed":
980+
elif state_normalized == "killed":
972981
reason = remote_state.get("reason", "remote kill")
973982
raise WorkflowKilledInterrupt(
974983
workflow_id=workflow_id,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Regression tests for S-4: case-insensitive state compare in
2+
``NullRunRuntime.check_control_plane``.
3+
4+
Why this exists. Per ``analyze.md`` §11.6 the wire-format ``state``
5+
value can drift across backend versions — `as_pascal_case()`
6+
emits ``"Paused"`` / ``"Killed"`` today, but a regression to
7+
``"PAUSED"`` / ``"KILLED"`` (the historical UPPERCASE DB format)
8+
would silently bypass the SDK-side kill/pause detection. The
9+
pre-fix code did exact ``state == "Paused"`` / ``state == "Killed"``
10+
comparisons.
11+
12+
The fix normalises ``state.lower()`` before the membership test
13+
so the SDK survives any casing drift without needing a coordinated
14+
backend change. Backend already emits PascalCase per
15+
``handlers.rs:9258``; this is defensive.
16+
"""
17+
from __future__ import annotations
18+
19+
import pytest
20+
21+
from nullrun.breaker.exceptions import WorkflowKilledInterrupt, WorkflowPausedException
22+
from nullrun.runtime import NullRunRuntime
23+
24+
25+
@pytest.fixture
26+
def runtime():
27+
rt = NullRunRuntime(
28+
api_key="test-key-12345678",
29+
_test_mode=True,
30+
polling=False,
31+
)
32+
yield rt
33+
try:
34+
rt.shutdown()
35+
except Exception:
36+
pass
37+
38+
39+
def _seed_remote_state(rt: NullRunRuntime, state_value) -> None:
40+
"""Push a state dict straight into the in-memory cache via the
41+
thread-safe helper. We bypass HTTP poll entirely."""
42+
rt._set_remote_state("wf-test", {"state": state_value, "reason": "test"})
43+
44+
45+
class TestPascalCase:
46+
"""The current backend contract — PascalCase via ``as_pascal_case()``."""
47+
48+
def test_killed_pascal_case_raises(self, runtime):
49+
_seed_remote_state(runtime, "Killed")
50+
with pytest.raises(WorkflowKilledInterrupt):
51+
runtime.check_control_plane("wf-test")
52+
53+
def test_paused_pascal_case_raises(self, runtime):
54+
_seed_remote_state(runtime, "Paused")
55+
with pytest.raises(WorkflowPausedException):
56+
runtime.check_control_plane("wf-test")
57+
58+
59+
class TestUppercaseDrift:
60+
"""If a backend regression emits UPPERCASE (the historical DB
61+
format), the SDK must still raise — the case-insensitive
62+
compare catches the drift."""
63+
64+
def test_killed_uppercase_raises(self, runtime):
65+
_seed_remote_state(runtime, "KILLED")
66+
with pytest.raises(WorkflowKilledInterrupt):
67+
runtime.check_control_plane("wf-test")
68+
69+
def test_paused_uppercase_raises(self, runtime):
70+
_seed_remote_state(runtime, "PAUSED")
71+
with pytest.raises(WorkflowPausedException):
72+
runtime.check_control_plane("wf-test")
73+
74+
75+
class TestLowercaseDrift:
76+
"""If a backend regression emits lowercase, the SDK must still
77+
raise. (Same code path as Uppercase via .lower(), but exercises
78+
a separate input variant.)"""
79+
80+
def test_killed_lowercase_raises(self, runtime):
81+
_seed_remote_state(runtime, "killed")
82+
with pytest.raises(WorkflowKilledInterrupt):
83+
runtime.check_control_plane("wf-test")
84+
85+
def test_paused_lowercase_raises(self, runtime):
86+
_seed_remote_state(runtime, "paused")
87+
with pytest.raises(WorkflowPausedException):
88+
runtime.check_control_plane("wf-test")
89+
90+
91+
class TestNormalState:
92+
"""Anything that does NOT reduce to ``paused`` / ``killed`` must
93+
be a silent pass-through — including the default ``Normal``,
94+
explicit ``"normal"``, ``"running"``, ``"flagged"``, etc."""
95+
96+
def test_normal_pascal_does_not_raise(self, runtime):
97+
_seed_remote_state(runtime, "Normal")
98+
runtime.check_control_plane("wf-test") # no raise
99+
100+
def test_normal_lowercase_does_not_raise(self, runtime):
101+
_seed_remote_state(runtime, "normal")
102+
runtime.check_control_plane("wf-test") # no raise
103+
104+
def test_running_does_not_raise(self, runtime):
105+
_seed_remote_state(runtime, "Running")
106+
runtime.check_control_plane("wf-test") # no raise
107+
108+
def test_unknown_does_not_raise(self, runtime):
109+
_seed_remote_state(runtime, "Tripped") # not in the KILL/PAUSE set
110+
runtime.check_control_plane("wf-test") # no raise

0 commit comments

Comments
 (0)