arp-jarvis-runstore 0.3.5__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Agent Runtime Protocol
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,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: arp-jarvis-runstore
3
+ Version: 0.3.5
4
+ Summary: JARVIS Run Store internal service for Run and NodeRun persistence.
5
+ Author: Agent Runtime Protocol
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: fastapi>=0.110.0
11
+ Requires-Dist: pydantic>=2.6.0
12
+ Requires-Dist: arp-standard-model==0.3.5
13
+ Requires-Dist: arp-standard-server==0.3.5
14
+ Requires-Dist: uvicorn>=0.29.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pyright>=1.1.0; extra == "dev"
17
+ Requires-Dist: pytest>=7; extra == "dev"
18
+ Requires-Dist: pytest-cov>=4; extra == "dev"
19
+ Requires-Dist: httpx>=0.23.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # JARVIS Run Store
23
+
24
+ Internal JARVIS service that persists `Run` and `NodeRun` state for the Coordinator.
25
+ This is a JARVIS-only contract (not part of the ARP Standard).
26
+
27
+ ## Requirements
28
+
29
+ - Python >= 3.11
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ python3 -m pip install -e .
35
+ ```
36
+
37
+ ## Run
38
+
39
+ ```bash
40
+ python3 -m pip install -e .
41
+ arp-jarvis-runstore
42
+ ```
43
+
44
+ > [!TIP]
45
+ > Use `bash src/scripts/dev_server.sh --host ... --port ... --reload` for dev convenience.
46
+
47
+ ## Configuration
48
+
49
+ Environment variables:
50
+ - `JARVIS_RUN_STORE_DB_URL` (default `sqlite:///./runs/jarvis_run_store.sqlite`)
51
+ - `JARVIS_RUN_STORE_MAX_SIZE_MB` (optional guardrail)
52
+ - `ARP_AUTH_*` (JWT auth settings, shared across JARVIS services)
53
+
54
+ Auth is enabled by default (JWT). To disable for local dev, set `ARP_AUTH_PROFILE=dev-insecure`
55
+ or `ARP_AUTH_MODE=disabled`. Health/version endpoints are always exempt.
56
+ If no `ARP_AUTH_*` env vars are set, the service defaults to the dev Keycloak issuer.
57
+
58
+ ## API (v0.3.5)
59
+
60
+ Health/version:
61
+ - `GET /v1/health`
62
+ - `GET /v1/version`
63
+
64
+ Runs:
65
+ - `POST /v1/runs` -> `{ run: Run }`
66
+ - `GET /v1/runs/{run_id}`
67
+ - `PUT /v1/runs/{run_id}` -> `{ run: Run }`
68
+
69
+ NodeRuns:
70
+ - `POST /v1/node-runs` -> `{ node_run: NodeRun }`
71
+ - `GET /v1/node-runs/{node_run_id}`
72
+ - `PUT /v1/node-runs/{node_run_id}` -> `{ node_run: NodeRun }`
73
+ - `GET /v1/runs/{run_id}/node-runs?limit=100&page_token=...`
74
+
75
+ Idempotency:
76
+ - `POST` endpoints accept `idempotency_key` and will return the existing record if the key matches.
77
+
78
+ ## Notes
79
+
80
+ - The store is owned by the Coordinator; no cross-component DB access.
81
+ - Uses SQLite by default for v0.3.5.
@@ -0,0 +1,60 @@
1
+ # JARVIS Run Store
2
+
3
+ Internal JARVIS service that persists `Run` and `NodeRun` state for the Coordinator.
4
+ This is a JARVIS-only contract (not part of the ARP Standard).
5
+
6
+ ## Requirements
7
+
8
+ - Python >= 3.11
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ python3 -m pip install -e .
14
+ ```
15
+
16
+ ## Run
17
+
18
+ ```bash
19
+ python3 -m pip install -e .
20
+ arp-jarvis-runstore
21
+ ```
22
+
23
+ > [!TIP]
24
+ > Use `bash src/scripts/dev_server.sh --host ... --port ... --reload` for dev convenience.
25
+
26
+ ## Configuration
27
+
28
+ Environment variables:
29
+ - `JARVIS_RUN_STORE_DB_URL` (default `sqlite:///./runs/jarvis_run_store.sqlite`)
30
+ - `JARVIS_RUN_STORE_MAX_SIZE_MB` (optional guardrail)
31
+ - `ARP_AUTH_*` (JWT auth settings, shared across JARVIS services)
32
+
33
+ Auth is enabled by default (JWT). To disable for local dev, set `ARP_AUTH_PROFILE=dev-insecure`
34
+ or `ARP_AUTH_MODE=disabled`. Health/version endpoints are always exempt.
35
+ If no `ARP_AUTH_*` env vars are set, the service defaults to the dev Keycloak issuer.
36
+
37
+ ## API (v0.3.5)
38
+
39
+ Health/version:
40
+ - `GET /v1/health`
41
+ - `GET /v1/version`
42
+
43
+ Runs:
44
+ - `POST /v1/runs` -> `{ run: Run }`
45
+ - `GET /v1/runs/{run_id}`
46
+ - `PUT /v1/runs/{run_id}` -> `{ run: Run }`
47
+
48
+ NodeRuns:
49
+ - `POST /v1/node-runs` -> `{ node_run: NodeRun }`
50
+ - `GET /v1/node-runs/{node_run_id}`
51
+ - `PUT /v1/node-runs/{node_run_id}` -> `{ node_run: NodeRun }`
52
+ - `GET /v1/runs/{run_id}/node-runs?limit=100&page_token=...`
53
+
54
+ Idempotency:
55
+ - `POST` endpoints accept `idempotency_key` and will return the existing record if the key matches.
56
+
57
+ ## Notes
58
+
59
+ - The store is owned by the Coordinator; no cross-component DB access.
60
+ - Uses SQLite by default for v0.3.5.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=70", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "arp-jarvis-runstore"
7
+ version = "0.3.5"
8
+ description = "JARVIS Run Store internal service for Run and NodeRun persistence."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Agent Runtime Protocol" }]
14
+ dependencies = [
15
+ "fastapi>=0.110.0",
16
+ "pydantic>=2.6.0",
17
+ "arp-standard-model==0.3.5",
18
+ "arp-standard-server==0.3.5",
19
+ "uvicorn>=0.29.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pyright>=1.1.0",
25
+ "pytest>=7",
26
+ "pytest-cov>=4",
27
+ "httpx>=0.23.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ arp-jarvis-runstore = "jarvis_run_store.__main__:main"
32
+
33
+ [tool.setuptools]
34
+ package-dir = { "" = "src" }
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: arp-jarvis-runstore
3
+ Version: 0.3.5
4
+ Summary: JARVIS Run Store internal service for Run and NodeRun persistence.
5
+ Author: Agent Runtime Protocol
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: fastapi>=0.110.0
11
+ Requires-Dist: pydantic>=2.6.0
12
+ Requires-Dist: arp-standard-model==0.3.5
13
+ Requires-Dist: arp-standard-server==0.3.5
14
+ Requires-Dist: uvicorn>=0.29.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pyright>=1.1.0; extra == "dev"
17
+ Requires-Dist: pytest>=7; extra == "dev"
18
+ Requires-Dist: pytest-cov>=4; extra == "dev"
19
+ Requires-Dist: httpx>=0.23.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # JARVIS Run Store
23
+
24
+ Internal JARVIS service that persists `Run` and `NodeRun` state for the Coordinator.
25
+ This is a JARVIS-only contract (not part of the ARP Standard).
26
+
27
+ ## Requirements
28
+
29
+ - Python >= 3.11
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ python3 -m pip install -e .
35
+ ```
36
+
37
+ ## Run
38
+
39
+ ```bash
40
+ python3 -m pip install -e .
41
+ arp-jarvis-runstore
42
+ ```
43
+
44
+ > [!TIP]
45
+ > Use `bash src/scripts/dev_server.sh --host ... --port ... --reload` for dev convenience.
46
+
47
+ ## Configuration
48
+
49
+ Environment variables:
50
+ - `JARVIS_RUN_STORE_DB_URL` (default `sqlite:///./runs/jarvis_run_store.sqlite`)
51
+ - `JARVIS_RUN_STORE_MAX_SIZE_MB` (optional guardrail)
52
+ - `ARP_AUTH_*` (JWT auth settings, shared across JARVIS services)
53
+
54
+ Auth is enabled by default (JWT). To disable for local dev, set `ARP_AUTH_PROFILE=dev-insecure`
55
+ or `ARP_AUTH_MODE=disabled`. Health/version endpoints are always exempt.
56
+ If no `ARP_AUTH_*` env vars are set, the service defaults to the dev Keycloak issuer.
57
+
58
+ ## API (v0.3.5)
59
+
60
+ Health/version:
61
+ - `GET /v1/health`
62
+ - `GET /v1/version`
63
+
64
+ Runs:
65
+ - `POST /v1/runs` -> `{ run: Run }`
66
+ - `GET /v1/runs/{run_id}`
67
+ - `PUT /v1/runs/{run_id}` -> `{ run: Run }`
68
+
69
+ NodeRuns:
70
+ - `POST /v1/node-runs` -> `{ node_run: NodeRun }`
71
+ - `GET /v1/node-runs/{node_run_id}`
72
+ - `PUT /v1/node-runs/{node_run_id}` -> `{ node_run: NodeRun }`
73
+ - `GET /v1/runs/{run_id}/node-runs?limit=100&page_token=...`
74
+
75
+ Idempotency:
76
+ - `POST` endpoints accept `idempotency_key` and will return the existing record if the key matches.
77
+
78
+ ## Notes
79
+
80
+ - The store is owned by the Coordinator; no cross-component DB access.
81
+ - Uses SQLite by default for v0.3.5.
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/arp_jarvis_runstore.egg-info/PKG-INFO
5
+ src/arp_jarvis_runstore.egg-info/SOURCES.txt
6
+ src/arp_jarvis_runstore.egg-info/dependency_links.txt
7
+ src/arp_jarvis_runstore.egg-info/entry_points.txt
8
+ src/arp_jarvis_runstore.egg-info/requires.txt
9
+ src/arp_jarvis_runstore.egg-info/top_level.txt
10
+ src/jarvis_run_store/__init__.py
11
+ src/jarvis_run_store/__main__.py
12
+ src/jarvis_run_store/app.py
13
+ src/jarvis_run_store/config.py
14
+ src/jarvis_run_store/errors.py
15
+ src/jarvis_run_store/service.py
16
+ src/jarvis_run_store/sqlite.py
17
+ src/jarvis_run_store/utils.py
18
+ tests/test_cli.py
19
+ tests/test_run_store_errors.py
20
+ tests/test_run_store_smoke.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ arp-jarvis-runstore = jarvis_run_store.__main__:main
@@ -0,0 +1,11 @@
1
+ fastapi>=0.110.0
2
+ pydantic>=2.6.0
3
+ arp-standard-model==0.3.5
4
+ arp-standard-server==0.3.5
5
+ uvicorn>=0.29.0
6
+
7
+ [dev]
8
+ pyright>=1.1.0
9
+ pytest>=7
10
+ pytest-cov>=4
11
+ httpx>=0.23.0
@@ -0,0 +1,2 @@
1
+ jarvis_run_store
2
+ scripts
@@ -0,0 +1 @@
1
+ __version__ = "0.3.5"
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import uvicorn
5
+
6
+
7
+ def main() -> None:
8
+ parser = argparse.ArgumentParser(description="Start the JARVIS Run Store server.")
9
+ parser.add_argument("--host", default="127.0.0.1")
10
+ parser.add_argument("--port", type=int, default=8091)
11
+ parser.add_argument("--reload", action="store_true", help="Enable auto-reload (dev only).")
12
+ args = parser.parse_args()
13
+
14
+ if args.reload:
15
+ uvicorn.run("jarvis_run_store.app:app", host=args.host, port=args.port, reload=True)
16
+ return
17
+
18
+ from .app import create_app
19
+
20
+ app = create_app()
21
+ uvicorn.run(app, host=args.host, port=args.port, reload=False)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from .service import create_app
4
+
5
+ app = create_app()
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class RunStoreConfig:
10
+ db_url: str
11
+ max_size_mb: int | None
12
+
13
+ @property
14
+ def db_path(self) -> Path:
15
+ return Path(_sqlite_path(self.db_url))
16
+
17
+
18
+ def run_store_config_from_env() -> RunStoreConfig:
19
+ db_url = os.getenv("JARVIS_RUN_STORE_DB_URL", "sqlite:///./runs/jarvis_run_store.sqlite")
20
+ max_size_raw = os.getenv("JARVIS_RUN_STORE_MAX_SIZE_MB")
21
+ max_size = int(max_size_raw) if max_size_raw else None
22
+ return RunStoreConfig(db_url=db_url, max_size_mb=max_size)
23
+
24
+
25
+ def _sqlite_path(db_url: str) -> str:
26
+ prefix = "sqlite:///"
27
+ if not db_url.startswith(prefix):
28
+ raise ValueError("Only sqlite:/// URLs are supported for JARVIS Run Store.")
29
+ path = db_url[len(prefix) :]
30
+ if not path:
31
+ raise ValueError("SQLite URL must include a file path.")
32
+ return path
@@ -0,0 +1,14 @@
1
+ class RunStoreError(Exception):
2
+ pass
3
+
4
+
5
+ class NotFoundError(RunStoreError):
6
+ pass
7
+
8
+
9
+ class ConflictError(RunStoreError):
10
+ pass
11
+
12
+
13
+ class StorageFullError(RunStoreError):
14
+ pass
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Annotated
5
+ from datetime import datetime, timezone
6
+
7
+ from arp_standard_model import Health, NodeRun, Run, Status, VersionInfo
8
+ from arp_standard_server import AuthSettings
9
+ from arp_standard_server.auth import register_auth_middleware
10
+ from fastapi import FastAPI, HTTPException, Query
11
+ from pydantic import BaseModel
12
+
13
+ from . import __version__
14
+ from .config import RunStoreConfig, run_store_config_from_env
15
+ from .errors import ConflictError, NotFoundError, StorageFullError
16
+ from .sqlite import ListNodeRunsResult, SqliteRunStore
17
+ from .utils import (
18
+ auth_settings_from_env_or_dev_secure,
19
+ decode_page_token,
20
+ encode_page_token,
21
+ now,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class CreateRunRequest(BaseModel):
28
+ run: Run
29
+ idempotency_key: str | None = None
30
+
31
+
32
+ class RunResponse(BaseModel):
33
+ run: Run
34
+
35
+
36
+ class CreateNodeRunRequest(BaseModel):
37
+ node_run: NodeRun
38
+ idempotency_key: str | None = None
39
+
40
+
41
+ class NodeRunResponse(BaseModel):
42
+ node_run: NodeRun
43
+
44
+
45
+ class ListNodeRunsResponse(BaseModel):
46
+ items: list[NodeRun]
47
+ next_token: str | None = None
48
+
49
+
50
+ def create_app(
51
+ config: RunStoreConfig | None = None,
52
+ auth_settings: AuthSettings | None = None,
53
+ ) -> FastAPI:
54
+ cfg = config or run_store_config_from_env()
55
+ store = SqliteRunStore(cfg)
56
+ logger.info("Run Store config (db_path=%s, max_size_mb=%s)", cfg.db_path, cfg.max_size_mb)
57
+
58
+ app = FastAPI(title="JARVIS Run Store", version=__version__)
59
+ auth_settings = auth_settings or auth_settings_from_env_or_dev_secure()
60
+ logger.info(
61
+ "Run Store auth settings (mode=%s, issuer=%s)",
62
+ getattr(auth_settings, "mode", None),
63
+ getattr(auth_settings, "issuer", None),
64
+ )
65
+ register_auth_middleware(app, settings=auth_settings)
66
+
67
+ @app.get("/v1/health", response_model=Health)
68
+ async def health() -> Health:
69
+ return Health(status=Status.ok, time=datetime.now(timezone.utc))
70
+
71
+ @app.get("/v1/version", response_model=VersionInfo)
72
+ async def version() -> VersionInfo:
73
+ return VersionInfo(
74
+ service_name="arp-jarvis-runstore",
75
+ service_version=__version__,
76
+ supported_api_versions=["v1"],
77
+ )
78
+
79
+ @app.post("/v1/runs", response_model=RunResponse)
80
+ async def create_run(request: CreateRunRequest) -> RunResponse:
81
+ logger.info(
82
+ "Run create requested (run_id=%s, idempotency=%s)",
83
+ request.run.run_id,
84
+ bool(request.idempotency_key),
85
+ )
86
+ try:
87
+ run = store.create_run(request.run, idempotency_key=request.idempotency_key)
88
+ except ConflictError as exc:
89
+ logger.warning("Run create conflict (run_id=%s)", request.run.run_id)
90
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
91
+ except StorageFullError as exc:
92
+ logger.warning("Run store full (run_id=%s)", request.run.run_id)
93
+ raise HTTPException(status_code=507, detail=str(exc)) from exc
94
+ logger.info("Run created (run_id=%s, state=%s)", run.run_id, run.state)
95
+ return RunResponse(run=run)
96
+
97
+ @app.get("/v1/runs/{run_id}", response_model=RunResponse)
98
+ async def get_run(run_id: str) -> RunResponse:
99
+ logger.info("Run fetch requested (run_id=%s)", run_id)
100
+ try:
101
+ run = store.get_run(run_id)
102
+ except NotFoundError as exc:
103
+ logger.warning("Run not found (run_id=%s)", run_id)
104
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
105
+ return RunResponse(run=run)
106
+
107
+ @app.put("/v1/runs/{run_id}", response_model=RunResponse)
108
+ async def update_run(run_id: str, request: RunResponse) -> RunResponse:
109
+ logger.info("Run update requested (run_id=%s)", run_id)
110
+ try:
111
+ run = store.update_run(run_id, request.run)
112
+ except NotFoundError as exc:
113
+ logger.warning("Run update missing (run_id=%s)", run_id)
114
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
115
+ except ConflictError as exc:
116
+ logger.warning("Run update conflict (run_id=%s)", run_id)
117
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
118
+ logger.info("Run updated (run_id=%s, state=%s)", run.run_id, run.state)
119
+ return RunResponse(run=run)
120
+
121
+ @app.post("/v1/node-runs", response_model=NodeRunResponse)
122
+ async def create_node_run(request: CreateNodeRunRequest) -> NodeRunResponse:
123
+ logger.info(
124
+ "NodeRun create requested (node_run_id=%s, run_id=%s, idempotency=%s)",
125
+ request.node_run.node_run_id,
126
+ request.node_run.run_id,
127
+ bool(request.idempotency_key),
128
+ )
129
+ try:
130
+ node_run = store.create_node_run(request.node_run, idempotency_key=request.idempotency_key)
131
+ except ConflictError as exc:
132
+ logger.warning("NodeRun create conflict (node_run_id=%s)", request.node_run.node_run_id)
133
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
134
+ except StorageFullError as exc:
135
+ logger.warning("NodeRun store full (node_run_id=%s)", request.node_run.node_run_id)
136
+ raise HTTPException(status_code=507, detail=str(exc)) from exc
137
+ logger.info("NodeRun created (node_run_id=%s, state=%s)", node_run.node_run_id, node_run.state)
138
+ return NodeRunResponse(node_run=node_run)
139
+
140
+ @app.get("/v1/node-runs/{node_run_id}", response_model=NodeRunResponse)
141
+ async def get_node_run(node_run_id: str) -> NodeRunResponse:
142
+ logger.info("NodeRun fetch requested (node_run_id=%s)", node_run_id)
143
+ try:
144
+ node_run = store.get_node_run(node_run_id)
145
+ except NotFoundError as exc:
146
+ logger.warning("NodeRun not found (node_run_id=%s)", node_run_id)
147
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
148
+ return NodeRunResponse(node_run=node_run)
149
+
150
+ @app.put("/v1/node-runs/{node_run_id}", response_model=NodeRunResponse)
151
+ async def update_node_run(node_run_id: str, request: NodeRunResponse) -> NodeRunResponse:
152
+ logger.info("NodeRun update requested (node_run_id=%s)", node_run_id)
153
+ try:
154
+ node_run = store.update_node_run(node_run_id, request.node_run)
155
+ except NotFoundError as exc:
156
+ logger.warning("NodeRun update missing (node_run_id=%s)", node_run_id)
157
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
158
+ except ConflictError as exc:
159
+ logger.warning("NodeRun update conflict (node_run_id=%s)", node_run_id)
160
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
161
+ logger.info("NodeRun updated (node_run_id=%s, state=%s)", node_run.node_run_id, node_run.state)
162
+ return NodeRunResponse(node_run=node_run)
163
+
164
+ @app.get("/v1/runs/{run_id}/node-runs", response_model=ListNodeRunsResponse)
165
+ async def list_node_runs(
166
+ run_id: str,
167
+ limit: Annotated[int, Query(ge=1, le=500)] = 100,
168
+ page_token: str | None = None,
169
+ ) -> ListNodeRunsResponse:
170
+ logger.info("NodeRun list requested (run_id=%s, limit=%s, page_token=%s)", run_id, limit, bool(page_token))
171
+ if page_token:
172
+ try:
173
+ offset = decode_page_token(page_token)
174
+ except ValueError as exc:
175
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
176
+ else:
177
+ offset = 0
178
+ result: ListNodeRunsResult = store.list_node_runs(run_id, limit=limit, offset=offset)
179
+ next_token = encode_page_token(result.next_offset) if result.next_offset is not None else None
180
+ logger.info(
181
+ "NodeRun list resolved (run_id=%s, count=%s, next_token=%s)",
182
+ run_id,
183
+ len(result.items),
184
+ bool(next_token),
185
+ )
186
+ return ListNodeRunsResponse(items=result.items, next_token=next_token)
187
+
188
+ return app
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass
7
+ from collections.abc import Sequence
8
+ from typing import Iterator
9
+
10
+ from arp_standard_model import NodeRun, Run
11
+
12
+ from .config import RunStoreConfig
13
+ from .errors import ConflictError, NotFoundError, StorageFullError
14
+ from .utils import now
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ListNodeRunsResult:
19
+ items: list[NodeRun]
20
+ next_offset: int | None
21
+
22
+
23
+ class SqliteRunStore:
24
+ def __init__(self, config: RunStoreConfig) -> None:
25
+ self._db_path = config.db_path
26
+ self._max_size_mb = config.max_size_mb
27
+ self._ensure_db_dir()
28
+ self._init_db()
29
+
30
+ def create_run(self, run: Run, *, idempotency_key: str | None = None) -> Run:
31
+ self._check_size()
32
+ run_json = _encode_model(run)
33
+ timestamp = now()
34
+ with self._connect() as conn:
35
+ if idempotency_key:
36
+ existing = _fetch_one(conn, "SELECT run_id, run_json FROM runs WHERE idempotency_key = ?", (idempotency_key,))
37
+ if existing:
38
+ existing_run = _decode_run(existing["run_json"])
39
+ if existing_run.run_id != run.run_id:
40
+ raise ConflictError("Idempotency key already used for a different run_id.")
41
+ return existing_run
42
+ try:
43
+ conn.execute(
44
+ "INSERT INTO runs (run_id, run_json, idempotency_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
45
+ (run.run_id, run_json, idempotency_key, timestamp, timestamp),
46
+ )
47
+ except sqlite3.IntegrityError as exc:
48
+ raise ConflictError("Run already exists.") from exc
49
+ return run
50
+
51
+ def get_run(self, run_id: str) -> Run:
52
+ with self._connect() as conn:
53
+ row = _fetch_one(conn, "SELECT run_json FROM runs WHERE run_id = ?", (run_id,))
54
+ if not row:
55
+ raise NotFoundError("Run not found.")
56
+ return _decode_run(row["run_json"])
57
+
58
+ def update_run(self, run_id: str, run: Run) -> Run:
59
+ if run.run_id != run_id:
60
+ raise ConflictError("run_id path parameter does not match payload.")
61
+ run_json = _encode_model(run)
62
+ timestamp = now()
63
+ with self._connect() as conn:
64
+ cursor = conn.execute(
65
+ "UPDATE runs SET run_json = ?, updated_at = ? WHERE run_id = ?",
66
+ (run_json, timestamp, run_id),
67
+ )
68
+ if cursor.rowcount == 0:
69
+ raise NotFoundError("Run not found.")
70
+ return run
71
+
72
+ def create_node_run(self, node_run: NodeRun, *, idempotency_key: str | None = None) -> NodeRun:
73
+ self._check_size()
74
+ node_run_json = _encode_model(node_run)
75
+ timestamp = now()
76
+ with self._connect() as conn:
77
+ if idempotency_key:
78
+ existing = _fetch_one(
79
+ conn,
80
+ "SELECT node_run_id, node_run_json FROM node_runs WHERE idempotency_key = ?",
81
+ (idempotency_key,),
82
+ )
83
+ if existing:
84
+ existing_node_run = _decode_node_run(existing["node_run_json"])
85
+ if existing_node_run.node_run_id != node_run.node_run_id:
86
+ raise ConflictError("Idempotency key already used for a different node_run_id.")
87
+ return existing_node_run
88
+ try:
89
+ conn.execute(
90
+ "INSERT INTO node_runs (node_run_id, run_id, node_run_json, idempotency_key, created_at, updated_at) "
91
+ "VALUES (?, ?, ?, ?, ?, ?)",
92
+ (node_run.node_run_id, node_run.run_id, node_run_json, idempotency_key, timestamp, timestamp),
93
+ )
94
+ except sqlite3.IntegrityError as exc:
95
+ raise ConflictError("NodeRun already exists.") from exc
96
+ return node_run
97
+
98
+ def get_node_run(self, node_run_id: str) -> NodeRun:
99
+ with self._connect() as conn:
100
+ row = _fetch_one(conn, "SELECT node_run_json FROM node_runs WHERE node_run_id = ?", (node_run_id,))
101
+ if not row:
102
+ raise NotFoundError("NodeRun not found.")
103
+ return _decode_node_run(row["node_run_json"])
104
+
105
+ def update_node_run(self, node_run_id: str, node_run: NodeRun) -> NodeRun:
106
+ if node_run.node_run_id != node_run_id:
107
+ raise ConflictError("node_run_id path parameter does not match payload.")
108
+ node_run_json = _encode_model(node_run)
109
+ timestamp = now()
110
+ with self._connect() as conn:
111
+ cursor = conn.execute(
112
+ "UPDATE node_runs SET node_run_json = ?, updated_at = ? WHERE node_run_id = ?",
113
+ (node_run_json, timestamp, node_run_id),
114
+ )
115
+ if cursor.rowcount == 0:
116
+ raise NotFoundError("NodeRun not found.")
117
+ return node_run
118
+
119
+ def list_node_runs(self, run_id: str, *, limit: int, offset: int) -> ListNodeRunsResult:
120
+ with self._connect() as conn:
121
+ rows = list(
122
+ conn.execute(
123
+ "SELECT node_run_json FROM node_runs WHERE run_id = ? ORDER BY created_at, node_run_id LIMIT ? OFFSET ?",
124
+ (run_id, limit, offset),
125
+ )
126
+ )
127
+ items = [_decode_node_run(row["node_run_json"]) for row in rows]
128
+ next_offset = offset + len(items) if len(items) == limit else None
129
+ return ListNodeRunsResult(items=items, next_offset=next_offset)
130
+
131
+ @contextmanager
132
+ def _connect(self) -> Iterator[sqlite3.Connection]:
133
+ conn = sqlite3.connect(self._db_path)
134
+ conn.row_factory = sqlite3.Row
135
+ try:
136
+ yield conn
137
+ conn.commit()
138
+ except Exception:
139
+ conn.rollback()
140
+ raise
141
+ finally:
142
+ conn.close()
143
+
144
+ def _ensure_db_dir(self) -> None:
145
+ if self._db_path.parent:
146
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
147
+
148
+ def _init_db(self) -> None:
149
+ with self._connect() as conn:
150
+ conn.execute(
151
+ "CREATE TABLE IF NOT EXISTS runs ("
152
+ "run_id TEXT PRIMARY KEY, "
153
+ "run_json TEXT NOT NULL, "
154
+ "idempotency_key TEXT, "
155
+ "created_at TEXT NOT NULL, "
156
+ "updated_at TEXT NOT NULL"
157
+ ")"
158
+ )
159
+ conn.execute(
160
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_idempotency ON runs(idempotency_key) "
161
+ "WHERE idempotency_key IS NOT NULL"
162
+ )
163
+ conn.execute(
164
+ "CREATE TABLE IF NOT EXISTS node_runs ("
165
+ "node_run_id TEXT PRIMARY KEY, "
166
+ "run_id TEXT NOT NULL, "
167
+ "node_run_json TEXT NOT NULL, "
168
+ "idempotency_key TEXT, "
169
+ "created_at TEXT NOT NULL, "
170
+ "updated_at TEXT NOT NULL"
171
+ ")"
172
+ )
173
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_node_runs_run_id ON node_runs(run_id)")
174
+ conn.execute(
175
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_node_runs_idempotency ON node_runs(idempotency_key) "
176
+ "WHERE idempotency_key IS NOT NULL"
177
+ )
178
+
179
+ def _check_size(self) -> None:
180
+ if self._max_size_mb is None or not self._db_path.exists():
181
+ return
182
+ size_mb = self._db_path.stat().st_size / (1024 * 1024)
183
+ if size_mb > self._max_size_mb:
184
+ raise StorageFullError("Run store exceeds configured max size.")
185
+
186
+
187
+ def _fetch_one(conn: sqlite3.Connection, query: str, params: Sequence[object]) -> sqlite3.Row | None:
188
+ cursor = conn.execute(query, params)
189
+ return cursor.fetchone()
190
+
191
+
192
+ def _encode_model(model: Run | NodeRun) -> str:
193
+ payload = model.model_dump(mode="json")
194
+ return json.dumps(payload, separators=(",", ":"), ensure_ascii=True)
195
+
196
+
197
+ def _decode_run(raw: str) -> Run:
198
+ return Run.model_validate(json.loads(raw))
199
+
200
+
201
+ def _decode_node_run(raw: str) -> NodeRun:
202
+ return NodeRun.model_validate(json.loads(raw))
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import os
5
+ from datetime import datetime, timezone
6
+
7
+ from arp_standard_server import AuthSettings
8
+
9
+ DEFAULT_DEV_KEYCLOAK_ISSUER = "http://localhost:8080/realms/arp-dev"
10
+
11
+
12
+ def now() -> str:
13
+ return datetime.now(timezone.utc).isoformat()
14
+
15
+
16
+ def encode_page_token(offset: int) -> str:
17
+ return base64.urlsafe_b64encode(str(offset).encode()).decode()
18
+
19
+
20
+ def decode_page_token(token: str) -> int:
21
+ try:
22
+ raw = base64.urlsafe_b64decode(token.encode()).decode()
23
+ return int(raw)
24
+ except Exception as exc:
25
+ raise ValueError("Invalid page_token.") from exc
26
+
27
+
28
+ def _has_auth_env() -> bool:
29
+ return any(key.startswith("ARP_AUTH_") for key in os.environ)
30
+
31
+
32
+ def auth_settings_from_env_or_dev_secure() -> AuthSettings:
33
+ if _has_auth_env():
34
+ return AuthSettings.from_env()
35
+ return AuthSettings(mode="required", issuer=DEFAULT_DEV_KEYCLOAK_ISSUER)
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import sys
5
+ from types import SimpleNamespace
6
+ from typing import Any
7
+
8
+ from fastapi import FastAPI
9
+
10
+ from jarvis_run_store import __main__ as main_mod
11
+
12
+
13
+ class _CallCapture:
14
+ def __init__(self) -> None:
15
+ self.args: tuple[Any, ...] = ()
16
+ self.kwargs: dict[str, Any] = {}
17
+
18
+
19
+ def test_main_reload(monkeypatch) -> None:
20
+ calls = _CallCapture()
21
+
22
+ def _run(*args, **kwargs): # type: ignore[no-untyped-def]
23
+ calls.args = args
24
+ calls.kwargs = kwargs
25
+
26
+ monkeypatch.setattr(main_mod, "uvicorn", SimpleNamespace(run=_run))
27
+ monkeypatch.setenv("ARP_AUTH_MODE", "disabled")
28
+ monkeypatch.setattr(sys, "argv", ["prog", "--reload", "--host", "0.0.0.0", "--port", "9000"])
29
+
30
+ main_mod.main()
31
+
32
+ assert calls.args[0] == "jarvis_run_store.app:app"
33
+ assert calls.kwargs["reload"] is True
34
+ assert calls.kwargs["host"] == "0.0.0.0"
35
+ assert calls.kwargs["port"] == 9000
36
+
37
+
38
+ def test_main_no_reload(monkeypatch) -> None:
39
+ calls = _CallCapture()
40
+
41
+ def _run(*args, **kwargs): # type: ignore[no-untyped-def]
42
+ calls.args = args
43
+ calls.kwargs = kwargs
44
+
45
+ monkeypatch.setattr(main_mod, "uvicorn", SimpleNamespace(run=_run))
46
+ monkeypatch.setenv("ARP_AUTH_MODE", "disabled")
47
+ monkeypatch.setattr(sys, "argv", ["prog", "--host", "127.0.0.1", "--port", "9001"])
48
+
49
+ main_mod.main()
50
+
51
+ assert isinstance(calls.args[0], FastAPI)
52
+ assert calls.kwargs["reload"] is False
53
+ assert calls.kwargs["host"] == "127.0.0.1"
54
+ assert calls.kwargs["port"] == 9001
55
+
56
+
57
+ def test_app_module(monkeypatch) -> None:
58
+ monkeypatch.setenv("ARP_AUTH_MODE", "disabled")
59
+ sys.modules.pop("jarvis_run_store.app", None)
60
+ module = importlib.import_module("jarvis_run_store.app")
61
+ assert hasattr(module, "app")
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import pytest
6
+ from fastapi.testclient import TestClient
7
+
8
+ from arp_standard_model import NodeRun, NodeRunState, NodeTypeRef, Run, RunState
9
+ from arp_standard_server import AuthSettings
10
+ from jarvis_run_store.config import RunStoreConfig, run_store_config_from_env
11
+ from jarvis_run_store.errors import ConflictError, NotFoundError, StorageFullError
12
+ from jarvis_run_store.service import create_app
13
+ from jarvis_run_store.sqlite import SqliteRunStore
14
+ from jarvis_run_store.utils import (
15
+ DEFAULT_DEV_KEYCLOAK_ISSUER,
16
+ auth_settings_from_env_or_dev_secure,
17
+ decode_page_token,
18
+ )
19
+
20
+
21
+ def _make_run(run_id: str = "run_1", root_node_run_id: str = "node_1") -> Run:
22
+ return Run(run_id=run_id, root_node_run_id=root_node_run_id, state=RunState.running)
23
+
24
+
25
+ def _make_node_run(node_run_id: str = "node_1", run_id: str = "run_1") -> NodeRun:
26
+ return NodeRun(
27
+ node_run_id=node_run_id,
28
+ run_id=run_id,
29
+ state=NodeRunState.running,
30
+ node_type_ref=NodeTypeRef(node_type_id="jarvis.core.echo", version="0.3.5"),
31
+ )
32
+
33
+
34
+ def test_invalid_sqlite_url() -> None:
35
+ config = RunStoreConfig(db_url="postgres://db", max_size_mb=None)
36
+ with pytest.raises(ValueError):
37
+ _ = config.db_path
38
+
39
+
40
+ def test_missing_sqlite_path() -> None:
41
+ config = RunStoreConfig(db_url="sqlite:///", max_size_mb=None)
42
+ with pytest.raises(ValueError):
43
+ _ = config.db_path
44
+
45
+
46
+ def test_config_from_env(monkeypatch, tmp_path) -> None:
47
+ monkeypatch.setenv("JARVIS_RUN_STORE_DB_URL", f"sqlite:///{tmp_path / 'runs.sqlite'}")
48
+ monkeypatch.setenv("JARVIS_RUN_STORE_MAX_SIZE_MB", "5")
49
+ config = run_store_config_from_env()
50
+ assert config.db_url.endswith("runs.sqlite")
51
+ assert config.max_size_mb == 5
52
+
53
+
54
+ def test_auth_settings_default(monkeypatch) -> None:
55
+ for key in list(os.environ):
56
+ if key.startswith("ARP_AUTH_"):
57
+ monkeypatch.delenv(key, raising=False)
58
+ settings = auth_settings_from_env_or_dev_secure()
59
+ assert settings.mode == "required"
60
+ assert settings.issuer == DEFAULT_DEV_KEYCLOAK_ISSUER
61
+
62
+
63
+ def test_auth_settings_from_env(monkeypatch) -> None:
64
+ monkeypatch.setenv("ARP_AUTH_MODE", "disabled")
65
+ settings = auth_settings_from_env_or_dev_secure()
66
+ assert settings.mode == "disabled"
67
+
68
+
69
+ def test_decode_page_token_invalid() -> None:
70
+ with pytest.raises(ValueError):
71
+ decode_page_token("not-base64")
72
+
73
+
74
+ def test_idempotency_conflicts(tmp_path) -> None:
75
+ config = RunStoreConfig(db_url=f"sqlite:///{tmp_path / 'run_store.sqlite'}", max_size_mb=None)
76
+ store = SqliteRunStore(config)
77
+
78
+ run_one = _make_run(run_id="run_1")
79
+ store.create_run(run_one, idempotency_key="key-1")
80
+ existing = store.create_run(run_one, idempotency_key="key-1")
81
+ assert existing.run_id == "run_1"
82
+
83
+ run_two = _make_run(run_id="run_2")
84
+ with pytest.raises(ConflictError):
85
+ store.create_run(run_two, idempotency_key="key-1")
86
+
87
+ with pytest.raises(ConflictError):
88
+ store.create_run(run_one)
89
+
90
+
91
+ def test_missing_run_and_node_run(tmp_path) -> None:
92
+ config = RunStoreConfig(db_url=f"sqlite:///{tmp_path / 'run_store.sqlite'}", max_size_mb=None)
93
+ store = SqliteRunStore(config)
94
+
95
+ with pytest.raises(NotFoundError):
96
+ store.get_run("missing")
97
+
98
+ with pytest.raises(NotFoundError):
99
+ store.get_node_run("missing")
100
+
101
+
102
+ def test_update_id_mismatch(tmp_path) -> None:
103
+ config = RunStoreConfig(db_url=f"sqlite:///{tmp_path / 'run_store.sqlite'}", max_size_mb=None)
104
+ store = SqliteRunStore(config)
105
+
106
+ run = _make_run(run_id="run_1")
107
+ store.create_run(run)
108
+
109
+ with pytest.raises(ConflictError):
110
+ store.update_run("run_2", run)
111
+
112
+ with pytest.raises(NotFoundError):
113
+ store.update_run("missing", _make_run(run_id="missing"))
114
+
115
+ node_run = _make_node_run(node_run_id="node_1")
116
+ store.create_node_run(node_run)
117
+
118
+ with pytest.raises(ConflictError):
119
+ store.update_node_run("node_2", node_run)
120
+
121
+ with pytest.raises(NotFoundError):
122
+ store.update_node_run("missing", _make_node_run(node_run_id="missing", run_id="run_1"))
123
+
124
+
125
+ def test_storage_full(tmp_path) -> None:
126
+ config = RunStoreConfig(db_url=f"sqlite:///{tmp_path / 'run_store.sqlite'}", max_size_mb=0)
127
+ store = SqliteRunStore(config)
128
+
129
+ with pytest.raises(StorageFullError):
130
+ store.create_run(_make_run())
131
+
132
+
133
+ def test_invalid_page_token_returns_422(tmp_path) -> None:
134
+ config = RunStoreConfig(db_url=f"sqlite:///{tmp_path / 'run_store.sqlite'}", max_size_mb=None)
135
+ app = create_app(config, auth_settings=AuthSettings(mode="disabled"))
136
+ client = TestClient(app)
137
+
138
+ resp = client.get("/v1/runs/run_1/node-runs", params={"page_token": "bad-token"})
139
+ assert resp.status_code == 422
140
+
141
+
142
+ def test_service_conflict_and_not_found(tmp_path) -> None:
143
+ config = RunStoreConfig(db_url=f"sqlite:///{tmp_path / 'run_store.sqlite'}", max_size_mb=None)
144
+ app = create_app(config, auth_settings=AuthSettings(mode="disabled"))
145
+ client = TestClient(app)
146
+
147
+ run = _make_run(run_id="run_1")
148
+ create_resp = client.post("/v1/runs", json={"run": run.model_dump(mode="json")})
149
+ assert create_resp.status_code == 200
150
+
151
+ conflict_resp = client.put(
152
+ "/v1/runs/run_1",
153
+ json={"run": _make_run(run_id="run_2").model_dump(mode="json")},
154
+ )
155
+ assert conflict_resp.status_code == 409
156
+
157
+ missing_resp = client.put(
158
+ "/v1/runs/missing",
159
+ json={"run": _make_run(run_id="missing").model_dump(mode="json")},
160
+ )
161
+ assert missing_resp.status_code == 404
162
+
163
+ node_run = _make_node_run(node_run_id="node_1", run_id="run_1")
164
+ client.post("/v1/node-runs", json={"node_run": node_run.model_dump(mode="json")})
165
+
166
+ node_conflict = client.put(
167
+ "/v1/node-runs/node_1",
168
+ json={"node_run": _make_node_run(node_run_id="node_2", run_id="run_1").model_dump(mode="json")},
169
+ )
170
+ assert node_conflict.status_code == 409
171
+
172
+ node_missing = client.put(
173
+ "/v1/node-runs/missing",
174
+ json={"node_run": _make_node_run(node_run_id="missing", run_id="run_1").model_dump(mode="json")},
175
+ )
176
+ assert node_missing.status_code == 404
177
+
178
+
179
+ def test_service_storage_full_returns_507(tmp_path) -> None:
180
+ config = RunStoreConfig(db_url=f"sqlite:///{tmp_path / 'run_store.sqlite'}", max_size_mb=0)
181
+ app = create_app(config, auth_settings=AuthSettings(mode="disabled"))
182
+ client = TestClient(app)
183
+
184
+ run = _make_run(run_id="run_1")
185
+ resp = client.post("/v1/runs", json={"run": run.model_dump(mode="json")})
186
+ assert resp.status_code == 507
187
+
188
+ node_run = _make_node_run(node_run_id="node_1", run_id="run_1")
189
+ resp = client.post("/v1/node-runs", json={"node_run": node_run.model_dump(mode="json")})
190
+ assert resp.status_code == 507
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi.testclient import TestClient
4
+
5
+ from arp_standard_model import NodeRun, NodeRunState, NodeTypeRef, Run, RunState
6
+ from arp_standard_server import AuthSettings
7
+ from jarvis_run_store.config import RunStoreConfig
8
+ from jarvis_run_store.service import create_app
9
+
10
+
11
+ def _make_run(run_id: str = "run_1", root_node_run_id: str = "node_1") -> Run:
12
+ return Run(run_id=run_id, root_node_run_id=root_node_run_id, state=RunState.running)
13
+
14
+
15
+ def _make_node_run(node_run_id: str = "node_1", run_id: str = "run_1") -> NodeRun:
16
+ return NodeRun(
17
+ node_run_id=node_run_id,
18
+ run_id=run_id,
19
+ state=NodeRunState.running,
20
+ node_type_ref=NodeTypeRef(node_type_id="jarvis.core.echo", version="0.3.5"),
21
+ )
22
+
23
+
24
+ def test_run_store_roundtrip(tmp_path) -> None:
25
+ config = RunStoreConfig(db_url=f"sqlite:///{tmp_path / 'run_store.sqlite'}", max_size_mb=None)
26
+ app = create_app(config, auth_settings=AuthSettings(mode="disabled"))
27
+ client = TestClient(app)
28
+
29
+ run = _make_run()
30
+ create_resp = client.post("/v1/runs", json={"run": run.model_dump(mode="json")})
31
+ assert create_resp.status_code == 200
32
+ assert create_resp.json()["run"]["run_id"] == "run_1"
33
+
34
+ get_resp = client.get("/v1/runs/run_1")
35
+ assert get_resp.status_code == 200
36
+
37
+ update_run = _make_run()
38
+ update_resp = client.put("/v1/runs/run_1", json={"run": update_run.model_dump(mode="json")})
39
+ assert update_resp.status_code == 200
40
+
41
+
42
+ def test_node_run_listing_with_pagination(tmp_path) -> None:
43
+ config = RunStoreConfig(db_url=f"sqlite:///{tmp_path / 'run_store.sqlite'}", max_size_mb=None)
44
+ app = create_app(config, auth_settings=AuthSettings(mode="disabled"))
45
+ client = TestClient(app)
46
+
47
+ run = _make_run()
48
+ client.post("/v1/runs", json={"run": run.model_dump(mode="json")})
49
+
50
+ node_one = _make_node_run(node_run_id="node_1")
51
+ node_two = _make_node_run(node_run_id="node_2")
52
+ client.post("/v1/node-runs", json={"node_run": node_one.model_dump(mode="json")})
53
+ client.post("/v1/node-runs", json={"node_run": node_two.model_dump(mode="json")})
54
+
55
+ first_page = client.get("/v1/runs/run_1/node-runs", params={"limit": 1})
56
+ assert first_page.status_code == 200
57
+ payload = first_page.json()
58
+ assert len(payload["items"]) == 1
59
+ assert payload["next_token"]
60
+
61
+ second_page = client.get(
62
+ "/v1/runs/run_1/node-runs",
63
+ params={"limit": 1, "page_token": payload["next_token"]},
64
+ )
65
+ assert second_page.status_code == 200
66
+ assert len(second_page.json()["items"]) == 1