chemunited-workflow 0.0.1__py3-none-any.whl
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/__init__.py +46 -0
- chemunited_workflow/api/__init__.py +41 -0
- chemunited_workflow/api/dependencies.py +35 -0
- chemunited_workflow/api/project_holder.py +80 -0
- chemunited_workflow/api/routers/__init__.py +0 -0
- chemunited_workflow/api/routers/components.py +30 -0
- chemunited_workflow/api/routers/logs.py +60 -0
- chemunited_workflow/api/routers/processes.py +53 -0
- chemunited_workflow/api/routers/project.py +53 -0
- chemunited_workflow/api/routers/runner.py +196 -0
- chemunited_workflow/api/routers/snapshots.py +73 -0
- chemunited_workflow/api/run_store.py +79 -0
- chemunited_workflow/api/schemas.py +110 -0
- chemunited_workflow/api/services/__init__.py +0 -0
- chemunited_workflow/api/services/protocol.py +238 -0
- chemunited_workflow/api/services/runner.py +152 -0
- chemunited_workflow/cli.py +114 -0
- chemunited_workflow/clients.py +348 -0
- chemunited_workflow/compiler.py +135 -0
- chemunited_workflow/durations.py +32 -0
- chemunited_workflow/enums.py +28 -0
- chemunited_workflow/exceptions.py +5 -0
- chemunited_workflow/executor.py +548 -0
- chemunited_workflow/mcp/__init__.py +32 -0
- chemunited_workflow/mcp/tools.py +286 -0
- chemunited_workflow/models.py +170 -0
- chemunited_workflow/monitoring.py +5 -0
- chemunited_workflow/platform.py +102 -0
- chemunited_workflow/process.py +169 -0
- chemunited_workflow/project_loader.py +78 -0
- chemunited_workflow/quantity.py +146 -0
- chemunited_workflow/terminal.py +76 -0
- chemunited_workflow-0.0.1.dist-info/METADATA +47 -0
- chemunited_workflow-0.0.1.dist-info/RECORD +38 -0
- chemunited_workflow-0.0.1.dist-info/WHEEL +5 -0
- chemunited_workflow-0.0.1.dist-info/entry_points.txt +2 -0
- chemunited_workflow-0.0.1.dist-info/licenses/LICENSE +21 -0
- chemunited_workflow-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Routes: GET /logs, GET /logs/{filename}."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
|
|
5
|
+
from ..dependencies import get_protocol_service
|
|
6
|
+
from ..schemas import LogSearchResult
|
|
7
|
+
from ..services.protocol import ProtocolService
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/logs", tags=["logs"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/")
|
|
13
|
+
async def list_logs(svc: ProtocolService = Depends(get_protocol_service)):
|
|
14
|
+
"""List all execution log files.
|
|
15
|
+
|
|
16
|
+
Returns metadata (filename, last-modified timestamp, size) for every
|
|
17
|
+
`.log` file in the project's `log/` directory, sorted most-recent first.
|
|
18
|
+
"""
|
|
19
|
+
return svc.list_logs()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/search", response_model=list[LogSearchResult])
|
|
23
|
+
async def search_logs(
|
|
24
|
+
query: str,
|
|
25
|
+
max_results: int = 50,
|
|
26
|
+
svc: ProtocolService = Depends(get_protocol_service),
|
|
27
|
+
):
|
|
28
|
+
"""Search all active log files for lines containing *query* (case-insensitive)."""
|
|
29
|
+
return svc.search_logs(query, max_results=max_results)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/{filename}/archive", status_code=200)
|
|
33
|
+
async def archive_log(
|
|
34
|
+
filename: str,
|
|
35
|
+
svc: ProtocolService = Depends(get_protocol_service),
|
|
36
|
+
):
|
|
37
|
+
"""Move a log file from ``log/`` to ``log/archive/``."""
|
|
38
|
+
try:
|
|
39
|
+
archived_path = svc.archive_log(filename)
|
|
40
|
+
return {"archived": archived_path}
|
|
41
|
+
except FileNotFoundError as exc:
|
|
42
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.get("/{filename}")
|
|
46
|
+
async def read_log(
|
|
47
|
+
filename: str,
|
|
48
|
+
tail: int | None = None,
|
|
49
|
+
svc: ProtocolService = Depends(get_protocol_service),
|
|
50
|
+
):
|
|
51
|
+
"""Read the contents of a log file.
|
|
52
|
+
|
|
53
|
+
Returns the full text of the log. Pass `?tail=N` to return only the last
|
|
54
|
+
N lines — useful for checking recent activity without loading the entire
|
|
55
|
+
file.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
return {"content": svc.read_log(filename, tail=tail)}
|
|
59
|
+
except FileNotFoundError as exc:
|
|
60
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Routes: GET /processes, GET /processes/{name}/schema."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
|
|
5
|
+
from ..dependencies import get_protocol_service
|
|
6
|
+
from ..schemas import ProcessSource
|
|
7
|
+
from ..services.protocol import ProtocolService
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/processes", tags=["processes"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/")
|
|
13
|
+
async def list_processes(svc: ProtocolService = Depends(get_protocol_service)):
|
|
14
|
+
"""List all registered processes.
|
|
15
|
+
|
|
16
|
+
Returns every process available in this experiment together with its
|
|
17
|
+
human-readable description and the JSON Schema of its configuration model.
|
|
18
|
+
Call this first to discover what processes can be added to a snapshot.
|
|
19
|
+
"""
|
|
20
|
+
return svc.list_processes()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/{name}/source", response_model=ProcessSource)
|
|
24
|
+
async def get_process_source(
|
|
25
|
+
name: str,
|
|
26
|
+
svc: ProtocolService = Depends(get_protocol_service),
|
|
27
|
+
):
|
|
28
|
+
"""Return the full source code of a process definition file."""
|
|
29
|
+
try:
|
|
30
|
+
source = svc.read_process(name)
|
|
31
|
+
return ProcessSource(name=name, source=source)
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
raise HTTPException(status_code=400, detail=str(exc))
|
|
34
|
+
except FileNotFoundError as exc:
|
|
35
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/{name}/schema")
|
|
39
|
+
async def get_process_schema(
|
|
40
|
+
name: str,
|
|
41
|
+
svc: ProtocolService = Depends(get_protocol_service),
|
|
42
|
+
):
|
|
43
|
+
"""Return the full parameter schema for a single process.
|
|
44
|
+
|
|
45
|
+
Includes the `config_schema` (process-specific parameters) and the
|
|
46
|
+
`main_parameter_schema` (experiment-level parameters shared across all
|
|
47
|
+
processes). Each field may carry `group`, `editable`, and `visible` hints
|
|
48
|
+
inside `json_schema_extra`.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
return svc.get_process_schema(name)
|
|
52
|
+
except KeyError as exc:
|
|
53
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Routes: GET /project, PUT /project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
8
|
+
|
|
9
|
+
from chemunited_workflow.project_loader import ProjectLoadError, load_project
|
|
10
|
+
|
|
11
|
+
from ..dependencies import get_project_holder
|
|
12
|
+
from ..project_holder import ProjectHolder
|
|
13
|
+
from ..schemas import ProjectIn, ProjectOut
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/project", tags=["project"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/", response_model=ProjectOut)
|
|
19
|
+
async def get_project(holder: ProjectHolder = Depends(get_project_holder)):
|
|
20
|
+
"""Return the currently loaded project directory, or null if none is loaded.
|
|
21
|
+
|
|
22
|
+
Always returns 200 — use this as a readiness probe.
|
|
23
|
+
"""
|
|
24
|
+
pd = holder.project_dir
|
|
25
|
+
return ProjectOut(project_dir=str(pd) if pd is not None else None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.put("/", response_model=ProjectOut)
|
|
29
|
+
async def put_project(
|
|
30
|
+
body: ProjectIn,
|
|
31
|
+
holder: ProjectHolder = Depends(get_project_holder),
|
|
32
|
+
):
|
|
33
|
+
"""Load or switch the active project.
|
|
34
|
+
|
|
35
|
+
Rejects with 409 if a run is currently active. The RunStore (and all
|
|
36
|
+
historical run records) is preserved across switches.
|
|
37
|
+
"""
|
|
38
|
+
active = holder.active_run_id()
|
|
39
|
+
if active is not None:
|
|
40
|
+
raise HTTPException(
|
|
41
|
+
status_code=409,
|
|
42
|
+
detail=f"Cannot switch project while run '{active}' is active.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
project_path = Path(body.project_dir).resolve()
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
modules = load_project(project_path)
|
|
49
|
+
except ProjectLoadError as exc:
|
|
50
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
51
|
+
|
|
52
|
+
holder.load(modules)
|
|
53
|
+
return ProjectOut(project_dir=str(holder.project_dir))
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Routes: POST/GET/DELETE /run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
11
|
+
from fastapi.responses import StreamingResponse
|
|
12
|
+
|
|
13
|
+
from ..dependencies import get_runner_service
|
|
14
|
+
from ..run_store import RunState
|
|
15
|
+
from ..schemas import RunRequest, RunStatus
|
|
16
|
+
from ..services.runner import RunnerService
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/run", tags=["run"])
|
|
19
|
+
STREAM_POLL_INTERVAL_SECONDS = 0.1
|
|
20
|
+
STREAM_HEARTBEAT_INTERVAL_SECONDS = 5.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.post("/", status_code=202)
|
|
24
|
+
async def start_run(
|
|
25
|
+
body: RunRequest,
|
|
26
|
+
svc: RunnerService = Depends(get_runner_service),
|
|
27
|
+
):
|
|
28
|
+
"""Start executing a protocol snapshot.
|
|
29
|
+
|
|
30
|
+
Launches execution in a background thread and returns a `run_id`
|
|
31
|
+
immediately (HTTP 202 Accepted). Pass `dry_run: true` to suppress all
|
|
32
|
+
HTTP calls to physical devices — the workflow graph and node logic run
|
|
33
|
+
normally but every device call returns a synthetic `200 OK` with an empty
|
|
34
|
+
body. `timeout_commands` controls feedback polling timeout. Use the
|
|
35
|
+
returned `run_id` to poll status or stream events.
|
|
36
|
+
"""
|
|
37
|
+
run_id = svc.start(
|
|
38
|
+
body.snapshot,
|
|
39
|
+
dry_run=body.dry_run,
|
|
40
|
+
timeout_commands=body.timeout_commands,
|
|
41
|
+
error_resilient=body.error_resilient,
|
|
42
|
+
)
|
|
43
|
+
return {"run_id": run_id}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.get("/active")
|
|
47
|
+
async def get_active_run(svc: RunnerService = Depends(get_runner_service)):
|
|
48
|
+
"""Return the run_id of the currently running execution, if any.
|
|
49
|
+
|
|
50
|
+
Returns `{"run_id": null}` when no execution is in progress.
|
|
51
|
+
"""
|
|
52
|
+
return {"run_id": svc._run_store.active_run_id}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/{run_id}/status")
|
|
56
|
+
async def get_run_status(
|
|
57
|
+
run_id: str,
|
|
58
|
+
svc: RunnerService = Depends(get_runner_service),
|
|
59
|
+
):
|
|
60
|
+
"""Poll the status of a run.
|
|
61
|
+
|
|
62
|
+
Returns the current state (`running`, `finished`, `failed`, or
|
|
63
|
+
`cancelled`) and all `WorkflowExecutionEvent` objects accumulated since
|
|
64
|
+
the last call. Events are cleared on each read — call repeatedly until
|
|
65
|
+
state is terminal. For a continuous feed, use the `/stream` endpoint
|
|
66
|
+
instead.
|
|
67
|
+
"""
|
|
68
|
+
rec = svc._run_store.get(run_id)
|
|
69
|
+
if rec is None:
|
|
70
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found.")
|
|
71
|
+
events = svc._run_store.pop_events(run_id)
|
|
72
|
+
return RunStatus(
|
|
73
|
+
run_id=run_id,
|
|
74
|
+
state=rec.state.value,
|
|
75
|
+
events=[e.model_dump() for e in events],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.get("/{run_id}/report")
|
|
80
|
+
async def get_run_report(
|
|
81
|
+
run_id: str,
|
|
82
|
+
svc: RunnerService = Depends(get_runner_service),
|
|
83
|
+
):
|
|
84
|
+
"""Return the full execution report for a finished run.
|
|
85
|
+
|
|
86
|
+
Returns one `WorkflowResult` per process step in execution order,
|
|
87
|
+
containing node states, results, runtimes, and any errors. Returns
|
|
88
|
+
HTTP 202 if the run has not finished yet.
|
|
89
|
+
"""
|
|
90
|
+
rec = svc._run_store.get(run_id)
|
|
91
|
+
if rec is None:
|
|
92
|
+
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found.")
|
|
93
|
+
if rec.state == RunState.RUNNING:
|
|
94
|
+
raise HTTPException(status_code=202, detail="Run has not finished yet.")
|
|
95
|
+
return {
|
|
96
|
+
"run_id": run_id,
|
|
97
|
+
"state": rec.state.value,
|
|
98
|
+
"results": [r.model_dump() for r in rec.results],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.get("/{run_id}/stream")
|
|
103
|
+
async def stream_run(
|
|
104
|
+
run_id: str,
|
|
105
|
+
svc: RunnerService = Depends(get_runner_service),
|
|
106
|
+
):
|
|
107
|
+
"""Stream execution events as Server-Sent Events (SSE).
|
|
108
|
+
|
|
109
|
+
Keeps the connection open while the run is active and pushes each
|
|
110
|
+
`WorkflowExecutionEvent` as it arrives. Closes with a final
|
|
111
|
+
`{"state": "finished"|"failed"|"cancelled"}` frame when the run ends.
|
|
112
|
+
For simple polling without a persistent connection, use `/status` instead.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
return StreamingResponse(
|
|
116
|
+
_generate_run_stream(svc, run_id),
|
|
117
|
+
media_type="text/event-stream",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def _generate_run_stream(
|
|
122
|
+
svc: RunnerService,
|
|
123
|
+
run_id: str,
|
|
124
|
+
*,
|
|
125
|
+
poll_interval: float = STREAM_POLL_INTERVAL_SECONDS,
|
|
126
|
+
heartbeat_interval: float = STREAM_HEARTBEAT_INTERVAL_SECONDS,
|
|
127
|
+
) -> AsyncIterator[str]:
|
|
128
|
+
rec = svc._run_store.get(run_id)
|
|
129
|
+
if rec is None:
|
|
130
|
+
yield 'data: {"error": "run not found"}\n\n'
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
last_sent = time.monotonic()
|
|
134
|
+
while rec.state == RunState.RUNNING:
|
|
135
|
+
sent_event = False
|
|
136
|
+
for event in svc._run_store.pop_events(run_id):
|
|
137
|
+
yield f"data: {event.model_dump_json()}\n\n"
|
|
138
|
+
sent_event = True
|
|
139
|
+
|
|
140
|
+
now = time.monotonic()
|
|
141
|
+
if sent_event:
|
|
142
|
+
last_sent = now
|
|
143
|
+
elif rec.state == RunState.RUNNING and now - last_sent >= heartbeat_interval:
|
|
144
|
+
yield ": heartbeat\n\n"
|
|
145
|
+
last_sent = now
|
|
146
|
+
|
|
147
|
+
await asyncio.sleep(poll_interval)
|
|
148
|
+
|
|
149
|
+
yield f'data: {{"state": "{rec.state.value}"}}\n\n'
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.get("/pool")
|
|
153
|
+
async def drain_pool(svc: RunnerService = Depends(get_runner_service)):
|
|
154
|
+
"""Return all pending device commands and delete their files.
|
|
155
|
+
|
|
156
|
+
Reads every ``*.jsonl`` file under ``log/pool/``, collects every line,
|
|
157
|
+
deletes the files, and returns the full list. Returns an empty list when
|
|
158
|
+
no commands have been issued since the last poll.
|
|
159
|
+
|
|
160
|
+
Poll this endpoint at a comfortable interval (e.g. every 500 ms) while a
|
|
161
|
+
run is active to display live device activity in the UI.
|
|
162
|
+
"""
|
|
163
|
+
pool_dir = svc._project_dir / "log" / "pool"
|
|
164
|
+
if not pool_dir.exists():
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
commands = []
|
|
168
|
+
for f in pool_dir.glob("*.jsonl"):
|
|
169
|
+
try:
|
|
170
|
+
for line in f.read_text(encoding="utf-8").splitlines():
|
|
171
|
+
line = line.strip()
|
|
172
|
+
if line:
|
|
173
|
+
commands.append(json.loads(line))
|
|
174
|
+
f.unlink()
|
|
175
|
+
except (OSError, json.JSONDecodeError):
|
|
176
|
+
pass
|
|
177
|
+
return commands
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@router.delete("/{run_id}", status_code=204)
|
|
181
|
+
async def cancel_run(
|
|
182
|
+
run_id: str,
|
|
183
|
+
svc: RunnerService = Depends(get_runner_service),
|
|
184
|
+
):
|
|
185
|
+
"""Cancel an active run.
|
|
186
|
+
|
|
187
|
+
Sets the run state to `cancelled`. The currently executing process step
|
|
188
|
+
will run to completion before the runner stops — this is intentional to
|
|
189
|
+
avoid leaving physical devices in an undefined state mid-operation.
|
|
190
|
+
Returns 404 if the run does not exist or is already in a terminal state.
|
|
191
|
+
"""
|
|
192
|
+
if not svc._run_store.cancel(run_id):
|
|
193
|
+
raise HTTPException(
|
|
194
|
+
status_code=404,
|
|
195
|
+
detail=f"Run '{run_id}' not found or not running.",
|
|
196
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Routes: GET/POST/DELETE /snapshots."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
|
|
5
|
+
from ..dependencies import get_protocol_service
|
|
6
|
+
from ..schemas import SnapshotIn
|
|
7
|
+
from ..services.protocol import ProtocolService
|
|
8
|
+
|
|
9
|
+
read_router = APIRouter(prefix="/snapshots", tags=["snapshots"])
|
|
10
|
+
write_router = APIRouter(prefix="/snapshots", tags=["snapshots"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@read_router.get("/")
|
|
14
|
+
async def list_snapshots(svc: ProtocolService = Depends(get_protocol_service)):
|
|
15
|
+
"""List all protocol snapshots.
|
|
16
|
+
|
|
17
|
+
Returns metadata (filename, last-modified timestamp, size) for every JSON
|
|
18
|
+
file in `protocols_hystoric/`, sorted most-recent first. Use the filename
|
|
19
|
+
from this list to start a run or inspect a snapshot's contents.
|
|
20
|
+
"""
|
|
21
|
+
return svc.list_snapshots()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@read_router.get("/{filename}")
|
|
25
|
+
async def get_snapshot(
|
|
26
|
+
filename: str,
|
|
27
|
+
svc: ProtocolService = Depends(get_protocol_service),
|
|
28
|
+
):
|
|
29
|
+
"""Read a single snapshot by filename.
|
|
30
|
+
|
|
31
|
+
Returns the full JSON content of the snapshot — `main_parameter` plus one
|
|
32
|
+
key per process step in `{process_name}_{index}` format. The insertion
|
|
33
|
+
order of the process keys defines the execution sequence.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
return svc.read_snapshot(filename)
|
|
37
|
+
except FileNotFoundError as exc:
|
|
38
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@write_router.post("/", status_code=201)
|
|
42
|
+
async def create_snapshot(
|
|
43
|
+
body: SnapshotIn,
|
|
44
|
+
svc: ProtocolService = Depends(get_protocol_service),
|
|
45
|
+
):
|
|
46
|
+
"""Save a new protocol snapshot.
|
|
47
|
+
|
|
48
|
+
Validates all process configs and the `main_parameter` block, then writes
|
|
49
|
+
a timestamped JSON file to `protocols_hystoric/`. Every call creates a
|
|
50
|
+
**new** file — snapshots are immutable once written. Returns the generated
|
|
51
|
+
filename, e.g. `suzuki_batch_14_2026-05-15T10-38-00.json`.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
filename = svc.write_snapshot(body.name, body.data)
|
|
55
|
+
return {"filename": filename}
|
|
56
|
+
except (ValueError, Exception) as exc:
|
|
57
|
+
raise HTTPException(status_code=422, detail=str(exc))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@write_router.delete("/{filename}", status_code=204)
|
|
61
|
+
async def delete_snapshot(
|
|
62
|
+
filename: str,
|
|
63
|
+
svc: ProtocolService = Depends(get_protocol_service),
|
|
64
|
+
):
|
|
65
|
+
"""Delete a snapshot file.
|
|
66
|
+
|
|
67
|
+
Permanently removes the file from `protocols_hystoric/`. This action is
|
|
68
|
+
irreversible. Only available in builder mode (`enable_builder=True`).
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
svc.delete_snapshot(filename)
|
|
72
|
+
except FileNotFoundError as exc:
|
|
73
|
+
raise HTTPException(status_code=404, detail=str(exc))
|