wandelbots-novaflow 0.1.0__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.
Files changed (80) hide show
  1. wandelbots_novaflow-0.1.0/.gitignore +207 -0
  2. wandelbots_novaflow-0.1.0/.python-version +1 -0
  3. wandelbots_novaflow-0.1.0/.vscode/settings.json +5 -0
  4. wandelbots_novaflow-0.1.0/PKG-INFO +305 -0
  5. wandelbots_novaflow-0.1.0/README.md +291 -0
  6. wandelbots_novaflow-0.1.0/pyproject.toml +52 -0
  7. wandelbots_novaflow-0.1.0/src/novaflow/cli/__init__.py +20 -0
  8. wandelbots_novaflow-0.1.0/src/novaflow/cli/actions/__init__.py +1 -0
  9. wandelbots_novaflow-0.1.0/src/novaflow/cli/actions/conformance.py +47 -0
  10. wandelbots_novaflow-0.1.0/src/novaflow/cli/actions/lint.py +73 -0
  11. wandelbots_novaflow-0.1.0/src/novaflow/cli/main.py +83 -0
  12. wandelbots_novaflow-0.1.0/src/novaflow/conformance/__init__.py +58 -0
  13. wandelbots_novaflow-0.1.0/src/novaflow/conformance/adapters/__init__.py +16 -0
  14. wandelbots_novaflow-0.1.0/src/novaflow/conformance/adapters/httpx.py +284 -0
  15. wandelbots_novaflow-0.1.0/src/novaflow/conformance/adapters/synthetic.py +124 -0
  16. wandelbots_novaflow-0.1.0/src/novaflow/conformance/harness.py +334 -0
  17. wandelbots_novaflow-0.1.0/src/novaflow/conformance/tracker.py +124 -0
  18. wandelbots_novaflow-0.1.0/src/novaflow/core/__init__.py +21 -0
  19. wandelbots_novaflow-0.1.0/src/novaflow/core/action.py +2405 -0
  20. wandelbots_novaflow-0.1.0/src/novaflow/core/action_context.py +128 -0
  21. wandelbots_novaflow-0.1.0/src/novaflow/core/contract.py +15 -0
  22. wandelbots_novaflow-0.1.0/src/novaflow/core/correlation.py +96 -0
  23. wandelbots_novaflow-0.1.0/src/novaflow/core/crash_visibility.py +55 -0
  24. wandelbots_novaflow-0.1.0/src/novaflow/core/demand_map.py +194 -0
  25. wandelbots_novaflow-0.1.0/src/novaflow/core/exceptions.py +53 -0
  26. wandelbots_novaflow-0.1.0/src/novaflow/core/halt.py +378 -0
  27. wandelbots_novaflow-0.1.0/src/novaflow/core/input_validation.py +29 -0
  28. wandelbots_novaflow-0.1.0/src/novaflow/core/materializers/__init__.py +16 -0
  29. wandelbots_novaflow-0.1.0/src/novaflow/core/materializers/image.py +569 -0
  30. wandelbots_novaflow-0.1.0/src/novaflow/core/materializers/json_data.py +114 -0
  31. wandelbots_novaflow-0.1.0/src/novaflow/core/materializers/pointcloud.py +368 -0
  32. wandelbots_novaflow-0.1.0/src/novaflow/core/nats_backoff.py +66 -0
  33. wandelbots_novaflow-0.1.0/src/novaflow/core/nats_circuit_breaker.py +36 -0
  34. wandelbots_novaflow-0.1.0/src/novaflow/core/object_store.py +301 -0
  35. wandelbots_novaflow-0.1.0/src/novaflow/core/worker_pool.py +1090 -0
  36. wandelbots_novaflow-0.1.0/src/novaflow/lint/__init__.py +6 -0
  37. wandelbots_novaflow-0.1.0/src/novaflow/lint/halt.py +293 -0
  38. wandelbots_novaflow-0.1.0/src/novaflow/settings/__init__.py +3 -0
  39. wandelbots_novaflow-0.1.0/src/novaflow/settings/nova_settings.py +19 -0
  40. wandelbots_novaflow-0.1.0/src/novaflow/settings/settings.py +119 -0
  41. wandelbots_novaflow-0.1.0/tests/__init__.py +0 -0
  42. wandelbots_novaflow-0.1.0/tests/component/__init__.py +0 -0
  43. wandelbots_novaflow-0.1.0/tests/component/conftest.py +171 -0
  44. wandelbots_novaflow-0.1.0/tests/component/test_await_stream_ready_component.py +104 -0
  45. wandelbots_novaflow-0.1.0/tests/component/test_consumer_reconcile_component.py +107 -0
  46. wandelbots_novaflow-0.1.0/tests/component/test_demand_map_component.py +156 -0
  47. wandelbots_novaflow-0.1.0/tests/component/test_reconnect_catchup.py +643 -0
  48. wandelbots_novaflow-0.1.0/tests/component/test_run_lifecycle_hooks_component.py +188 -0
  49. wandelbots_novaflow-0.1.0/tests/component/test_state_provider_poll_component.py +155 -0
  50. wandelbots_novaflow-0.1.0/tests/test_R3_04_grace_period_alignment.py +40 -0
  51. wandelbots_novaflow-0.1.0/tests/test_R3_05_shutdown_graceful_halt.py +38 -0
  52. wandelbots_novaflow-0.1.0/tests/test_R3_06_halt_subscription_loud_fail.py +70 -0
  53. wandelbots_novaflow-0.1.0/tests/test_R3_07_partial_cleanup_reported.py +102 -0
  54. wandelbots_novaflow-0.1.0/tests/test_R4_03_worker_initializer_loud_fail.py +48 -0
  55. wandelbots_novaflow-0.1.0/tests/test_R4_04_dual_publish_serialization.py +44 -0
  56. wandelbots_novaflow-0.1.0/tests/test_action_cmd_subject.py +60 -0
  57. wandelbots_novaflow-0.1.0/tests/test_action_context.py +188 -0
  58. wandelbots_novaflow-0.1.0/tests/test_async_sync_handlers.py +310 -0
  59. wandelbots_novaflow-0.1.0/tests/test_cli.py +55 -0
  60. wandelbots_novaflow-0.1.0/tests/test_conformance_harness.py +224 -0
  61. wandelbots_novaflow-0.1.0/tests/test_conformance_httpx.py +116 -0
  62. wandelbots_novaflow-0.1.0/tests/test_consumer_dedup.py +233 -0
  63. wandelbots_novaflow-0.1.0/tests/test_crash_visibility.py +63 -0
  64. wandelbots_novaflow-0.1.0/tests/test_dual_publish.py +271 -0
  65. wandelbots_novaflow-0.1.0/tests/test_empty_model.py +332 -0
  66. wandelbots_novaflow-0.1.0/tests/test_halt.py +279 -0
  67. wandelbots_novaflow-0.1.0/tests/test_heartbeat.py +331 -0
  68. wandelbots_novaflow-0.1.0/tests/test_lint_halt.py +225 -0
  69. wandelbots_novaflow-0.1.0/tests/test_model_validation.py +391 -0
  70. wandelbots_novaflow-0.1.0/tests/test_name_validation.py +176 -0
  71. wandelbots_novaflow-0.1.0/tests/test_object_store_local_path.py +39 -0
  72. wandelbots_novaflow-0.1.0/tests/test_object_store_nova_path.py +165 -0
  73. wandelbots_novaflow-0.1.0/tests/test_reconnect_halt_catchup.py +430 -0
  74. wandelbots_novaflow-0.1.0/tests/test_run_lifecycle_hooks.py +120 -0
  75. wandelbots_novaflow-0.1.0/tests/test_service_condition_removed.py +44 -0
  76. wandelbots_novaflow-0.1.0/tests/test_startup_await_stream_ready.py +116 -0
  77. wandelbots_novaflow-0.1.0/tests/test_state_provider_write.py +396 -0
  78. wandelbots_novaflow-0.1.0/tests/test_worker_pool.py +1281 -0
  79. wandelbots_novaflow-0.1.0/tests/test_worker_pool_parse_result_after_halt.py +113 -0
  80. wandelbots_novaflow-0.1.0/uv.lock +1455 -0
