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.
- arp_jarvis_runstore-0.3.5/LICENSE +21 -0
- arp_jarvis_runstore-0.3.5/PKG-INFO +81 -0
- arp_jarvis_runstore-0.3.5/README.md +60 -0
- arp_jarvis_runstore-0.3.5/pyproject.toml +37 -0
- arp_jarvis_runstore-0.3.5/setup.cfg +4 -0
- arp_jarvis_runstore-0.3.5/src/arp_jarvis_runstore.egg-info/PKG-INFO +81 -0
- arp_jarvis_runstore-0.3.5/src/arp_jarvis_runstore.egg-info/SOURCES.txt +20 -0
- arp_jarvis_runstore-0.3.5/src/arp_jarvis_runstore.egg-info/dependency_links.txt +1 -0
- arp_jarvis_runstore-0.3.5/src/arp_jarvis_runstore.egg-info/entry_points.txt +2 -0
- arp_jarvis_runstore-0.3.5/src/arp_jarvis_runstore.egg-info/requires.txt +11 -0
- arp_jarvis_runstore-0.3.5/src/arp_jarvis_runstore.egg-info/top_level.txt +2 -0
- arp_jarvis_runstore-0.3.5/src/jarvis_run_store/__init__.py +1 -0
- arp_jarvis_runstore-0.3.5/src/jarvis_run_store/__main__.py +25 -0
- arp_jarvis_runstore-0.3.5/src/jarvis_run_store/app.py +5 -0
- arp_jarvis_runstore-0.3.5/src/jarvis_run_store/config.py +32 -0
- arp_jarvis_runstore-0.3.5/src/jarvis_run_store/errors.py +14 -0
- arp_jarvis_runstore-0.3.5/src/jarvis_run_store/service.py +188 -0
- arp_jarvis_runstore-0.3.5/src/jarvis_run_store/sqlite.py +202 -0
- arp_jarvis_runstore-0.3.5/src/jarvis_run_store/utils.py +35 -0
- arp_jarvis_runstore-0.3.5/tests/test_cli.py +61 -0
- arp_jarvis_runstore-0.3.5/tests/test_run_store_errors.py +190 -0
- arp_jarvis_runstore-0.3.5/tests/test_run_store_smoke.py +66 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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,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
|