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.
- wandelbots_novaflow-0.1.0/.gitignore +207 -0
- wandelbots_novaflow-0.1.0/.python-version +1 -0
- wandelbots_novaflow-0.1.0/.vscode/settings.json +5 -0
- wandelbots_novaflow-0.1.0/PKG-INFO +305 -0
- wandelbots_novaflow-0.1.0/README.md +291 -0
- wandelbots_novaflow-0.1.0/pyproject.toml +52 -0
- wandelbots_novaflow-0.1.0/src/novaflow/cli/__init__.py +20 -0
- wandelbots_novaflow-0.1.0/src/novaflow/cli/actions/__init__.py +1 -0
- wandelbots_novaflow-0.1.0/src/novaflow/cli/actions/conformance.py +47 -0
- wandelbots_novaflow-0.1.0/src/novaflow/cli/actions/lint.py +73 -0
- wandelbots_novaflow-0.1.0/src/novaflow/cli/main.py +83 -0
- wandelbots_novaflow-0.1.0/src/novaflow/conformance/__init__.py +58 -0
- wandelbots_novaflow-0.1.0/src/novaflow/conformance/adapters/__init__.py +16 -0
- wandelbots_novaflow-0.1.0/src/novaflow/conformance/adapters/httpx.py +284 -0
- wandelbots_novaflow-0.1.0/src/novaflow/conformance/adapters/synthetic.py +124 -0
- wandelbots_novaflow-0.1.0/src/novaflow/conformance/harness.py +334 -0
- wandelbots_novaflow-0.1.0/src/novaflow/conformance/tracker.py +124 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/__init__.py +21 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/action.py +2405 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/action_context.py +128 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/contract.py +15 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/correlation.py +96 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/crash_visibility.py +55 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/demand_map.py +194 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/exceptions.py +53 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/halt.py +378 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/input_validation.py +29 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/materializers/__init__.py +16 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/materializers/image.py +569 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/materializers/json_data.py +114 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/materializers/pointcloud.py +368 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/nats_backoff.py +66 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/nats_circuit_breaker.py +36 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/object_store.py +301 -0
- wandelbots_novaflow-0.1.0/src/novaflow/core/worker_pool.py +1090 -0
- wandelbots_novaflow-0.1.0/src/novaflow/lint/__init__.py +6 -0
- wandelbots_novaflow-0.1.0/src/novaflow/lint/halt.py +293 -0
- wandelbots_novaflow-0.1.0/src/novaflow/settings/__init__.py +3 -0
- wandelbots_novaflow-0.1.0/src/novaflow/settings/nova_settings.py +19 -0
- wandelbots_novaflow-0.1.0/src/novaflow/settings/settings.py +119 -0
- wandelbots_novaflow-0.1.0/tests/__init__.py +0 -0
- wandelbots_novaflow-0.1.0/tests/component/__init__.py +0 -0
- wandelbots_novaflow-0.1.0/tests/component/conftest.py +171 -0
- wandelbots_novaflow-0.1.0/tests/component/test_await_stream_ready_component.py +104 -0
- wandelbots_novaflow-0.1.0/tests/component/test_consumer_reconcile_component.py +107 -0
- wandelbots_novaflow-0.1.0/tests/component/test_demand_map_component.py +156 -0
- wandelbots_novaflow-0.1.0/tests/component/test_reconnect_catchup.py +643 -0
- wandelbots_novaflow-0.1.0/tests/component/test_run_lifecycle_hooks_component.py +188 -0
- wandelbots_novaflow-0.1.0/tests/component/test_state_provider_poll_component.py +155 -0
- wandelbots_novaflow-0.1.0/tests/test_R3_04_grace_period_alignment.py +40 -0
- wandelbots_novaflow-0.1.0/tests/test_R3_05_shutdown_graceful_halt.py +38 -0
- wandelbots_novaflow-0.1.0/tests/test_R3_06_halt_subscription_loud_fail.py +70 -0
- wandelbots_novaflow-0.1.0/tests/test_R3_07_partial_cleanup_reported.py +102 -0
- wandelbots_novaflow-0.1.0/tests/test_R4_03_worker_initializer_loud_fail.py +48 -0
- wandelbots_novaflow-0.1.0/tests/test_R4_04_dual_publish_serialization.py +44 -0
- wandelbots_novaflow-0.1.0/tests/test_action_cmd_subject.py +60 -0
- wandelbots_novaflow-0.1.0/tests/test_action_context.py +188 -0
- wandelbots_novaflow-0.1.0/tests/test_async_sync_handlers.py +310 -0
- wandelbots_novaflow-0.1.0/tests/test_cli.py +55 -0
- wandelbots_novaflow-0.1.0/tests/test_conformance_harness.py +224 -0
- wandelbots_novaflow-0.1.0/tests/test_conformance_httpx.py +116 -0
- wandelbots_novaflow-0.1.0/tests/test_consumer_dedup.py +233 -0
- wandelbots_novaflow-0.1.0/tests/test_crash_visibility.py +63 -0
- wandelbots_novaflow-0.1.0/tests/test_dual_publish.py +271 -0
- wandelbots_novaflow-0.1.0/tests/test_empty_model.py +332 -0
- wandelbots_novaflow-0.1.0/tests/test_halt.py +279 -0
- wandelbots_novaflow-0.1.0/tests/test_heartbeat.py +331 -0
- wandelbots_novaflow-0.1.0/tests/test_lint_halt.py +225 -0
- wandelbots_novaflow-0.1.0/tests/test_model_validation.py +391 -0
- wandelbots_novaflow-0.1.0/tests/test_name_validation.py +176 -0
- wandelbots_novaflow-0.1.0/tests/test_object_store_local_path.py +39 -0
- wandelbots_novaflow-0.1.0/tests/test_object_store_nova_path.py +165 -0
- wandelbots_novaflow-0.1.0/tests/test_reconnect_halt_catchup.py +430 -0
- wandelbots_novaflow-0.1.0/tests/test_run_lifecycle_hooks.py +120 -0
- wandelbots_novaflow-0.1.0/tests/test_service_condition_removed.py +44 -0
- wandelbots_novaflow-0.1.0/tests/test_startup_await_stream_ready.py +116 -0
- wandelbots_novaflow-0.1.0/tests/test_state_provider_write.py +396 -0
- wandelbots_novaflow-0.1.0/tests/test_worker_pool.py +1281 -0
- wandelbots_novaflow-0.1.0/tests/test_worker_pool_parse_result_after_halt.py +113 -0
- 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,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.
|