@@ -0,0 +1,207 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,5 @@
1
+ {
2
+ "editor.defaultFormatter": "charliermarsh.ruff",
3
+ "editor.formatOnPaste": true,
4
+ "editor.formatOnSave": true
5
+ }
@@ -0,0 +1,305 @@
1
+ Metadata-Version: 2.4
2
+ Name: wandelbots-novaflow
3
+ Version: 0.1.0
4
+ Summary: Nova on Nodes
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: faststream[nats]==0.6.7
7
+ Requires-Dist: httpx>=0.27.0
8
+ Requires-Dist: loguru==0.7.3
9
+ Requires-Dist: novaflow-types==0.1.0
10
+ Requires-Dist: pydantic-settings==2.11.0
11
+ Requires-Dist: python-dotenv==1.2.2
12
+ Requires-Dist: python-statemachine<4,>=3.2.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # NovaFlow
16
+
17
+ The `novaflow` package allows you to build distributed action services for the NovaFlow behavior tree orchestration framework. It provides a simple **decorator-based API** for creating microservices that communicate with the control plane via CloudEvents over NATS. It exports the `ActionService` class, which allows you to define **multiple actions per service** with **automatic handling of communication with control plane** via CloudEvents sent over NATS, including heartbeat management and status reporting.
18
+
19
+ ## Define an Action Service
20
+
21
+ Create a Python file with your action definition:
22
+
23
+ ```python
24
+ from pydantic import BaseModel
25
+ from novaflow.core import ActionService
26
+
27
+ # Define input and output models
28
+ class MyInput(BaseModel):
29
+ message: str
30
+
31
+ class MyOutput(BaseModel):
32
+ processed: str
33
+
34
+ # Create service
35
+ service = ActionService(name="MyService", version="1.0.0")
36
+
37
+ # Define actions with input and output validation
38
+ @service.action(
39
+ action_name="ProcessMessage",
40
+ input_model=MyInput,
41
+ output_model=MyOutput
42
+ )
43
+ async def process_message(params: MyInput) -> MyOutput:
44
+ """Process a message."""
45
+ return MyOutput(
46
+ processed=params.message.upper(),
47
+ )
48
+
49
+ # Run service
50
+ if __name__ == "__main__":
51
+ service.run()
52
+ ```
53
+
54
+ > **Conditions are not actions.** A gate/branch in a behavior tree is a
55
+ > synchronous `Condition` node that evaluates a predicate against the
56
+ > blackboard — not a service decorator. See the `Condition` node and the
57
+ > State Provider framework (`../docs/state-providers.md`) rather than defining a
58
+ > boolean-returning action.
59
+
60
+ ## Organizing Actions with Routers
61
+
62
+ For larger services, you can organize actions into separate modules using `Router`:
63
+
64
+ ```python
65
+ # In actions/message_processing.py
66
+ from pydantic import BaseModel
67
+ from novaflow.core import Router
68
+
69
+ router = Router()
70
+
71
+ class MessageInput(BaseModel):
72
+ text: str
73
+
74
+ class MessageOutput(BaseModel):
75
+ result: str
76
+
77
+ @router.action("ProcessText", MessageInput, MessageOutput)
78
+ async def process_text(params: MessageInput) -> dict:
79
+ return {"result": params.text.upper()}
80
+
81
+ # In run.py
82
+ from novaflow.core import ActionService
83
+ from actions import message_processing
84
+
85
+ service = ActionService(name="MessageService")
86
+ service.include_router(message_processing.router)
87
+ service.run()
88
+ ```
89
+
90
+ ### Input and Output Models
91
+
92
+ **Input / Output Models**: Optional Pydantic `BaseModel` with flat fields using only [supported types](../novaflow-schemas/python/src/novaflow/types/data/__init__.py). Set to `None` if the action requires no input or output respectively.
93
+
94
+ Models are validated at service startup, as well as on each request. Action will validate incoming data against the input model and outgoing data against the output model. **If the function returns data when no output model is defined, an error will be raised.** This ensures strict adherence to the defined data contracts.
95
+
96
+ > [!NOTE]
97
+ > This is similar to how FastAPI validates request and response models. However, the current approach is less flexible as it does not support automated parsing from type hints and requires explicit model definitions.
98
+
99
+ ### Action Function
100
+
101
+ Every action becomes an endpoint for the service. It will automatically listen to events sent on `<nats-cmd-prefix>.<ServiceName>.<ActionName>` subject. So the control plane can invoke the action by sending a CloudEvent to that subject. This maps directly to the tree definition like so:
102
+
103
+ ```json
104
+ {
105
+ "version": "3.0",
106
+ "root": {
107
+ "id": "node-1",
108
+ "kind": "action",
109
+ "namespace": "wandelbots",
110
+ "service": "<ServiceName>",
111
+ "operation": "<ActionName>",
112
+ "schema_version": "1.0.0",
113
+ "data": {
114
+ "message": { "ref": "value", "data": "Hello World" }
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ (`data.message` is wrapped as `{ "ref": "value", "data": … }` — the field carries
121
+ either an inline value or an output reference; `data` matches the action's input model.)
122
+
123
+ ## Run the Action Service
124
+
125
+ ```bash
126
+ uv run your_action.py
127
+ ```
128
+
129
+ ## Communication Protocol
130
+
131
+ Services communicate with the control plane using **CloudEvents** over NATS:
132
+
133
+ ```
134
+ Control Plane Service
135
+ | |
136
+ |-----(CloudEvent Request)---->|
137
+ |<----(RUNNING)----------------| t=0s (execution started)
138
+ |<----(RUNNING)----------------| t=2s (heartbeat)
139
+ |<----(RUNNING)----------------| t=4s (heartbeat)
140
+ |<----(RUNNING)----------------| t=6s (heartbeat)
141
+ | | ... function still executing ...
142
+ |<----(RUNNING)----------------| t=8s (heartbeat)
143
+ |<----(SUCCESS)----------------| t=9s (completed!)
144
+ | (return response) |
145
+ ```
146
+
147
+ ### Message Flow
148
+
149
+ 1. **Request**: Control plane sends CloudEvent with action data to `svc.cmd.{ServiceName}.{ActionName}`
150
+ 2. **RUNNING**: Service immediately responds with status and sends heartbeat updates every 2s
151
+ 3. **SUCCESS/FAILED**: Service sends final status with result data or error message
152
+
153
+ For the complete NATS communication architecture, see
154
+ [ADR 017](../docs/adr/017-action-communication-protocol.md).
155
+
156
+ ### Development & Testing
157
+
158
+ Running tests:
159
+
160
+ ```bash
161
+ uv run pytest tests/
162
+ ```
163
+
164
+ ## Halt Semantics
165
+
166
+ Actions run under a one-verb halt model: when a run is halted (user-
167
+ initiated stop, parent BT preemption, etc.), the SDK fires a
168
+ `HaltToken` your handler can observe, then escalates to SIGKILL on
169
+ deadline if the handler doesn't return cooperatively. There is no
170
+ soft/hard mode — one cooperative grace path, one supervisor floor.
171
+
172
+ ```python
173
+ from novaflow.core.halt import HaltToken
174
+
175
+ @router.action("Move", MoveInput, MoveOutput)
176
+ async def move(input: MoveInput, halt: HaltToken) -> MoveOutput:
177
+ # `halt.is_halted` polls cheaply; `halt.wait_for_halt(timeout=...)`
178
+ # races against the halt event; `halt.on_halt(callback)` runs a
179
+ # cleanup when halt fires.
180
+ async with my_db.connect() as db:
181
+ return await analyse(db, halt=halt)
182
+ ```
183
+
184
+ For resources that have a context manager (`async with`), the
185
+ language-level cleanup is the right pattern — `__aexit__` runs on
186
+ halt-induced `CancelledError`. For ordering-sensitive cleanup or
187
+ legacy libraries without context managers, `halt.on_halt(close_fn)`
188
+ registers a cleanup callback. See
189
+ [ADR 005 — Action Halt](../docs/adr/005-action-halt.md).
190
+
191
+ > **Note on physical halt.** Motion actions built on the Nova SDK
192
+ > (via `CancellableMovementController`) halt with confirmed
193
+ > standstill — the controller surfaces `"standstill confirmed"` on
194
+ > the wire's `message` field. This is a property of how the
195
+ > handler is *built*; an action that bypasses Nova does not get
196
+ > it. The supervisor SIGKILL floor still terminates the process
197
+ > regardless. `status="halted"` is a controlled-stop signal, **not
198
+ > a safety-rated stop** — safety-rated stops remain the hardware
199
+ > E-stop circuit's responsibility. Conversely, when a motion action
200
+ > *observes* a hardware safety event on the controller (e.g. via
201
+ > Nova's `stream_robot_controller_state`), the run terminates as
202
+ > `status="failed"`, not `"halted"` — the safety event is an external
203
+ > interruption from the stack's POV, not a halt the stack initiated.
204
+ > See [`docs/terminology.md`](../docs/terminology.md) and issue #337.
205
+
206
+ ## Developer CLI (`nf`)
207
+
208
+ The `nf` command is the single entry point for novaflow developer
209
+ tooling. Installing this package puts `nf` on your PATH (declared as
210
+ a `[project.scripts]` entry in `pyproject.toml`):
211
+
212
+ ```bash
213
+ uv run nf # top-level help
214
+ uv run nf actions # action-author commands
215
+ uv run nf actions lint # halt-cleanup heuristic check
216
+ uv run nf actions conformance # halt-conformance harness driver (stub)
217
+ ```
218
+
219
+ Subcommands follow `git`-style nesting: `nf <group> <command>`. The
220
+ `actions` group hosts every command an action author needs; future
221
+ groups (`nf trees …`, `nf services …`) will follow the same pattern.
222
+
223
+ ## Halt-cleanup Lint (advisory)
224
+
225
+ `novaflow.lint.halt` is a heuristic AST scan that flags action handlers
226
+ that acquire a resource (a httpx client, a db pool, a websocket, a
227
+ camera handle, …) without a paired cleanup — no `async with`, no
228
+ `.close()`, no `halt.on_halt`, not returned. Run it against your
229
+ action package:
230
+
231
+ ```bash
232
+ uv run nf actions lint # defaults to src/actions/
233
+ uv run nf actions lint path/to/handlers # override
234
+ ```
235
+
236
+ The rule is **warning-only** (~70% true positive / ~30% false positive
237
+ based on a real-codebase scan), exit code is always 0. It's a
238
+ developer aid, not a CI gate. The conformance harness (below) is the
239
+ authoritative safety check.
240
+
241
+ ## Halt-conformance Testing
242
+
243
+ `novaflow.conformance` is a halt-conformance harness that verifies
244
+ your action handlers actually release their resources on halt — by
245
+ running them against a real (testcontainer) backend and observing
246
+ the **server side**, not just trusting the in-process bookkeeping.
247
+
248
+ The pattern (also see `actions/builtin/balance/tests/test_halt_conformance.py`):
249
+
250
+ ```python
251
+ # tests/test_halt_conformance.py
252
+ import pytest
253
+ from novaflow.conformance import HaltConformanceHarness
254
+ from novaflow.conformance.adapters.httpx import HttpxAdapter
255
+ from novaflow.conformance.harness import ConformanceCase
256
+ from novaflow.core.halt import HaltToken
257
+
258
+ from src.actions.my_action import my_handler, MyInput
259
+
260
+
261
+ @pytest.mark.asyncio
262
+ async def test_my_action_halt_conformance():
263
+ async def handler(halt: HaltToken, fixture: dict):
264
+ try:
265
+ await my_handler(MyInput(base_url=fixture["base_url"]), halt=halt)
266
+ except Exception:
267
+ pass
268
+
269
+ async with HttpxAdapter(drain_timeout_s=2.0) as adapter:
270
+ harness = HaltConformanceHarness("MyAction", adapter)
271
+ # Case 1: handler completes before halt fires (clean path).
272
+ harness.add_case(ConformanceCase("clean", handler, halt_after_ms=2000))
273
+ # Case 2: halt fires mid-request — handler must still release.
274
+ # 100 ms (not 10 ms) — macOS' localhost TCP handshake plus
275
+ # first-byte takes ~20–30 ms in pytest-asyncio's loop, so a
276
+ # 10 ms halt sometimes fires before the request leaves the
277
+ # client. 100 ms reliably exercises the in-flight cancel.
278
+ harness.add_case(ConformanceCase("halt_mid", handler, halt_after_ms=100))
279
+ result = await harness.run()
280
+ assert result.all_passed, result.describe()
281
+ ```
282
+
283
+ Run via:
284
+
285
+ ```bash
286
+ uv run pytest tests/test_halt_conformance.py
287
+ ```
288
+
289
+ **Built-in adapters:**
290
+
291
+ | Adapter | Use when your action talks to… | Real backend |
292
+ | ------------------ | ------------------------------------ | ---------------------- |
293
+ | `HttpxAdapter` | HTTP APIs via `httpx.AsyncClient` | in-process asyncio HTTP server with TCP-level peer-close detection |
294
+ | `SyntheticAdapter` | a fake resource (testing the harness itself) | in-memory pool |
295
+
296
+ For a custom backend (postgres, gRPC, MQTT, vendor-specific client),
297
+ implement the small `Adapter` protocol — see
298
+ [docs/halt-conformance.md](../docs/halt-conformance.md) for the full
299
+ guide and `novaflow/src/novaflow/conformance/adapters/synthetic.py`
300
+ as the reference.
301
+
302
+ The harness is a **dev-side quality bar**, not a runtime gate — the
303
+ worker pool's SIGKILL floor enforces termination regardless. A
304
+ non-conforming action still runs; it just gets a "no halt-conformance
305
+ attestation" badge in the operator UI.