shadowbox 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shadowbox-0.3.0/.github/workflows/ci.yml +45 -0
- shadowbox-0.3.0/.github/workflows/publish.yml +33 -0
- shadowbox-0.3.0/.gitignore +26 -0
- shadowbox-0.3.0/.python-version +1 -0
- shadowbox-0.3.0/PKG-INFO +130 -0
- shadowbox-0.3.0/README.md +115 -0
- shadowbox-0.3.0/apps/api/worker.py +22 -0
- shadowbox-0.3.0/apps/studio/index.html +12 -0
- shadowbox-0.3.0/apps/studio/package.json +24 -0
- shadowbox-0.3.0/apps/studio/src/App.tsx +73 -0
- shadowbox-0.3.0/apps/studio/src/api.ts +86 -0
- shadowbox-0.3.0/apps/studio/src/fixtures.ts +23 -0
- shadowbox-0.3.0/apps/studio/src/graph.ts +45 -0
- shadowbox-0.3.0/apps/studio/src/main.tsx +9 -0
- shadowbox-0.3.0/apps/studio/tsconfig.json +14 -0
- shadowbox-0.3.0/apps/studio/vite.config.ts +11 -0
- shadowbox-0.3.0/apps/web/app.js +117 -0
- shadowbox-0.3.0/apps/web/index.html +25 -0
- shadowbox-0.3.0/apps/web/styles.css +9 -0
- shadowbox-0.3.0/pyproject.toml +57 -0
- shadowbox-0.3.0/schema.sql +13 -0
- shadowbox-0.3.0/schemas/model-v1.json +51 -0
- shadowbox-0.3.0/schemas/scenario-v1.json +41 -0
- shadowbox-0.3.0/src/shadowbox/__init__.py +3 -0
- shadowbox-0.3.0/src/shadowbox/api.py +136 -0
- shadowbox-0.3.0/src/shadowbox/cards.py +59 -0
- shadowbox-0.3.0/src/shadowbox/cli.py +222 -0
- shadowbox-0.3.0/src/shadowbox/compare.py +103 -0
- shadowbox-0.3.0/src/shadowbox/data/__init__.py +1 -0
- shadowbox-0.3.0/src/shadowbox/data/cards/cache-poison.yaml +7 -0
- shadowbox-0.3.0/src/shadowbox/data/cards/db-down.yaml +7 -0
- shadowbox-0.3.0/src/shadowbox/data/cards/latency-500ms.yaml +8 -0
- shadowbox-0.3.0/src/shadowbox/data/cards/queue-overflow.yaml +6 -0
- shadowbox-0.3.0/src/shadowbox/data/cards/slow-dependency.yaml +8 -0
- shadowbox-0.3.0/src/shadowbox/data/cards/traffic-10x.yaml +6 -0
- shadowbox-0.3.0/src/shadowbox/data/cards/zone-loss.yaml +8 -0
- shadowbox-0.3.0/src/shadowbox/data/example/docker-compose.yaml +13 -0
- shadowbox-0.3.0/src/shadowbox/data/example/model.yaml +33 -0
- shadowbox-0.3.0/src/shadowbox/data/example/scenarios/db-failure.yaml +12 -0
- shadowbox-0.3.0/src/shadowbox/dsl.py +94 -0
- shadowbox-0.3.0/src/shadowbox/engine.py +220 -0
- shadowbox-0.3.0/src/shadowbox/errors.py +38 -0
- shadowbox-0.3.0/src/shadowbox/importers/__init__.py +3 -0
- shadowbox-0.3.0/src/shadowbox/importers/compose.py +109 -0
- shadowbox-0.3.0/src/shadowbox/metrics.py +49 -0
- shadowbox-0.3.0/src/shadowbox/model.py +74 -0
- shadowbox-0.3.0/src/shadowbox/report.py +48 -0
- shadowbox-0.3.0/src/shadowbox/store.py +85 -0
- shadowbox-0.3.0/tests/deterministic/test_golden.py +48 -0
- shadowbox-0.3.0/tests/fixtures/bad-ref.yaml +13 -0
- shadowbox-0.3.0/tests/fixtures/cycle.yaml +23 -0
- shadowbox-0.3.0/tests/integration/test_api.py +94 -0
- shadowbox-0.3.0/tests/property/test_invariants.py +98 -0
- shadowbox-0.3.0/tests/unit/test_compare.py +132 -0
- shadowbox-0.3.0/tests/unit/test_import.py +58 -0
- shadowbox-0.3.0/tests/unit/test_init.py +43 -0
- shadowbox-0.3.0/tests/unit/test_validate.py +57 -0
- shadowbox-0.3.0/tests/unit/test_version.py +9 -0
- shadowbox-0.3.0/tests/unit/test_worker.py +17 -0
- shadowbox-0.3.0/uv.lock +893 -0
- shadowbox-0.3.0/wrangler.jsonc +20 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
m0:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: astral-sh/setup-uv@v5
|
|
13
|
+
with:
|
|
14
|
+
python-version: "3.13"
|
|
15
|
+
- run: uv sync --group dev
|
|
16
|
+
- run: uv run ruff check .
|
|
17
|
+
- run: uv run mypy src
|
|
18
|
+
- run: uv run pytest
|
|
19
|
+
- run: uv run shadowbox init --out /tmp/sb
|
|
20
|
+
- run: uv run shadowbox validate /tmp/sb/model.yaml --scenario /tmp/sb/scenarios/db-failure.yaml
|
|
21
|
+
- run: uv run shadowbox import --from /tmp/sb/docker-compose.yaml --out /tmp/imported.yaml
|
|
22
|
+
- run: uv run shadowbox validate /tmp/imported.yaml --scenario /tmp/sb/cards/db-down.yaml
|
|
23
|
+
- run: |
|
|
24
|
+
for card in /tmp/sb/cards/*.yaml; do
|
|
25
|
+
uv run shadowbox validate /tmp/sb/model.yaml --scenario "$card"
|
|
26
|
+
done
|
|
27
|
+
- run: uv run shadowbox simulate /tmp/sb/model.yaml --scenario /tmp/sb/cards/queue-overflow.yaml --seed 42 --out /tmp/report.json
|
|
28
|
+
- run: uv run shadowbox simulate /tmp/sb/model.yaml --scenario /tmp/sb/scenarios/db-failure.yaml --seed 42 --out /tmp/base.json
|
|
29
|
+
- run: uv run shadowbox compare --a /tmp/base.json --b /tmp/base.json
|
|
30
|
+
- run: uv run shadowbox report /tmp/report.json --format text
|
|
31
|
+
- run: uv build
|
|
32
|
+
- run: uv run python -c "import zipfile,glob; w=sorted(glob.glob('dist/*.whl'))[-1]; n=zipfile.ZipFile(w).namelist(); assert sum(1 for x in n if '/data/' in x) == 11, n; assert any(x.endswith('example/model.yaml') for x in n)"
|
|
33
|
+
|
|
34
|
+
studio:
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
defaults:
|
|
37
|
+
run:
|
|
38
|
+
working-directory: apps/studio
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
- uses: actions/setup-node@v4
|
|
42
|
+
with:
|
|
43
|
+
node-version: "24"
|
|
44
|
+
- run: npm install
|
|
45
|
+
- run: npm run build
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.13"
|
|
16
|
+
- run: uv build
|
|
17
|
+
- uses: actions/upload-artifact@v4
|
|
18
|
+
with:
|
|
19
|
+
name: dist
|
|
20
|
+
path: dist/
|
|
21
|
+
|
|
22
|
+
publish:
|
|
23
|
+
needs: build
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
environment: pypi
|
|
26
|
+
permissions:
|
|
27
|
+
id-token: write
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/download-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/
|
|
33
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Ignore all Markdown except README.md (spec and agent docs stay local)
|
|
2
|
+
*.md
|
|
3
|
+
**/*.md
|
|
4
|
+
!README.md
|
|
5
|
+
!**/README.md
|
|
6
|
+
|
|
7
|
+
# Python runtimes (never commit)
|
|
8
|
+
.venv/
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.pyc
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
|
|
15
|
+
# Local server artifacts (never commit)
|
|
16
|
+
*.db
|
|
17
|
+
report.json
|
|
18
|
+
model.yaml
|
|
19
|
+
!src/shadowbox/data/example/model.yaml
|
|
20
|
+
|
|
21
|
+
# Build output (never commit)
|
|
22
|
+
dist/
|
|
23
|
+
|
|
24
|
+
# Node (never commit)
|
|
25
|
+
node_modules/
|
|
26
|
+
dist/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
shadowbox-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shadowbox
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Executable architectural model for safe what-if experimentation (M0: contracts + validate)
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: fastapi>=0.115
|
|
8
|
+
Requires-Dist: jsonschema>=4.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
12
|
+
Requires-Dist: typer>=0.9
|
|
13
|
+
Requires-Dist: uvicorn>=0.30
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# ShadowBox
|
|
17
|
+
|
|
18
|
+
Executable architectural model for safe what-if experimentation. Model the system. Experiment safely.
|
|
19
|
+
|
|
20
|
+
> Scope: headless CLI (validate, simulate, compare, report, import, init, serve) plus local API, static demo, and React Studio. Simulation output is always labeled with assumptions, confidence, and seed — never presented as production measurement.
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Python `>=3.13` (pinned via `.python-version`)
|
|
25
|
+
- `uv` for env and runs (no Docker needed)
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
With a clone (development):
|
|
30
|
+
|
|
31
|
+
```powershell
|
|
32
|
+
uv python pin 3.13
|
|
33
|
+
uv venv
|
|
34
|
+
uv sync --group dev
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Without a clone (use only):
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
uvx --from "shadowbox @ git+https://github.com/Pa004/shadowBox.git@v0.2.0" shadowbox init --out demo
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Run in development
|
|
44
|
+
|
|
45
|
+
```powershell
|
|
46
|
+
uv run shadowbox init --out demo
|
|
47
|
+
uv run shadowbox import --from demo/docker-compose.yaml --out demo/model2.yaml
|
|
48
|
+
uv run shadowbox validate demo/model.yaml --scenario demo/scenarios/db-failure.yaml
|
|
49
|
+
uv run shadowbox simulate demo/model.yaml --scenario demo/scenarios/db-failure.yaml --seed 42 --out report.json
|
|
50
|
+
uv run shadowbox report report.json --format text
|
|
51
|
+
uv run shadowbox compare --a base.json --b report.json
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Exit codes: `0` valid/pass, `2` scenario regression (compare), `3` invalid input or existing files without `--force` (prints `E_*` code).
|
|
55
|
+
|
|
56
|
+
Import notes: every performance field is an estimated default (see warnings). Calibrate before trusting output.
|
|
57
|
+
|
|
58
|
+
Chaos cards ship in the package (`init` writes them to `cards/`): `db-down`, `cache-poison`, `latency-500ms`, `traffic-10x`, `zone-loss`, `slow-dependency`, `queue-overflow`.
|
|
59
|
+
|
|
60
|
+
## API server (local)
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
uv run uvicorn shadowbox.api:app --port 8000
|
|
64
|
+
# or: uv run shadowbox serve --port 8000
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Endpoints: `POST /api/v1/models`, `GET /api/v1/models/{id}`, `POST /api/v1/simulations?model_id=...`, `GET /api/v1/simulations/{id}[/events|/metrics|/report]`. Events are paginated (`limit` 1..1000, `cursor` offset over the stored 500-request sample). State lives in `shadowbox.db` (git-ignored, created on first use). The API has no authentication: bind to localhost (`serve` defaults to `127.0.0.1`) and never expose it directly to the internet.
|
|
68
|
+
|
|
69
|
+
## Deploy (Cloudflare free tier, no card)
|
|
70
|
+
|
|
71
|
+
Scaffold ready in `wrangler.jsonc` + `schema.sql` + `apps/api/worker.py` + `apps/web/` (static demo, no build step). Remaining steps need your Cloudflare account:
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
!npm install -g wrangler
|
|
75
|
+
!wrangler login
|
|
76
|
+
!wrangler d1 create shadowbox # paste database_id into wrangler.jsonc
|
|
77
|
+
!wrangler d1 execute shadowbox --file schema.sql
|
|
78
|
+
!uvx --from workers-py pywrangler dev # local Worker emulation, no account needed
|
|
79
|
+
!wrangler deploy
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Production note: the Worker serves the same FastAPI app; swapping the SQLite file store for the D1 binding is a follow-up task verified against a real account (M4b-full). The static demo deploys to Pages as-is and talks to any API base URL.
|
|
83
|
+
|
|
84
|
+
Open `apps/web/index.html` after `Run` to scrub virtual time: the SVG graph colors failed components red and shows active faults per second (first 500 sampled requests).
|
|
85
|
+
|
|
86
|
+
## Studio (React + Cytoscape)
|
|
87
|
+
|
|
88
|
+
Full UI in `apps/studio/` (Vite, strict TS). Needs Node deps (run yourself):
|
|
89
|
+
|
|
90
|
+
```powershell
|
|
91
|
+
cd apps/studio
|
|
92
|
+
npm install
|
|
93
|
+
npm run build # tsc plus vite
|
|
94
|
+
npm run dev # /api proxies to 127.0.0.1:8000
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Run any scenario vs baseline, inspect p99/error verdict, scrub the failure cascade on the Cytoscape graph.
|
|
98
|
+
|
|
99
|
+
## Environment variables
|
|
100
|
+
|
|
101
|
+
None required. Server mode reads no env vars yet; Cloudflare D1 bindings arrive with the production Worker swap.
|
|
102
|
+
|
|
103
|
+
## Project structure
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
src/shadowbox/ # model, dsl, cli, errors, engine, metrics, report, cards, api, store
|
|
107
|
+
src/shadowbox/data/ # canonical example + chaos cards (shipped in the wheel)
|
|
108
|
+
schemas/ # model-v1.json, scenario-v1.json
|
|
109
|
+
tests/ # unit, deterministic (golden seed 42), property, integration
|
|
110
|
+
tests/fixtures/ # broken models (E_CYCLE, E_REF)
|
|
111
|
+
apps/web/ # static demo with replay (no build)
|
|
112
|
+
apps/studio/ # React + Cytoscape UI (Vite, strict TS)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Run tests
|
|
116
|
+
|
|
117
|
+
```powershell
|
|
118
|
+
uv run ruff check .
|
|
119
|
+
uv run mypy src
|
|
120
|
+
uv run pytest
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Benchmarks (reference: i7-1255U, 16GB, Python 3.13; SLO: 50k events < 2s):
|
|
124
|
+
|
|
125
|
+
- checkout db-failure (6k reqs, 85k events): ~0.05s
|
|
126
|
+
- 60k reqs, 840k events: ~0.6s
|
|
127
|
+
|
|
128
|
+
## Deploy notes
|
|
129
|
+
|
|
130
|
+
Local-first: CLI and `serve` need nothing but Python. Demo deploy: Cloudflare Pages (web) + Python Worker (FastAPI via `workers.asgi`) + D1 — see `Deploy (Cloudflare...)` above. No paid service, no credit card at any tier. See `ShadowBox.md` (local spec, git-ignored) for the full contract.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# ShadowBox
|
|
2
|
+
|
|
3
|
+
Executable architectural model for safe what-if experimentation. Model the system. Experiment safely.
|
|
4
|
+
|
|
5
|
+
> Scope: headless CLI (validate, simulate, compare, report, import, init, serve) plus local API, static demo, and React Studio. Simulation output is always labeled with assumptions, confidence, and seed — never presented as production measurement.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python `>=3.13` (pinned via `.python-version`)
|
|
10
|
+
- `uv` for env and runs (no Docker needed)
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
With a clone (development):
|
|
15
|
+
|
|
16
|
+
```powershell
|
|
17
|
+
uv python pin 3.13
|
|
18
|
+
uv venv
|
|
19
|
+
uv sync --group dev
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Without a clone (use only):
|
|
23
|
+
|
|
24
|
+
```powershell
|
|
25
|
+
uvx --from "shadowbox @ git+https://github.com/Pa004/shadowBox.git@v0.2.0" shadowbox init --out demo
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Run in development
|
|
29
|
+
|
|
30
|
+
```powershell
|
|
31
|
+
uv run shadowbox init --out demo
|
|
32
|
+
uv run shadowbox import --from demo/docker-compose.yaml --out demo/model2.yaml
|
|
33
|
+
uv run shadowbox validate demo/model.yaml --scenario demo/scenarios/db-failure.yaml
|
|
34
|
+
uv run shadowbox simulate demo/model.yaml --scenario demo/scenarios/db-failure.yaml --seed 42 --out report.json
|
|
35
|
+
uv run shadowbox report report.json --format text
|
|
36
|
+
uv run shadowbox compare --a base.json --b report.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Exit codes: `0` valid/pass, `2` scenario regression (compare), `3` invalid input or existing files without `--force` (prints `E_*` code).
|
|
40
|
+
|
|
41
|
+
Import notes: every performance field is an estimated default (see warnings). Calibrate before trusting output.
|
|
42
|
+
|
|
43
|
+
Chaos cards ship in the package (`init` writes them to `cards/`): `db-down`, `cache-poison`, `latency-500ms`, `traffic-10x`, `zone-loss`, `slow-dependency`, `queue-overflow`.
|
|
44
|
+
|
|
45
|
+
## API server (local)
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
uv run uvicorn shadowbox.api:app --port 8000
|
|
49
|
+
# or: uv run shadowbox serve --port 8000
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Endpoints: `POST /api/v1/models`, `GET /api/v1/models/{id}`, `POST /api/v1/simulations?model_id=...`, `GET /api/v1/simulations/{id}[/events|/metrics|/report]`. Events are paginated (`limit` 1..1000, `cursor` offset over the stored 500-request sample). State lives in `shadowbox.db` (git-ignored, created on first use). The API has no authentication: bind to localhost (`serve` defaults to `127.0.0.1`) and never expose it directly to the internet.
|
|
53
|
+
|
|
54
|
+
## Deploy (Cloudflare free tier, no card)
|
|
55
|
+
|
|
56
|
+
Scaffold ready in `wrangler.jsonc` + `schema.sql` + `apps/api/worker.py` + `apps/web/` (static demo, no build step). Remaining steps need your Cloudflare account:
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
!npm install -g wrangler
|
|
60
|
+
!wrangler login
|
|
61
|
+
!wrangler d1 create shadowbox # paste database_id into wrangler.jsonc
|
|
62
|
+
!wrangler d1 execute shadowbox --file schema.sql
|
|
63
|
+
!uvx --from workers-py pywrangler dev # local Worker emulation, no account needed
|
|
64
|
+
!wrangler deploy
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Production note: the Worker serves the same FastAPI app; swapping the SQLite file store for the D1 binding is a follow-up task verified against a real account (M4b-full). The static demo deploys to Pages as-is and talks to any API base URL.
|
|
68
|
+
|
|
69
|
+
Open `apps/web/index.html` after `Run` to scrub virtual time: the SVG graph colors failed components red and shows active faults per second (first 500 sampled requests).
|
|
70
|
+
|
|
71
|
+
## Studio (React + Cytoscape)
|
|
72
|
+
|
|
73
|
+
Full UI in `apps/studio/` (Vite, strict TS). Needs Node deps (run yourself):
|
|
74
|
+
|
|
75
|
+
```powershell
|
|
76
|
+
cd apps/studio
|
|
77
|
+
npm install
|
|
78
|
+
npm run build # tsc plus vite
|
|
79
|
+
npm run dev # /api proxies to 127.0.0.1:8000
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Run any scenario vs baseline, inspect p99/error verdict, scrub the failure cascade on the Cytoscape graph.
|
|
83
|
+
|
|
84
|
+
## Environment variables
|
|
85
|
+
|
|
86
|
+
None required. Server mode reads no env vars yet; Cloudflare D1 bindings arrive with the production Worker swap.
|
|
87
|
+
|
|
88
|
+
## Project structure
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
src/shadowbox/ # model, dsl, cli, errors, engine, metrics, report, cards, api, store
|
|
92
|
+
src/shadowbox/data/ # canonical example + chaos cards (shipped in the wheel)
|
|
93
|
+
schemas/ # model-v1.json, scenario-v1.json
|
|
94
|
+
tests/ # unit, deterministic (golden seed 42), property, integration
|
|
95
|
+
tests/fixtures/ # broken models (E_CYCLE, E_REF)
|
|
96
|
+
apps/web/ # static demo with replay (no build)
|
|
97
|
+
apps/studio/ # React + Cytoscape UI (Vite, strict TS)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Run tests
|
|
101
|
+
|
|
102
|
+
```powershell
|
|
103
|
+
uv run ruff check .
|
|
104
|
+
uv run mypy src
|
|
105
|
+
uv run pytest
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Benchmarks (reference: i7-1255U, 16GB, Python 3.13; SLO: 50k events < 2s):
|
|
109
|
+
|
|
110
|
+
- checkout db-failure (6k reqs, 85k events): ~0.05s
|
|
111
|
+
- 60k reqs, 840k events: ~0.6s
|
|
112
|
+
|
|
113
|
+
## Deploy notes
|
|
114
|
+
|
|
115
|
+
Local-first: CLI and `serve` need nothing but Python. Demo deploy: Cloudflare Pages (web) + Python Worker (FastAPI via `workers.asgi`) + D1 — see `Deploy (Cloudflare...)` above. No paid service, no credit card at any tier. See `ShadowBox.md` (local spec, git-ignored) for the full contract.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Cloudflare Python Worker entry (M4b scaffold).
|
|
2
|
+
|
|
3
|
+
Local emulation (no account needed):
|
|
4
|
+
uvx --from workers-py pywrangler dev
|
|
5
|
+
|
|
6
|
+
Production additionally needs:
|
|
7
|
+
1. `wrangler d1 create shadowbox` -> paste database_id into wrangler.jsonc
|
|
8
|
+
2. `wrangler d1 execute shadowbox --file schema.sql`
|
|
9
|
+
3. D1-backed store swap (same Store signatures, D1 binding instead of
|
|
10
|
+
sqlite3 file) — follow-up task, needs account access to verify.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from workers import asgi
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise RuntimeError(
|
|
17
|
+
"workers runtime missing: run inside `pywrangler dev`, not plain python"
|
|
18
|
+
) from exc
|
|
19
|
+
|
|
20
|
+
from shadowbox.api import app
|
|
21
|
+
|
|
22
|
+
Default = asgi.entrypoint(app)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>ShadowBox Studio</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shadowbox-studio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc --noEmit && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"cytoscape": "^3.30.0",
|
|
13
|
+
"react": "^19.0.0",
|
|
14
|
+
"react-dom": "^19.0.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/cytoscape": "^3.14.0",
|
|
18
|
+
"@types/react": "^19.0.0",
|
|
19
|
+
"@types/react-dom": "^19.0.0",
|
|
20
|
+
"@vitejs/plugin-react": "^4.0.0",
|
|
21
|
+
"typescript": "^5.6.0",
|
|
22
|
+
"vite": "^5.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { createModel, runScenario, type Report, type SimEvent } from "./api";
|
|
3
|
+
import { MODEL, SCENARIOS } from "./fixtures";
|
|
4
|
+
import { activeFaults, failuresUpTo, renderGraph } from "./graph";
|
|
5
|
+
import type cytoscape from "cytoscape";
|
|
6
|
+
|
|
7
|
+
export default function App() {
|
|
8
|
+
const [base, setBase] = useState("http://127.0.0.1:8000");
|
|
9
|
+
const [scenarioName, setScenarioName] = useState("db-down");
|
|
10
|
+
const [baseline, setBaseline] = useState<Report | null>(null);
|
|
11
|
+
const [report, setReport] = useState<Report | null>(null);
|
|
12
|
+
const [events, setEvents] = useState<SimEvent[]>([]);
|
|
13
|
+
const [t, setT] = useState(0);
|
|
14
|
+
const [error, setError] = useState("");
|
|
15
|
+
const [busy, setBusy] = useState(false);
|
|
16
|
+
const cyRef = useRef<cytoscape.Core | null>(null);
|
|
17
|
+
const boxRef = useRef<HTMLDivElement>(null);
|
|
18
|
+
|
|
19
|
+
const scenario = SCENARIOS.find((s) => s.name === scenarioName) ?? SCENARIOS[0];
|
|
20
|
+
|
|
21
|
+
async function run() {
|
|
22
|
+
setBusy(true);
|
|
23
|
+
setError("");
|
|
24
|
+
try {
|
|
25
|
+
const modelId = await createModel(base, MODEL);
|
|
26
|
+
const baseRes = await runScenario(base, modelId, SCENARIOS[0], 42);
|
|
27
|
+
const cur = await runScenario(base, modelId, scenario, 42);
|
|
28
|
+
setBaseline(baseRes.report);
|
|
29
|
+
setReport(cur.report);
|
|
30
|
+
setEvents(cur.events);
|
|
31
|
+
setT(0);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
34
|
+
} finally {
|
|
35
|
+
setBusy(false);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!boxRef.current || !report) return;
|
|
41
|
+
cyRef.current?.destroy();
|
|
42
|
+
const timeouts: Record<string, number> = {};
|
|
43
|
+
for (const [id, c] of Object.entries(report.metrics.components)) timeouts[id] = c.timeouts;
|
|
44
|
+
cyRef.current = renderGraph(boxRef.current, MODEL.components, MODEL.connections, failuresUpTo(events, t * 1000), timeouts);
|
|
45
|
+
return () => cyRef.current?.destroy();
|
|
46
|
+
}, [report, events, t]);
|
|
47
|
+
|
|
48
|
+
const m = report?.metrics;
|
|
49
|
+
const verdict = baseline && m ? (m.error_rate > baseline.metrics.error_rate ? "REGRESSION" : "pass") : "-";
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<main style={{ fontFamily: "system-ui", maxWidth: 900, margin: "2rem auto", padding: "0 1rem" }}>
|
|
53
|
+
<h1>ShadowBox Studio</h1>
|
|
54
|
+
<p>What-if simulation. Results are illustrative, never production measurements.</p>
|
|
55
|
+
<label>API base <input value={base} onChange={(e) => setBase(e.target.value)} size={30} /></label>{" "}
|
|
56
|
+
<label>Scenario{" "}
|
|
57
|
+
<select value={scenarioName} onChange={(e) => setScenarioName(e.target.value)}>
|
|
58
|
+
{SCENARIOS.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
|
|
59
|
+
</select>
|
|
60
|
+
</label>{" "}
|
|
61
|
+
<button onClick={run} disabled={busy}>{busy ? "Running..." : "Run vs baseline"}</button>
|
|
62
|
+
{error && <pre style={{ color: "red" }}>{error}</pre>}
|
|
63
|
+
{m && (
|
|
64
|
+
<>
|
|
65
|
+
<p>error_rate={m.error_rate.toFixed(3)} p99={m.latency_ms.p99}ms verdict={verdict} hash={report?.metrics_hash.slice(0, 12)}</p>
|
|
66
|
+
<div ref={boxRef} style={{ width: "100%", height: 320, border: "1px solid #ccc" }} />
|
|
67
|
+
<label>t={t}s <input type="range" min={0} max={scenario.duration_s} value={t} onChange={(e) => setT(Number(e.target.value))} /></label>
|
|
68
|
+
<pre>active_faults=[{activeFaults(scenario.faults, t).join(", ") || "none"}]</pre>
|
|
69
|
+
</>
|
|
70
|
+
)}
|
|
71
|
+
</main>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface Latency {
|
|
2
|
+
p50: number;
|
|
3
|
+
p90: number;
|
|
4
|
+
p95: number;
|
|
5
|
+
p99: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ComponentMetrics {
|
|
9
|
+
utilization: number;
|
|
10
|
+
queue_depth: number;
|
|
11
|
+
timeouts: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Metrics {
|
|
15
|
+
throughput_rps: number;
|
|
16
|
+
error_rate: number;
|
|
17
|
+
latency_ms: Latency;
|
|
18
|
+
timeouts: number;
|
|
19
|
+
retries: number;
|
|
20
|
+
cascade_depth: number;
|
|
21
|
+
components: Record<string, ComponentMetrics>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Report {
|
|
25
|
+
metrics: Metrics;
|
|
26
|
+
metrics_hash: string;
|
|
27
|
+
seed: number;
|
|
28
|
+
confidence: string;
|
|
29
|
+
calibration_source: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SimEvent {
|
|
33
|
+
correlation_id: string;
|
|
34
|
+
ok: boolean;
|
|
35
|
+
latency_ms: number;
|
|
36
|
+
failed_at: string | null;
|
|
37
|
+
timed_out: boolean;
|
|
38
|
+
arrival_ms: number;
|
|
39
|
+
finish_ms: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Fault {
|
|
43
|
+
target: string;
|
|
44
|
+
type: string;
|
|
45
|
+
start_s: number;
|
|
46
|
+
duration_s: number;
|
|
47
|
+
extra_ms?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Scenario {
|
|
51
|
+
name: string;
|
|
52
|
+
duration_s: number;
|
|
53
|
+
workload: { rate_rps: number };
|
|
54
|
+
faults: Fault[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function postJson(base: string, path: string, body: unknown, query = ""): Promise<unknown> {
|
|
58
|
+
const res = await fetch(`${base}${path}${query}`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify(body)
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
|
|
64
|
+
return res.json() as Promise<unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function createModel(base: string, model: unknown): Promise<string> {
|
|
68
|
+
const out = (await postJson(base, "/api/v1/models", model)) as { id: string };
|
|
69
|
+
return out.id;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function runScenario(
|
|
73
|
+
base: string,
|
|
74
|
+
modelId: string,
|
|
75
|
+
scenario: Scenario,
|
|
76
|
+
seed: number
|
|
77
|
+
): Promise<{ simId: string; report: Report; events: SimEvent[] }> {
|
|
78
|
+
const sim = (await postJson(base, "/api/v1/simulations", { scenario, seed }, `?model_id=${modelId}`)) as {
|
|
79
|
+
id: string;
|
|
80
|
+
};
|
|
81
|
+
const report = (await (await fetch(`${base}/api/v1/simulations/${sim.id}/report`)).json()) as Report;
|
|
82
|
+
const page = (await (
|
|
83
|
+
await fetch(`${base}/api/v1/simulations/${sim.id}/events?limit=500`)
|
|
84
|
+
).json()) as { events: SimEvent[] };
|
|
85
|
+
return { simId: sim.id, report, events: page.events };
|
|
86
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const MODEL = {
|
|
2
|
+
components: [
|
|
3
|
+
{ id: "api", type: "service", capacity: 50, latency_ms: { base: 20, jitter_ms: 5 }, timeout_ms: 1000, queue_size: 200, queue_policy: "drop" },
|
|
4
|
+
{ id: "cache", type: "cache", capacity: 100, latency_ms: { base: 2, jitter_ms: 1 }, timeout_ms: 200, queue_size: 500, queue_policy: "drop" },
|
|
5
|
+
{ id: "database", type: "database", capacity: 20, latency_ms: { base: 5, jitter_ms: 1 }, timeout_ms: 500, queue_size: 100, queue_policy: "fifo" }
|
|
6
|
+
],
|
|
7
|
+
connections: [
|
|
8
|
+
{ from: "api", to: "cache" },
|
|
9
|
+
{ from: "api", to: "database" },
|
|
10
|
+
{ from: "cache", to: "database" }
|
|
11
|
+
]
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const SCENARIOS = [
|
|
15
|
+
{ name: "baseline", duration_s: 60, workload: { rate_rps: 100 }, faults: [] },
|
|
16
|
+
{ name: "db-down", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "database", type: "unavailable", start_s: 20, duration_s: 10 }] },
|
|
17
|
+
{ name: "cache-poison", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "cache", type: "unavailable", start_s: 20, duration_s: 10 }] },
|
|
18
|
+
{ name: "latency-500ms", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "database", type: "latency", extra_ms: 500, start_s: 0, duration_s: 60 }] },
|
|
19
|
+
{ name: "traffic-10x", duration_s: 60, workload: { rate_rps: 1000 }, faults: [] },
|
|
20
|
+
{ name: "zone-loss", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "cache", type: "unavailable", start_s: 20, duration_s: 20 }, { target: "database", type: "unavailable", start_s: 20, duration_s: 20 }] },
|
|
21
|
+
{ name: "slow-dependency", duration_s: 60, workload: { rate_rps: 100 }, faults: [{ target: "cache", type: "latency", extra_ms: 100, start_s: 0, duration_s: 60 }] },
|
|
22
|
+
{ name: "queue-overflow", duration_s: 10, workload: { rate_rps: 3000 }, faults: [] }
|
|
23
|
+
];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import cytoscape from "cytoscape";
|
|
2
|
+
import type { SimEvent } from "./api";
|
|
3
|
+
|
|
4
|
+
export type NodeStatus = "ok" | "failing" | "degraded";
|
|
5
|
+
|
|
6
|
+
export function renderGraph(
|
|
7
|
+
container: HTMLElement,
|
|
8
|
+
components: { id: string; type: string }[],
|
|
9
|
+
connections: { from: string; to: string }[],
|
|
10
|
+
failures: Record<string, number>,
|
|
11
|
+
timeouts: Record<string, number>
|
|
12
|
+
): cytoscape.Core {
|
|
13
|
+
const nodes = components.map((c) => {
|
|
14
|
+
const fails = failures[c.id] ?? 0;
|
|
15
|
+
const status: NodeStatus = fails > 0 ? "failing" : (timeouts[c.id] ?? 0) > 0 ? "degraded" : "ok";
|
|
16
|
+
return { data: { id: c.id, label: `${c.id}\n${c.type} fails:${fails}`, status } };
|
|
17
|
+
});
|
|
18
|
+
const edges = connections.map((c, i) => ({ data: { id: `e${i}`, source: c.from, target: c.to } }));
|
|
19
|
+
return cytoscape({
|
|
20
|
+
container,
|
|
21
|
+
elements: [...nodes, ...edges],
|
|
22
|
+
style: [
|
|
23
|
+
{ selector: "node", style: { label: "data(label)", color: "#fff", "text-valign": "center", "text-halign": "center", "font-size": 11 } },
|
|
24
|
+
{ selector: 'node[status="ok"]', style: { "background-color": "#2e7d32" } },
|
|
25
|
+
{ selector: 'node[status="failing"]', style: { "background-color": "#c62828" } },
|
|
26
|
+
{ selector: 'node[status="degraded"]', style: { "background-color": "#ef6c00" } },
|
|
27
|
+
{ selector: "edge", style: { width: 2, "line-color": "#888", "target-arrow-shape": "triangle", "target-arrow-color": "#888" } }
|
|
28
|
+
],
|
|
29
|
+
layout: { name: "breadthfirst", directed: true, padding: 30 }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function failuresUpTo(events: SimEvent[], tMs: number): Record<string, number> {
|
|
34
|
+
const out: Record<string, number> = {};
|
|
35
|
+
for (const e of events) {
|
|
36
|
+
if (e.finish_ms <= tMs && !e.ok && e.failed_at) out[e.failed_at] = (out[e.failed_at] ?? 0) + 1;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function activeFaults(faults: { target: string; type: string; start_s: number; duration_s: number }[], tS: number): string[] {
|
|
42
|
+
return faults
|
|
43
|
+
.filter((f) => f.start_s <= tS && tS < f.start_s + f.duration_s)
|
|
44
|
+
.map((f) => `${f.type}@${f.target}`);
|
|
45
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"noUnusedLocals": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"types": ["vite/client"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src", "vite.config.ts"]
|
|
14
|
+
}
|