chemunited-workflow 0.0.1__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.
- chemunited_workflow-0.0.1/LICENSE +21 -0
- chemunited_workflow-0.0.1/PKG-INFO +47 -0
- chemunited_workflow-0.0.1/README.md +388 -0
- chemunited_workflow-0.0.1/chemunited_workflow/__init__.py +46 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/__init__.py +41 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/dependencies.py +35 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/project_holder.py +80 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/routers/__init__.py +0 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/routers/components.py +30 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/routers/logs.py +60 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/routers/processes.py +53 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/routers/project.py +53 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/routers/runner.py +196 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/routers/snapshots.py +73 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/run_store.py +79 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/schemas.py +110 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/services/__init__.py +0 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/services/protocol.py +238 -0
- chemunited_workflow-0.0.1/chemunited_workflow/api/services/runner.py +152 -0
- chemunited_workflow-0.0.1/chemunited_workflow/cli.py +114 -0
- chemunited_workflow-0.0.1/chemunited_workflow/clients.py +348 -0
- chemunited_workflow-0.0.1/chemunited_workflow/compiler.py +135 -0
- chemunited_workflow-0.0.1/chemunited_workflow/durations.py +32 -0
- chemunited_workflow-0.0.1/chemunited_workflow/enums.py +28 -0
- chemunited_workflow-0.0.1/chemunited_workflow/exceptions.py +5 -0
- chemunited_workflow-0.0.1/chemunited_workflow/executor.py +548 -0
- chemunited_workflow-0.0.1/chemunited_workflow/mcp/__init__.py +32 -0
- chemunited_workflow-0.0.1/chemunited_workflow/mcp/tools.py +286 -0
- chemunited_workflow-0.0.1/chemunited_workflow/models.py +170 -0
- chemunited_workflow-0.0.1/chemunited_workflow/monitoring.py +5 -0
- chemunited_workflow-0.0.1/chemunited_workflow/platform.py +102 -0
- chemunited_workflow-0.0.1/chemunited_workflow/process.py +169 -0
- chemunited_workflow-0.0.1/chemunited_workflow/project_loader.py +78 -0
- chemunited_workflow-0.0.1/chemunited_workflow/quantity.py +146 -0
- chemunited_workflow-0.0.1/chemunited_workflow/terminal.py +76 -0
- chemunited_workflow-0.0.1/chemunited_workflow.egg-info/PKG-INFO +47 -0
- chemunited_workflow-0.0.1/chemunited_workflow.egg-info/SOURCES.txt +41 -0
- chemunited_workflow-0.0.1/chemunited_workflow.egg-info/dependency_links.txt +1 -0
- chemunited_workflow-0.0.1/chemunited_workflow.egg-info/entry_points.txt +2 -0
- chemunited_workflow-0.0.1/chemunited_workflow.egg-info/requires.txt +20 -0
- chemunited_workflow-0.0.1/chemunited_workflow.egg-info/top_level.txt +1 -0
- chemunited_workflow-0.0.1/pyproject.toml +46 -0
- chemunited_workflow-0.0.1/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Automated Chemistry @ Max Plank Institute of Colloids and Interfaces
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chemunited-workflow
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Conditional NetworkX-based workflow execution with loopbacks and parallel branches.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Automated Chemistry @ Max Plank Institute of Colloids and Interfaces
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: networkx>=3.2
|
|
30
|
+
Requires-Dist: pydantic>=2.0
|
|
31
|
+
Requires-Dist: loguru>=0.7
|
|
32
|
+
Requires-Dist: rich>=13.0
|
|
33
|
+
Requires-Dist: requests>=2.31
|
|
34
|
+
Requires-Dist: fastapi!=0.136.3,>=0.111
|
|
35
|
+
Requires-Dist: starlette>=1.0.1
|
|
36
|
+
Requires-Dist: mcp[cli]>=1.0
|
|
37
|
+
Requires-Dist: click>=8.1
|
|
38
|
+
Requires-Dist: pint>=0.23
|
|
39
|
+
Requires-Dist: pydantic-pint>=0.2
|
|
40
|
+
Provides-Extra: server
|
|
41
|
+
Requires-Dist: uvicorn>=0.29; extra == "server"
|
|
42
|
+
Provides-Extra: test
|
|
43
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
44
|
+
Requires-Dist: pytest-mock>=3.12; extra == "test"
|
|
45
|
+
Requires-Dist: responses>=0.25; extra == "test"
|
|
46
|
+
Requires-Dist: httpx>=0.27; extra == "test"
|
|
47
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# chemunited-workflow
|
|
2
|
+
|
|
3
|
+
A NetworkX-based workflow execution engine for conditional automation of chemistry experiments. Designed for hardware-in-the-loop laboratory protocols where operations depend on device responses, physical measurements, and branching conditions.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Conditional DAG execution** with loopbacks and parallel branches
|
|
8
|
+
- **Device-centric HTTP clients** for hardware control (pipettes, reactors, pumps, etc.)
|
|
9
|
+
- **Thread-safe parallel node execution** via `ThreadPoolExecutor`
|
|
10
|
+
- **Physical unit handling** (volumes, temperatures, concentrations) using Pint
|
|
11
|
+
- **Multiple deployment modes**: FastAPI REST API, MCP server, or direct Python execution
|
|
12
|
+
- **Protocol versioning** with snapshot persistence and schema validation
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Python >= 3.11
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git clone https://github.com/automatedchemistry/chemunited-workflow.git
|
|
22
|
+
cd chemunited-workflow
|
|
23
|
+
|
|
24
|
+
python -m venv .venv
|
|
25
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
26
|
+
|
|
27
|
+
# Core package
|
|
28
|
+
pip install -e .
|
|
29
|
+
|
|
30
|
+
# With API and MCP server support
|
|
31
|
+
pip install -e ".[server]"
|
|
32
|
+
|
|
33
|
+
# With test dependencies
|
|
34
|
+
pip install -e ".[test]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
### 1. Define a workflow
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from chemunited_workflow import Process, Platform
|
|
43
|
+
import networkx as nx
|
|
44
|
+
from pydantic import BaseModel
|
|
45
|
+
|
|
46
|
+
class MyConfig(BaseModel):
|
|
47
|
+
volume_ul: float
|
|
48
|
+
temperature_c: float
|
|
49
|
+
|
|
50
|
+
class MyWorkflow(Process[MyConfig]):
|
|
51
|
+
def build_workflow(self) -> nx.DiGraph:
|
|
52
|
+
G = nx.DiGraph()
|
|
53
|
+
G.add_node("dispense", method="dispense_step")
|
|
54
|
+
G.add_node("heat", method="heat_step")
|
|
55
|
+
G.add_node("verify", method="verify_step")
|
|
56
|
+
G.add_edge("dispense", "heat", condition=True)
|
|
57
|
+
G.add_edge("heat", "verify", condition=True)
|
|
58
|
+
return G
|
|
59
|
+
|
|
60
|
+
def dispense_step(self, ctx):
|
|
61
|
+
client = self.platform["pump_01"]
|
|
62
|
+
r = client.post("dispense", json={"volume": self.config.volume_ul})
|
|
63
|
+
return r.status_code == 200
|
|
64
|
+
|
|
65
|
+
def heat_step(self, ctx):
|
|
66
|
+
client = self.platform["reactor_01"]
|
|
67
|
+
r = client.post("set_temp", json={"temp": self.config.temperature_c})
|
|
68
|
+
return r.status_code == 200
|
|
69
|
+
|
|
70
|
+
def verify_step(self, ctx):
|
|
71
|
+
client = self.platform["reactor_01"]
|
|
72
|
+
r = client.get("status")
|
|
73
|
+
return r.json()["ready"]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Configure device connectivity
|
|
77
|
+
|
|
78
|
+
Create `connectivity/associations.json`:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"server_url": "http://192.168.1.10",
|
|
83
|
+
"associations": [
|
|
84
|
+
{ "component": "pump_01", "component_url": "devices/pump_01" },
|
|
85
|
+
{ "component": "reactor_01", "component_url": "devices/reactor_01" }
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 3. Run the workflow
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from pathlib import Path
|
|
94
|
+
|
|
95
|
+
platform = Platform.from_project_dir(Path("my_project"))
|
|
96
|
+
config = MyConfig(volume_ul=500, temperature_c=60.0)
|
|
97
|
+
workflow = MyWorkflow(config, platform)
|
|
98
|
+
result = workflow.run_workflow(start_node="dispense")
|
|
99
|
+
print(result)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Project Structure
|
|
103
|
+
|
|
104
|
+
A typical project using this library looks like:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
my_project/
|
|
108
|
+
├── protocols/
|
|
109
|
+
│ ├── __init__.py # PROCESSES and CONFIGS dicts
|
|
110
|
+
│ ├── main_parameters.py # MainParameter Pydantic model
|
|
111
|
+
│ └── my_workflow.py # Process subclasses (also readable via read_process)
|
|
112
|
+
├── connectivity/
|
|
113
|
+
│ └── associations.json # Device URL mapping
|
|
114
|
+
├── protocols_hystoric/ # Versioned protocol snapshots
|
|
115
|
+
└── log/ # Execution logs
|
|
116
|
+
└── archive/ # Archived logs (populated by archive_log)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`protocols/__init__.py` must export:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from .my_workflow import MyWorkflow, MyConfig
|
|
123
|
+
|
|
124
|
+
PROCESSES = {"my_workflow": MyWorkflow}
|
|
125
|
+
CONFIGS = {"my_workflow": MyConfig}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Deployment Modes
|
|
129
|
+
|
|
130
|
+
### FastAPI server
|
|
131
|
+
|
|
132
|
+
The FastAPI server is project-agnostic. You can start it without a project and load one at runtime, or pass a project directory as a shortcut to pre-load it at startup.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Start without a project — load one via PUT /project after startup
|
|
136
|
+
chemunited-workflow --fastapi --port 3116
|
|
137
|
+
|
|
138
|
+
# Start with a project pre-loaded (shortcut)
|
|
139
|
+
chemunited-workflow my_project --fastapi --port 3116
|
|
140
|
+
|
|
141
|
+
# Development with auto-reload
|
|
142
|
+
chemunited-workflow --fastapi --reload
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
To load or switch projects at runtime:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
curl -X PUT http://127.0.0.1:3116/project/ \
|
|
149
|
+
-H "Content-Type: application/json" \
|
|
150
|
+
-d '{"project_dir": "/absolute/path/to/my_project"}'
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
> **Windows paths:** Use forward slashes (`C:/Users/...`) or escaped backslashes (`C:\\Users\\...`) in JSON — bare backslashes are not valid JSON.
|
|
154
|
+
|
|
155
|
+
You can switch to a different project at any time, as long as no run is currently active.
|
|
156
|
+
|
|
157
|
+
### MCP server
|
|
158
|
+
|
|
159
|
+
The MCP server is project-agnostic. It starts without a project and the LLM loads one at runtime by calling the `load_project` tool.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# MCP server over stdio — expose workflows as tools to Claude or other agents
|
|
163
|
+
chemunited-workflow --mcp
|
|
164
|
+
|
|
165
|
+
# MCP server over streamable HTTP, exposed at http://127.0.0.1:3117/mcp
|
|
166
|
+
chemunited-workflow --mcp-http --port 3117
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### MCP stdio
|
|
170
|
+
|
|
171
|
+
The MCP server runs over **stdio** by default. It does not expose an HTTP
|
|
172
|
+
address in this mode. Instead, the LLM client starts the server command and
|
|
173
|
+
communicates through the process stdin/stdout streams:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"mcpServers": {
|
|
178
|
+
"chemunited-workflow": {
|
|
179
|
+
"command": "chemunited-workflow",
|
|
180
|
+
"args": ["--mcp"]
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
If you want to pin to a specific virtual environment, point `command` at that
|
|
187
|
+
environment's script:
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"mcpServers": {
|
|
192
|
+
"chemunited-workflow": {
|
|
193
|
+
"command": "/absolute/path/to/.venv/bin/chemunited-workflow",
|
|
194
|
+
"args": ["--mcp"]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
On Windows, assuming this repository is checked out at `D:\Projects\chemunited-workflow`:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"mcpServers": {
|
|
205
|
+
"chemunited-workflow": {
|
|
206
|
+
"command": "D:\\Projects\\chemunited-workflow\\.venv\\Scripts\\chemunited-workflow.exe",
|
|
207
|
+
"args": ["--mcp"]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### MCP HTTP
|
|
214
|
+
|
|
215
|
+
Use streamable HTTP when your MCP client asks for a URL or when you want the
|
|
216
|
+
server to run independently of the LLM client process:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
chemunited-workflow --mcp-http --host 127.0.0.1 --port 3117
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The MCP HTTP address is:
|
|
223
|
+
|
|
224
|
+
```text
|
|
225
|
+
http://127.0.0.1:3117/mcp
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
On Windows:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
.venv\Scripts\chemunited-workflow.exe --mcp-http --port 3117
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Use `--mcp-path` to change the endpoint path:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
chemunited-workflow --mcp-http --port 3117 --mcp-path /chemunited-mcp
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Then the address becomes `http://127.0.0.1:3117/chemunited-mcp`.
|
|
241
|
+
|
|
242
|
+
MCP HTTP is separate from the FastAPI REST API. FastAPI uses
|
|
243
|
+
`http://127.0.0.1:3116/docs`; MCP HTTP uses the MCP endpoint path, such as
|
|
244
|
+
`http://127.0.0.1:3117/mcp`.
|
|
245
|
+
|
|
246
|
+
> **Note:** Once connected, ask the LLM to call `load_project` with the path to
|
|
247
|
+
> your project directory. All other tools return an error until a project is loaded.
|
|
248
|
+
|
|
249
|
+
## API Overview
|
|
250
|
+
|
|
251
|
+
When running in `--fastapi` mode the following endpoints are available.
|
|
252
|
+
|
|
253
|
+
All endpoints except `GET /project/` return HTTP `503` if no project has been loaded yet.
|
|
254
|
+
|
|
255
|
+
### Project management
|
|
256
|
+
|
|
257
|
+
| Method | Path | Description |
|
|
258
|
+
|--------|------|-------------|
|
|
259
|
+
| `GET` | `/project/` | Return the currently loaded project directory, or `null` if none is loaded. Always returns `200` — use this as a readiness probe. |
|
|
260
|
+
| `PUT` | `/project/` | Load or switch the active project. Body: `{"project_dir": "/path/to/project"}`. Returns `409` if a run is currently active. |
|
|
261
|
+
|
|
262
|
+
### Processes
|
|
263
|
+
|
|
264
|
+
| Method | Path | Description |
|
|
265
|
+
|--------|------|-------------|
|
|
266
|
+
| `GET` | `/processes/` | List available workflow processes |
|
|
267
|
+
| `GET` | `/processes/{name}/schema` | JSON schema for a process config |
|
|
268
|
+
| `GET` | `/processes/{name}/source` | Full source code of a process file |
|
|
269
|
+
|
|
270
|
+
### Snapshots
|
|
271
|
+
|
|
272
|
+
| Method | Path | Description |
|
|
273
|
+
|--------|------|-------------|
|
|
274
|
+
| `GET` | `/snapshots/` | List saved protocol snapshots |
|
|
275
|
+
| `GET` | `/snapshots/{filename}` | Read a snapshot by filename |
|
|
276
|
+
| `POST` | `/snapshots/` | Save a new versioned snapshot |
|
|
277
|
+
| `DELETE` | `/snapshots/{filename}` | Permanently delete a snapshot |
|
|
278
|
+
|
|
279
|
+
### Run control
|
|
280
|
+
|
|
281
|
+
| Method | Path | Description |
|
|
282
|
+
|--------|------|-------------|
|
|
283
|
+
| `POST` | `/run/` | Start a workflow run from a snapshot. Body: `{"snapshot": "<snapshot filename>", "dry_run": false}`. `snapshot` is required; `dry_run` defaults to `false`. Returns HTTP `202` with a `run_id`. |
|
|
284
|
+
| `GET` | `/run/active` | Return the active run id as `{"run_id": "<id>"}` or `{"run_id": null}` |
|
|
285
|
+
| `GET` | `/run/{run_id}/status` | Poll run state and events. Events are cleared after each read; terminal states are `finished`, `failed`, and `cancelled`. |
|
|
286
|
+
| `GET` | `/run/{run_id}/report` | Full execution report for a finished run |
|
|
287
|
+
| `DELETE` | `/run/{run_id}` | Cancel an active run |
|
|
288
|
+
| `GET` | `/run/{run_id}/stream` | Stream workflow events for a run |
|
|
289
|
+
| `GET` | `/run/pool` | Drain pending device commands and delete their pool files; returns an empty list when no commands are pending |
|
|
290
|
+
|
|
291
|
+
Example `POST /run/` request:
|
|
292
|
+
|
|
293
|
+
```json
|
|
294
|
+
{
|
|
295
|
+
"snapshot": "snapshot_20250101T120000.json",
|
|
296
|
+
"dry_run": false
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Use `dry_run: true` to simulate device calls. The workflow graph and node logic still run, but physical HTTP calls are suppressed.
|
|
301
|
+
|
|
302
|
+
`POST /run/` accepts `timeout_commands` in the JSON body. Use values such as
|
|
303
|
+
`"5 s"` or `"2 min"` to control feedback polling timeout; omit it for the
|
|
304
|
+
default `"10 s"`, or pass `""` to poll without a timeout.
|
|
305
|
+
|
|
306
|
+
### Logs
|
|
307
|
+
|
|
308
|
+
| Method | Path | Description |
|
|
309
|
+
|--------|------|-------------|
|
|
310
|
+
| `GET` | `/logs/` | List log file metadata, sorted most recent first |
|
|
311
|
+
| `GET` | `/logs/search?query=...&max_results=50` | Search all active log files for matching lines, case-insensitive |
|
|
312
|
+
| `GET` | `/logs/{filename}?tail=N` | Read a log file. `tail` is optional and returns only the last `N` lines. |
|
|
313
|
+
| `POST` | `/logs/{filename}/archive` | Move a log to `log/archive/` |
|
|
314
|
+
|
|
315
|
+
### Components
|
|
316
|
+
|
|
317
|
+
| Method | Path | Description |
|
|
318
|
+
|--------|------|-------------|
|
|
319
|
+
| `GET` | `/components/` | Return the full `associations.json` map |
|
|
320
|
+
| `GET` | `/components/ping?timeout=2.0` | Check reachability of every device URL |
|
|
321
|
+
|
|
322
|
+
Visit `/docs` for the interactive Swagger UI.
|
|
323
|
+
|
|
324
|
+
## MCP Tools
|
|
325
|
+
|
|
326
|
+
When running in `--mcp` or `--mcp-http` mode, the following tools are exposed to the connected LLM agent:
|
|
327
|
+
|
|
328
|
+
| Tool | Description |
|
|
329
|
+
|------|-------------|
|
|
330
|
+
| `load_project` | Load or switch the active project by directory path. Rejected if a run is active. |
|
|
331
|
+
| `get_project` | Return the currently loaded project path, or null if none is loaded. |
|
|
332
|
+
| `list_processes` | Discover available process names and schemas |
|
|
333
|
+
| `get_process_schema` | Full parameter schema for a named process |
|
|
334
|
+
| `read_process` | Source code of a process definition file |
|
|
335
|
+
| `list_snapshots` | List snapshots in `protocols_hystoric/` |
|
|
336
|
+
| `get_snapshot` | Read a snapshot's full JSON content |
|
|
337
|
+
| `create_snapshot` | Validate and save a new versioned snapshot |
|
|
338
|
+
| `delete_snapshot` | Permanently delete a snapshot |
|
|
339
|
+
| `start_run` | Execute a snapshot; returns a `run_id` |
|
|
340
|
+
| `get_run_status` | Poll run state and events |
|
|
341
|
+
| `get_run_report` | Full per-step execution report |
|
|
342
|
+
| `cancel_run` | Cancel an active run |
|
|
343
|
+
| `get_components` | Return the device connectivity map |
|
|
344
|
+
| `ping_components` | Check reachability of all device URLs |
|
|
345
|
+
| `list_logs` | List log files |
|
|
346
|
+
| `read_log` | Read a log file's text content |
|
|
347
|
+
| `search_logs` | Search log files for a query string |
|
|
348
|
+
| `archive_log` | Move a log file to `log/archive/` |
|
|
349
|
+
|
|
350
|
+
## How Execution Works
|
|
351
|
+
|
|
352
|
+
1. **Graph definition** — `build_workflow()` returns a `networkx.DiGraph` where each node has a `method` attribute pointing to a method on the `Process` class, and edges carry boolean `condition` values.
|
|
353
|
+
2. **Compilation** — `compile_workflow()` validates the graph, extracts loopback edges (cycles), and ensures the remaining graph is a DAG.
|
|
354
|
+
3. **Execution** — `WorkflowExecutor` traverses the compiled graph using a `ThreadPoolExecutor`: nodes whose predecessors have all completed are scheduled concurrently; edge conditions are evaluated to route execution; loopbacks are triggered when a node returns `True` to repeat a section.
|
|
355
|
+
4. **Events** — The executor emits `WorkflowExecutionEvent` objects for each state transition, consumed by the API to provide real-time status and log streaming.
|
|
356
|
+
5. **Result** — A `WorkflowResult` is returned with the final state, per-node results, runtime, and any errors.
|
|
357
|
+
|
|
358
|
+
## Physical Units
|
|
359
|
+
|
|
360
|
+
Use `ChemUnitQuantity` for values that carry SI units:
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
from chemunited_workflow import ChemUnitQuantity
|
|
364
|
+
|
|
365
|
+
volume = ChemUnitQuantity.parse("500 ul")
|
|
366
|
+
double = volume * 2 # 1000 ul
|
|
367
|
+
in_ml = volume.to("ml") # 0.5 ml
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Units are validated and propagated through arithmetic. `ChemQuantityValidator` integrates with Pydantic models.
|
|
371
|
+
|
|
372
|
+
## Dry-Run Mode
|
|
373
|
+
|
|
374
|
+
Pass `dry_run=True` to `Platform` to suppress all HTTP calls — useful for testing graph logic without hardware:
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
platform = Platform.from_connectivity(path, dry_run=True)
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## Running Tests
|
|
381
|
+
|
|
382
|
+
```bash
|
|
383
|
+
pytest tests/
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## License
|
|
387
|
+
|
|
388
|
+
MIT — Automated Chemistry, Max Planck Institute for Colloids and Interfaces.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Public API for the workflow package."""
|
|
2
|
+
|
|
3
|
+
from .clients import BaseClient, ComponentClient
|
|
4
|
+
from .compiler import compile_workflow
|
|
5
|
+
from .enums import NodeState, WorkflowEventType
|
|
6
|
+
from .exceptions import ConcurrentClientAccessError
|
|
7
|
+
from .executor import WorkflowExecutor
|
|
8
|
+
from .models import (
|
|
9
|
+
LoopBackSpec,
|
|
10
|
+
NodeConfig,
|
|
11
|
+
NodeExecutionContext,
|
|
12
|
+
NodeRuntime,
|
|
13
|
+
WorkflowEdgeSpec,
|
|
14
|
+
WorkflowExecutionEvent,
|
|
15
|
+
WorkflowNodeSpec,
|
|
16
|
+
WorkflowResult,
|
|
17
|
+
)
|
|
18
|
+
from .platform import Platform
|
|
19
|
+
from .process import Process
|
|
20
|
+
from .quantity import ChemUnitQuantity, ChemQuantityValidator
|
|
21
|
+
from .terminal import WorkflowLogger, configure_terminal_logging, create_run_log_path
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Process",
|
|
25
|
+
"Platform",
|
|
26
|
+
"BaseClient",
|
|
27
|
+
"ComponentClient",
|
|
28
|
+
"ConcurrentClientAccessError",
|
|
29
|
+
"WorkflowExecutor",
|
|
30
|
+
"compile_workflow",
|
|
31
|
+
"NodeConfig",
|
|
32
|
+
"WorkflowNodeSpec",
|
|
33
|
+
"WorkflowEdgeSpec",
|
|
34
|
+
"LoopBackSpec",
|
|
35
|
+
"NodeRuntime",
|
|
36
|
+
"NodeExecutionContext",
|
|
37
|
+
"WorkflowResult",
|
|
38
|
+
"NodeState",
|
|
39
|
+
"WorkflowEventType",
|
|
40
|
+
"WorkflowExecutionEvent",
|
|
41
|
+
"WorkflowLogger",
|
|
42
|
+
"configure_terminal_logging",
|
|
43
|
+
"create_run_log_path",
|
|
44
|
+
"ChemUnitQuantity",
|
|
45
|
+
"ChemQuantityValidator",
|
|
46
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""chemunited_workflow.api — FastAPI application factory."""
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from fastapi.responses import RedirectResponse
|
|
5
|
+
|
|
6
|
+
from .dependencies import get_project_holder
|
|
7
|
+
from .project_holder import ProjectHolder
|
|
8
|
+
from .routers.components import router as components_router
|
|
9
|
+
from .routers.logs import router as logs_router
|
|
10
|
+
from .routers.processes import router as processes_router
|
|
11
|
+
from .routers.project import router as project_router
|
|
12
|
+
from .routers.runner import router as runner_router
|
|
13
|
+
from .routers.snapshots import read_router as snapshots_read_router
|
|
14
|
+
from .routers.snapshots import write_router as snapshots_write_router
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_api() -> FastAPI:
|
|
18
|
+
"""Create and return a configured FastAPI application.
|
|
19
|
+
|
|
20
|
+
The server starts with no project loaded. Use ``PUT /project`` to load a
|
|
21
|
+
project directory at runtime.
|
|
22
|
+
"""
|
|
23
|
+
holder = ProjectHolder()
|
|
24
|
+
|
|
25
|
+
app = FastAPI(title="chemunited API")
|
|
26
|
+
|
|
27
|
+
@app.get("/", include_in_schema=False)
|
|
28
|
+
async def root():
|
|
29
|
+
return RedirectResponse(url="/docs")
|
|
30
|
+
|
|
31
|
+
app.dependency_overrides[get_project_holder] = lambda: holder
|
|
32
|
+
|
|
33
|
+
app.include_router(project_router)
|
|
34
|
+
app.include_router(processes_router)
|
|
35
|
+
app.include_router(snapshots_read_router)
|
|
36
|
+
app.include_router(snapshots_write_router)
|
|
37
|
+
app.include_router(runner_router)
|
|
38
|
+
app.include_router(components_router)
|
|
39
|
+
app.include_router(logs_router)
|
|
40
|
+
|
|
41
|
+
return app
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""FastAPI dependency functions for the chemunited API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException
|
|
6
|
+
|
|
7
|
+
from .project_holder import ProjectHolder
|
|
8
|
+
from .services.protocol import ProtocolService
|
|
9
|
+
from .services.runner import RunnerService
|
|
10
|
+
|
|
11
|
+
_NO_PROJECT_MSG = (
|
|
12
|
+
"No project loaded. Use PUT /project to load a project directory first."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_project_holder() -> ProjectHolder:
|
|
17
|
+
raise NotImplementedError("Dependency not wired — was create_api() called?")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_protocol_service(
|
|
21
|
+
holder: ProjectHolder = Depends(get_project_holder),
|
|
22
|
+
) -> ProtocolService:
|
|
23
|
+
svc = holder.protocol_service
|
|
24
|
+
if svc is None:
|
|
25
|
+
raise HTTPException(status_code=503, detail=_NO_PROJECT_MSG)
|
|
26
|
+
return svc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_runner_service(
|
|
30
|
+
holder: ProjectHolder = Depends(get_project_holder),
|
|
31
|
+
) -> RunnerService:
|
|
32
|
+
svc = holder.runner_service
|
|
33
|
+
if svc is None:
|
|
34
|
+
raise HTTPException(status_code=503, detail=_NO_PROJECT_MSG)
|
|
35
|
+
return svc
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""ProjectHolder — manages the optional active project and its services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from chemunited_workflow.project_loader import ProjectModules
|
|
9
|
+
|
|
10
|
+
from .run_store import RunStore
|
|
11
|
+
from .services.protocol import ProtocolService
|
|
12
|
+
from .services.runner import RunnerService
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProjectHolder:
|
|
16
|
+
"""Thread-safe holder for the currently active project's service instances.
|
|
17
|
+
|
|
18
|
+
The ``RunStore`` is created once at construction and persists across project
|
|
19
|
+
switches so that in-flight run history is not lost.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
self._run_store = RunStore()
|
|
25
|
+
self._project_dir: Path | None = None
|
|
26
|
+
self._protocol_service: ProtocolService | None = None
|
|
27
|
+
self._runner_service: RunnerService | None = None
|
|
28
|
+
|
|
29
|
+
# ── Read accessors ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def project_dir(self) -> Path | None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
return self._project_dir
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def protocol_service(self) -> ProtocolService | None:
|
|
38
|
+
with self._lock:
|
|
39
|
+
return self._protocol_service
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def runner_service(self) -> RunnerService | None:
|
|
43
|
+
with self._lock:
|
|
44
|
+
return self._runner_service
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def run_store(self) -> RunStore:
|
|
48
|
+
return self._run_store
|
|
49
|
+
|
|
50
|
+
def is_loaded(self) -> bool:
|
|
51
|
+
with self._lock:
|
|
52
|
+
return self._project_dir is not None
|
|
53
|
+
|
|
54
|
+
def active_run_id(self) -> str | None:
|
|
55
|
+
return self._run_store.active_run_id
|
|
56
|
+
|
|
57
|
+
# ── Mutation ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def load(self, modules: ProjectModules) -> None:
|
|
60
|
+
"""Replace the active project.
|
|
61
|
+
|
|
62
|
+
Builds fresh ``ProtocolService`` and ``RunnerService`` instances (reusing
|
|
63
|
+
the same ``RunStore``), then swaps them under the lock.
|
|
64
|
+
"""
|
|
65
|
+
new_protocol = ProtocolService(
|
|
66
|
+
project_dir=modules.project_dir,
|
|
67
|
+
processes=modules.processes,
|
|
68
|
+
configs=modules.configs,
|
|
69
|
+
main_parameter_class=modules.main_parameter_class,
|
|
70
|
+
)
|
|
71
|
+
new_runner = RunnerService(
|
|
72
|
+
project_dir=modules.project_dir,
|
|
73
|
+
processes=modules.processes,
|
|
74
|
+
configs=modules.configs,
|
|
75
|
+
run_store=self._run_store,
|
|
76
|
+
)
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._project_dir = modules.project_dir
|
|
79
|
+
self._protocol_service = new_protocol
|
|
80
|
+
self._runner_service = new_runner
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Routes: GET /components."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends
|
|
4
|
+
|
|
5
|
+
from ..dependencies import get_protocol_service
|
|
6
|
+
from ..schemas import ComponentStatus
|
|
7
|
+
from ..services.protocol import ProtocolService
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/components", tags=["components"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/ping", response_model=list[ComponentStatus])
|
|
13
|
+
async def ping_components(
|
|
14
|
+
timeout: float = 2.0,
|
|
15
|
+
svc: ProtocolService = Depends(get_protocol_service),
|
|
16
|
+
):
|
|
17
|
+
"""Verify that all device URLs in ``associations.json`` are reachable."""
|
|
18
|
+
return svc.ping_components(timeout=timeout)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("/")
|
|
22
|
+
async def get_components(svc: ProtocolService = Depends(get_protocol_service)):
|
|
23
|
+
"""Return the device connectivity map.
|
|
24
|
+
|
|
25
|
+
Returns the full contents of `connectivity/associations.json` — the
|
|
26
|
+
mapping of component names to their device-server URLs. Entries with an
|
|
27
|
+
empty `component_url` are included as-is; they represent devices that are
|
|
28
|
+
physically present but not yet wired to a server endpoint.
|
|
29
|
+
"""
|
|
30
|
+
return svc.read_components()
|