From 8a24ebe72606bdc23345fdfd2a25fcaf04524bf7 Mon Sep 17 00:00:00 2001 From: Shay Date: Tue, 26 May 2026 10:16:35 -0700 Subject: [PATCH] feat(W-026): read-only workbench API (ADR-0160 Phase 1) (#292) * feat(W-026): add read-only workbench API * fix(workbench): harden read-only API review gaps --- core/cli.py | 36 ++- docs/workbench/README.md | 60 +++++ docs/workbench/acceptance-gates.md | 16 ++ docs/workbench/api-contract-v1.md | 83 ++++++- docs/workbench/implementation-plan.md | 34 ++- pyproject.toml | 1 + tests/test_workbench_api.py | 318 ++++++++++++++++++++++++++ tests/test_workbench_readers.py | 96 ++++++++ tests/test_workbench_schemas.py | 27 +++ workbench/__init__.py | 3 + workbench/api.py | 91 ++++++++ workbench/readers.py | 300 ++++++++++++++++++++++++ workbench/schemas.py | 134 +++++++++++ workbench/server.py | 72 ++++++ 14 files changed, 1262 insertions(+), 9 deletions(-) create mode 100644 tests/test_workbench_api.py create mode 100644 tests/test_workbench_readers.py create mode 100644 tests/test_workbench_schemas.py create mode 100644 workbench/__init__.py create mode 100644 workbench/api.py create mode 100644 workbench/readers.py create mode 100644 workbench/schemas.py create mode 100644 workbench/server.py diff --git a/core/cli.py b/core/cli.py index ab556f5c..d3698bdb 100644 --- a/core/cli.py +++ b/core/cli.py @@ -23,7 +23,7 @@ _CORE_RS_DIR = _REPO_ROOT / "core-rs" _CORE_RS_MANIFEST = _CORE_RS_DIR / "Cargo.toml" DESCRIPTION = "CORE versor engine command suite." -EPILOG = "Examples:\n core chat\n core pulse \"What is truth?\"\n core pulse --no-glove --json \"Compare knowledge and wisdom\"\n core bench\n core bench --suite all\n core bench --suite all --json --report bench_all.json\n core bench --suite determinism --runs 50\n core bench --suite speedup --json\n core trace \"word beginning truth\"\n core trace --output-language grc --frame-pack grc --json \"logos\"\n core rust status\n core rust build\n core oov covenant\n core pack list\n core pack verify en_minimal_v1\n core teaching audit\n core teaching audit --json\n core teaching gaps --top 10\n core teaching queue --threshold 3\n core teaching propose \n core teaching proposals --state pending\n core teaching review --accept --review-date 2026-05-18\n core teaching supersede cause_light_reveals_truth --subject light --intent cause --connective grounds --object truth --review-date 2026-05-18\n core teaching supersessions\n core teaching supersessions --json\n core test --suite fast -q\n core test --suite pulse -q\n core test --suite proof -q\n core test --suite cognition -q\n core test -- tests/test_alignment_graph.py -q\n core demo audit-tour\n core demo register-tour\n core demo anchor-lens-tour\n core demo orthogonality-tour\n core demo pack-measurements\n core demo long-context-comparison\n core demo anti-regression\n core demo learning-loop\n core demo learning-arc\n core demo articulation\n core demo conversation\n core demo conversation --no-stream\n core demo all\n core demo adr-0024-chain\n core eval --list\n core eval cognition\n core eval cognition --json --save\n core eval cognition --split dev --version v1\n core eval cognition --split holdout\n core eval contemplation_quality\n core eval contemplation_quality --json --save" +EPILOG = "Examples:\n core chat\n core pulse \"What is truth?\"\n core pulse --no-glove --json \"Compare knowledge and wisdom\"\n core bench\n core bench --suite all\n core bench --suite all --json --report bench_all.json\n core bench --suite determinism --runs 50\n core bench --suite speedup --json\n core trace \"word beginning truth\"\n core trace --output-language grc --frame-pack grc --json \"logos\"\n core rust status\n core rust build\n core oov covenant\n core pack list\n core pack verify en_minimal_v1\n core teaching audit\n core teaching audit --json\n core teaching gaps --top 10\n core teaching queue --threshold 3\n core teaching propose \n core teaching proposals --state pending\n core teaching review --accept --review-date 2026-05-18\n core teaching supersede cause_light_reveals_truth --subject light --intent cause --connective grounds --object truth --review-date 2026-05-18\n core teaching supersessions\n core teaching supersessions --json\n core test --suite fast -q\n core test --suite pulse -q\n core test --suite proof -q\n core test --suite cognition -q\n core test -- tests/test_alignment_graph.py -q\n core demo audit-tour\n core demo register-tour\n core demo anchor-lens-tour\n core demo orthogonality-tour\n core demo pack-measurements\n core demo long-context-comparison\n core demo anti-regression\n core demo learning-loop\n core demo learning-arc\n core demo articulation\n core demo conversation\n core demo conversation --no-stream\n core demo all\n core demo adr-0024-chain\n core eval --list\n core eval cognition\n core eval cognition --json --save\n core eval cognition --split dev --version v1\n core eval cognition --split holdout\n core eval contemplation_quality\n core eval contemplation_quality --json --save\n core workbench api\n core workbench api --port 9000\n core workbench api --host 0.0.0.0 --allow-nonlocal-bind" _TEST_SUITES: dict[str, tuple[str, ...]] = { "fast": ( @@ -1795,6 +1795,18 @@ def cmd_eval(args: argparse.Namespace) -> int: return 0 +def cmd_workbench(args: argparse.Namespace) -> int: + """Run CORE Workbench local operator surfaces.""" + if args.workbench_command == "api": + from workbench.server import main as workbench_api_main + + argv = ["--host", args.host, "--port", str(args.port)] + if args.allow_nonlocal_bind: + argv.append("--allow-nonlocal-bind") + return workbench_api_main(argv) + _die("workbench requires a subcommand") + + def cmd_pulse(args: argparse.Namespace) -> int: """Run a cognitive pulse and display recalled words + realized surface.""" from scripts.run_pulse import run_pulse @@ -3680,6 +3692,28 @@ def build_parser() -> argparse.ArgumentParser: rust_test = rust_sub.add_parser("test", help="run cargo test --release for core-rs") rust_test.set_defaults(func=cmd_rust_test) + workbench = subparsers.add_parser( + "workbench", + help="run CORE Workbench local operator surfaces", + ) + workbench_sub = workbench.add_subparsers( + dest="workbench_command", + metavar="workbench-command", + required=True, + ) + workbench_api = workbench_sub.add_parser( + "api", + help="start the W-026 read-only local Workbench API", + ) + workbench_api.add_argument("--host", default="127.0.0.1") + workbench_api.add_argument("--port", type=int, default=8765) + workbench_api.add_argument( + "--allow-nonlocal-bind", + action="store_true", + help="allow binding to a host other than 127.0.0.1 or localhost", + ) + workbench_api.set_defaults(func=cmd_workbench) + pulse = subparsers.add_parser( "pulse", help="run a cognitive pulse from injection to realized surface", diff --git a/docs/workbench/README.md b/docs/workbench/README.md index 04b1b98c..406b2c0a 100644 --- a/docs/workbench/README.md +++ b/docs/workbench/README.md @@ -136,6 +136,66 @@ mutation capability. --- +# Current Status + +The planning package is merged on `main` via `404e694` +(`docs(workbench): CORE Workbench v1 planning architecture (ADR-0160)`). + +The prototype branch `feat/w026-workbench-readonly-api` is superseded and must +not be used as the implementation base. It mixed W-026 with frontend and trace +work, added auth and web-framework dependencies before the local read-only +boundary was proven, and included placeholder replay/trace behavior that could +be mistaken for evidence. + +The next accepted implementation starts clean from `main` with W-026 only: +dataclass schemas, repo-root-constrained readers, a standard-library local HTTP +API, and route/read-model tests. W-027 and later phases build on that boundary +after it is accepted. + +--- + +# W-026 Local Runbook + +Start the read-only local API: + +```bash +core workbench api +``` + +Verify liveness: + +```bash +curl http://127.0.0.1:8765/health +``` + +Inspect each W-026 endpoint family: + +```bash +curl http://127.0.0.1:8765/runtime/status +curl http://127.0.0.1:8765/artifacts +curl http://127.0.0.1:8765/artifacts/evals/contemplation_quality/contract.md +curl http://127.0.0.1:8765/proposals +curl http://127.0.0.1:8765/evals +curl http://127.0.0.1:8765/evals/contemplation_quality +curl -X POST http://127.0.0.1:8765/evals/run \ + -H 'Content-Type: application/json' \ + -d '{"lane":"contemplation_quality","version":"v1","split":"public"}' +``` + +Bind to a different local port: + +```bash +core workbench api --port 9000 +``` + +Non-local binds require an explicit operator flag: + +```bash +core workbench api --host 0.0.0.0 --allow-nonlocal-bind +``` + +--- + # Initial Work Queue | Work Item | Goal | diff --git a/docs/workbench/acceptance-gates.md b/docs/workbench/acceptance-gates.md index 1c7938ac..d5ba2c6b 100644 --- a/docs/workbench/acceptance-gates.md +++ b/docs/workbench/acceptance-gates.md @@ -23,18 +23,34 @@ The planning branch is acceptable when it includes: Required: - local-only API defaults to `127.0.0.1` +- non-local bind requires an explicit operator flag - typed response schemas - route tests - path traversal tests for artifact readers +- proposal event-log read-model tests using `ProposalLog.current_state()` +- unknown trace ids return `404`, not placeholder success payloads - no proposal accept/reject route - no corpus mutation route - no pack mutation route - no workflow dispatch route - no hidden background worker +- no frontend commits +- no auth surface +- no FastAPI/uvicorn/pydantic dependency + +Blockers: + +- fake replay equality by comparing an artifact digest to itself +- placeholder trace-as-success responses +- parsing proposal events as proposal records +- non-local bind without explicit operator opt-in +- auth added before the local read-only boundary is accepted +- frontend, visual intro, trace drawer, or chat UI included in W-026 Acceptance command candidates: ```bash +uv run python -m pytest tests/test_workbench_schemas.py -q uv run python -m pytest tests/test_workbench_api.py -q uv run python -m pytest tests/test_workbench_readers.py -q ``` diff --git a/docs/workbench/api-contract-v1.md b/docs/workbench/api-contract-v1.md index 3cf458fe..78073240 100644 --- a/docs/workbench/api-contract-v1.md +++ b/docs/workbench/api-contract-v1.md @@ -23,7 +23,10 @@ without widening the mutation surface. ## Response envelope -All responses SHOULD follow: +All responses MUST include `generated_at` as an ISO-8601 UTC timestamp. +`generated_at` is wall-clock UTC and not part of replay state. Clients hashing +or caching responses must exclude this field. +Successful responses: ```json { @@ -38,6 +41,7 @@ Errors: ```json { "ok": false, + "generated_at": "2026-05-26T00:00:00Z", "error": { "code": "not_found", "message": "artifact not found" @@ -45,6 +49,51 @@ Errors: } ``` +W-026 error codes: + +- `bad_request` +- `not_found` +- `unsupported` +- `read_error` +- `eval_failed` +- `runtime_unavailable` + +`unauthorized` is reserved for a later auth ADR. W-026 remains unauthenticated +and local-only by default. + +Exception mapping: + +| Condition | HTTP status | Error code | +|---|---:|---| +| malformed request, invalid path, unsafe traversal | 400 | `bad_request` | +| well-formed missing artifact/proposal/lane/trace | 404 | `not_found` | +| artifact exceeds the W-026 read size limit | 413 | `read_error` | +| deferred W-027+ route | 501 | `unsupported` | +| filesystem read failure | 500 | `read_error` | +| unexpected runtime failure | 500 | `runtime_unavailable` | + +## W-026 execution model + +The W-026 API is a single-operator local service. It does not implement CORS +preflight; browser clients must run on the same origin until W-027 defines the +frontend serving model. Eval execution is serialized per server instance so +two `/evals/run` requests cannot race over shared runtime checkpoint files. + +### Side effects on `engine_state/` + +W-026 is read-only with respect to teaching corpora and pack data. Two routes +touch runtime checkpoint surfaces governed by existing ADRs: + +- `GET /runtime/status` instantiates `EngineStateStore` and reads + `engine_state/manifest.json` via `load_manifest`. +- `POST /evals/run` with `lane="contemplation_quality"` transitively invokes + the ADR-0159 replay baseline. That path can create normal `ChatRuntime` + checkpoints under `engine_state/`, governed by ADR-0146 and ADR-0150. + +These checkpoint writes are runtime artifacts, not teaching/corpus/pack +mutation. They are intentionally excluded from the W-026 read-only snapshot +invariant. + --- # Runtime @@ -139,6 +188,12 @@ Purpose: Inspect trace evidence for a turn. +W-026 status: + +Trace storage is not implemented in W-026. Unknown turn ids MUST return +`404 not_found`. The API must never return a synthetic empty trace as a +successful response. + Response: ```json @@ -170,6 +225,13 @@ Purpose: Return replay comparison information. +Important: + +The API must not claim `"equivalent": true` unless a real replay/compare path +has run. Comparing an artifact digest to itself is not replay evidence. Until +the replay theater is wired, this route should return `unsupported` or a +non-equivalent comparison with explicit divergence evidence. + Response: ```json @@ -178,9 +240,16 @@ Response: "data": { "artifact_id": "artifact-001", "original_hash": "sha256:...", - "replay_hash": "sha256:...", - "equivalent": true, - "divergences": [] + "replay_hash": null, + "equivalent": false, + "divergences": [ + { + "path": "$", + "original": "artifact digest", + "replay": null, + "severity": "info" + } + ] } } ``` @@ -195,6 +264,12 @@ Purpose: List proposal metadata. +Implementation rule: + +Proposal reads MUST derive their current view by replaying +`teaching.proposals.ProposalLog.current_state()`. The append-only JSONL file is +an event log, not a table of proposal records. + Response: ```json diff --git a/docs/workbench/implementation-plan.md b/docs/workbench/implementation-plan.md index a20916c1..44ebd3da 100644 --- a/docs/workbench/implementation-plan.md +++ b/docs/workbench/implementation-plan.md @@ -8,7 +8,7 @@ boundaries while making the system legible to operators, engineers, and auditors | Work item | Title | Scope | Mutation allowed? | |---|---|---|---| -| W-026 | Read-only API contract | endpoint schemas + local server surface | No | +| W-026 | Read-only API contract | endpoint schemas + stdlib local server surface only | No | | W-027 | Frontend shell | navigation, layout, design tokens, empty states | No | | W-028 | Chat + trace drawer | basic chat, turn metadata, trace drawer | Runtime turn only | | W-029 | Proposal queue | proposal-log read model, detail view, CLI-copy affordance | No | @@ -39,6 +39,7 @@ Deliverables: - `workbench/api.py` - `workbench/schemas.py` - `workbench/readers.py` +- `workbench/server.py` - route tests with temp fixtures - CLI command: `core workbench api --host 127.0.0.1 --port 8765` @@ -47,11 +48,22 @@ Initial endpoints: - `GET /health` - `GET /runtime/status` - `GET /artifacts` +- `GET /artifacts/{artifact_id}` - `GET /proposals` +- `GET /proposals/{proposal_id}` - `GET /evals` - `GET /evals/{lane}` +- `POST /evals/run` -Strict rule: no proposal accept/reject endpoints. +Strict rules: + +- W-026 is API-only. No frontend, auth, visual intro, trace drawer, chat UI, + live chat endpoint, or replay theater. +- Use the Python standard-library HTTP server for W-026. Defer FastAPI or any + other web framework unless a later ADR admits the dependency. +- No proposal accept/reject endpoints. +- No synthetic trace/replay evidence. Routes for later phases must return + `unsupported` or `not_found` until a real evidence path exists. ### Phase 2 — Frontend shell @@ -153,12 +165,14 @@ Deliverables: ## API design constraints -- All schemas must be explicit dataclasses or pydantic models. +- All schemas must be explicit dataclasses in W-026. - No raw arbitrary filesystem reads from user-provided paths. - Artifact readers must be rooted under known repo directories. - Every response should contain enough metadata to audit source path, digest, and timestamp when available. - Mutation endpoints are forbidden in v1. +- Proposal reads must derive state from `ProposalLog.current_state()`, not by + treating append-only JSONL events as proposal rows. ## Frontend constraints @@ -194,12 +208,24 @@ The v1 workbench is accepted when an operator can: ## Explicit deferrals -- auth - multi-user collaboration - cloud deployment - mobile UI +- auth, unless a separate ADR admits it after the local read-only boundary is + proven - proposal accept/reject buttons - workflow dispatch - packaged desktop app - public marketing site - arbitrary plugin/tool execution + +## Prototype branch rejection criteria + +Do not continue from a prototype branch that: + +- mixes W-026 with W-027/W-028 frontend or trace/chat work +- adds auth before the local read-only API boundary is accepted +- adds FastAPI, uvicorn, pydantic, or another web framework without ADR review +- claims replay equivalence by comparing an artifact digest to itself +- returns placeholder trace data as a successful trace response +- parses proposal log events as if they were proposal records diff --git a/pyproject.toml b/pyproject.toml index 16d75dcd..6b4fe1d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ include = [ "session*", "vault*", "vocab*", + "workbench*", ] [tool.pytest.ini_options] diff --git a/tests/test_workbench_api.py b/tests/test_workbench_api.py new file mode 100644 index 00000000..ddabef7f --- /dev/null +++ b/tests/test_workbench_api.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import json +import socket +import threading +from http.server import ThreadingHTTPServer +from pathlib import Path + +import pytest + +from workbench import readers +from workbench.api import WorkbenchApi +from workbench import server +from workbench.server import WorkbenchRequestHandler + + +def _request(method: str, path: str, body: dict | None = None): + raw = b"" if body is None else json.dumps(body).encode("utf-8") + return WorkbenchApi().handle(method, path, raw) + + +def _without_generated_at(payload: dict) -> dict: + out = dict(payload) + out.pop("generated_at", None) + return out + + +def _snapshot(root: Path) -> dict[str, bytes]: + snap: dict[str, bytes] = {} + if not root.exists(): + return snap + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root) + if "__pycache__" in rel.parts or rel.suffix in {".pyc", ".pyo"}: + continue + snap[rel.as_posix()] = path.read_bytes() + return snap + + +def _restore_snapshot(root: Path, snap: dict[str, bytes]) -> None: + if root.exists(): + for path in sorted(root.rglob("*"), reverse=True): + if path.is_file(): + rel = path.relative_to(root).as_posix() + if rel not in snap: + path.unlink() + for path in sorted(root.rglob("*"), reverse=True): + if path.is_dir(): + try: + path.rmdir() + except OSError: + pass + for rel, content in snap.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + +def test_health_route_returns_ok() -> None: + response = _request("GET", "/health") + + assert response.status == 200 + assert response.payload["ok"] is True + assert response.payload["data"] == {"status": "ok"} + assert response.payload["generated_at"] + + +def test_generated_at_is_only_route_payload_nondeterminism() -> None: + first = _request("GET", "/health").payload + second = _request("GET", "/health").payload + + assert _without_generated_at(first) == _without_generated_at(second) + + +def test_runtime_status_is_read_only_shape() -> None: + response = _request("GET", "/runtime/status") + + assert response.status == 200 + data = response.payload["data"] + assert data["mutation_mode"] == "read_only" + assert "git_revision" in data + assert "engine_state_present" in data + + +def test_artifact_path_traversal_is_rejected() -> None: + response = _request("GET", "/artifacts/../../pyproject.toml") + + assert response.status == 400 + assert response.payload["ok"] is False + assert response.payload["error"]["code"] == "bad_request" + + +def test_well_formed_missing_artifact_returns_404() -> None: + response = _request("GET", "/artifacts/evals/does-not-exist.json") + + assert response.status == 404 + assert response.payload["error"]["code"] == "not_found" + + +def test_artifacts_list_uses_envelope() -> None: + response = _request("GET", "/artifacts?limit=5") + + assert response.status == 200 + assert response.payload["ok"] is True + assert isinstance(response.payload["data"]["items"], list) + + +def test_known_artifact_detail_round_trip() -> None: + response = _request("GET", "/artifacts/evals/contemplation_quality/contract.md") + + assert response.status == 200 + assert response.payload["data"]["path"] == "evals/contemplation_quality/contract.md" + assert response.payload["data"]["digest"].startswith("sha256:") + + +def test_artifact_size_guard_returns_413(monkeypatch) -> None: + monkeypatch.setattr(readers, "MAX_ARTIFACT_BYTES", 1) + + response = _request("GET", "/artifacts/evals/contemplation_quality/contract.md") + + assert response.status == 413 + assert response.payload["error"]["code"] == "read_error" + + +def test_proposals_list_is_read_only_envelope() -> None: + response = _request("GET", "/proposals") + + assert response.status == 200 + assert response.payload["ok"] is True + assert "items" in response.payload["data"] + + +def test_missing_proposal_returns_404() -> None: + response = _request("GET", "/proposals/not-a-real-proposal") + + assert response.status == 404 + assert response.payload["ok"] is False + assert response.payload["error"]["code"] == "not_found" + + +def test_eval_lanes_route_returns_items() -> None: + response = _request("GET", "/evals") + + assert response.status == 200 + assert response.payload["ok"] is True + assert isinstance(response.payload["data"]["lanes"], list) + + +def test_eval_lane_detail_returns_read_only_flag() -> None: + response = _request("GET", "/evals/contemplation_quality") + + assert response.status == 200 + assert response.payload["data"]["lane"] == "contemplation_quality" + assert response.payload["data"]["read_only"] is True + + +def test_eval_run_rejects_unsafe_lane() -> None: + response = _request( + "POST", + "/evals/run", + {"lane": "cognition", "version": "v1", "split": "public"}, + ) + + assert response.status == 400 + assert response.payload["ok"] is False + assert response.payload["error"]["code"] == "bad_request" + assert "not workbench-safe" in response.payload["error"]["message"] + + +def test_eval_run_rejects_holdout() -> None: + response = _request( + "POST", + "/evals/run", + {"lane": "contemplation_quality", "version": "v1", "split": "holdout"}, + ) + + assert response.status == 400 + assert response.payload["ok"] is False + assert response.payload["error"]["code"] == "bad_request" + + +def test_eval_run_rejects_unknown_version_without_path_leak() -> None: + response = _request( + "POST", + "/evals/run", + {"lane": "contemplation_quality", "version": "v999", "split": "public"}, + ) + + assert response.status == 400 + assert response.payload["error"]["code"] == "bad_request" + assert "unsupported eval version" in response.payload["error"]["message"] + assert "cases.jsonl" not in response.payload["error"]["message"] + + +def test_unknown_trace_returns_404_not_placeholder_success() -> None: + response = _request("GET", "/trace/not-a-real-turn") + + assert response.status == 404 + assert response.payload["ok"] is False + assert response.payload["error"]["code"] == "not_found" + + +def test_chat_and_replay_are_explicitly_unsupported_in_w026() -> None: + chat = _request("POST", "/chat/turn", {"prompt": "What is truth?"}) + replay = _request("GET", "/replay/evals/cognition/results/example.json") + + assert chat.status == 501 + assert chat.payload["error"]["code"] == "unsupported" + assert replay.status == 501 + assert replay.payload["error"]["code"] == "unsupported" + + +def test_unexpected_dispatch_error_stays_json(monkeypatch) -> None: + def fail_runtime_status(): + raise TypeError("boom") + + monkeypatch.setattr(readers, "runtime_status", fail_runtime_status) + + response = _request("GET", "/runtime/status") + + assert response.status == 500 + assert response.payload["ok"] is False + assert response.payload["generated_at"] + assert response.payload["error"]["code"] == "runtime_unavailable" + + +def test_full_w026_route_table_preserves_teaching_and_pack_bytes() -> None: + """W-026 read-only invariant. + + The Workbench API must not mutate teaching corpora or pack data. Normal + runtime checkpoints under engine_state/ are governed by ADR-0146/0150/0159 + and are restored after this test to keep the worktree clean. + """ + repo_root = Path(__file__).resolve().parent.parent + guarded = { + "teaching": repo_root / "teaching", + "packs": repo_root / "packs", + "language_packs/data": repo_root / "language_packs" / "data", + } + before = {name: _snapshot(path) for name, path in guarded.items()} + engine_state = repo_root / "engine_state" + engine_state_before = _snapshot(engine_state) + + try: + calls = [ + ("GET", "/health", None), + ("GET", "/runtime/status", None), + ("GET", "/artifacts?limit=5", None), + ("GET", "/artifacts/evals/contemplation_quality/contract.md", None), + ("GET", "/proposals", None), + ("GET", "/evals", None), + ("GET", "/evals/contemplation_quality", None), + ( + "POST", + "/evals/run", + {"lane": "contemplation_quality", "version": "v1", "split": "public"}, + ), + ] + responses = [_request(method, path, body) for method, path, body in calls] + finally: + _restore_snapshot(engine_state, engine_state_before) + + assert all(response.status == 200 for response in responses) + after = {name: _snapshot(path) for name, path in guarded.items()} + assert before == after + + +def test_server_rejects_nonlocal_bind_without_explicit_flag(monkeypatch) -> None: + called = False + + def fake_serve(*, host: str, port: int) -> None: + nonlocal called + called = True + + monkeypatch.setattr(server, "serve", fake_serve) + + with pytest.raises(SystemExit): + server.main(["--host", "0.0.0.0"]) + + assert called is False + + +def test_server_allows_nonlocal_bind_with_explicit_flag(monkeypatch) -> None: + seen: dict[str, object] = {} + + def fake_serve(*, host: str, port: int) -> None: + seen["host"] = host + seen["port"] = port + + monkeypatch.setattr(server, "serve", fake_serve) + + assert server.main(["--host", "0.0.0.0", "--port", "9000", "--allow-nonlocal-bind"]) is None + assert seen == {"host": "0.0.0.0", "port": 9000} + + +def test_invalid_content_length_does_not_crash_server() -> None: + httpd = ThreadingHTTPServer(("127.0.0.1", 0), WorkbenchRequestHandler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + host, port = httpd.server_address + try: + with socket.create_connection((host, port), timeout=5) as sock: + sock.sendall( + b"POST /evals/run HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Content-Length: -1\r\n" + b"Connection: close\r\n\r\n" + ) + data = sock.recv(4096) + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + + assert b"HTTP/1.0 400" in data or b"HTTP/1.1 400" in data + assert b"application/json" in data diff --git a/tests/test_workbench_readers.py b/tests/test_workbench_readers.py new file mode 100644 index 00000000..0406f38a --- /dev/null +++ b/tests/test_workbench_readers.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from workbench.readers import list_artifacts, list_proposals, read_artifact, read_proposal + + +@pytest.mark.parametrize( + "artifact_id", + [ + "../../pyproject.toml", + "../engine_state/manifest.json", + "/etc/passwd", + ], +) +def test_read_artifact_rejects_path_traversal(artifact_id: str) -> None: + with pytest.raises(ValueError): + read_artifact(artifact_id) + + +def test_read_artifact_missing_file_raises_not_found() -> None: + with pytest.raises(FileNotFoundError): + read_artifact("evals/does-not-exist.json") + + +def test_read_known_allowed_artifact_when_present() -> None: + detail = read_artifact("evals/contemplation_quality/contract.md") + + assert detail.path == "evals/contemplation_quality/contract.md" + assert detail.digest and detail.digest.startswith("sha256:") + assert detail.content_type == "text" + + +def test_list_artifacts_hashes_without_reading_whole_files(monkeypatch) -> None: + def fail_read_bytes(self: Path) -> bytes: + raise AssertionError(f"list_artifacts should not call read_bytes: {self}") + + monkeypatch.setattr(Path, "read_bytes", fail_read_bytes) + + items = list_artifacts(limit=3) + + assert len(items) <= 3 + assert all(item.digest and item.digest.startswith("sha256:") for item in items) + + +def test_proposals_use_append_only_event_log_current_state(tmp_path: Path) -> None: + log_path = tmp_path / "proposals.jsonl" + proposal_id = "proposal-001" + proposal = { + "proposal_id": proposal_id, + "source_candidate_id": "candidate-001", + "proposed_chain": { + "subject": "alpha", + "intent": "cause", + "connective": "causes", + "object": "beta", + }, + "polarity": "affirms", + "claim_domain": "descriptive", + "evidence": [], + "source": { + "kind": "contemplation", + "source_id": "candidate-001", + "emitted_at_revision": "abc123", + }, + "review_state": "pending", + "operator_note": "", + "replay_evidence": None, + "provenance": None, + } + replay = { + "baseline": {"accuracy": 1.0}, + "candidate": {"accuracy": 1.0}, + "regressed_metrics": [], + "replay_equivalent": True, + } + events = [ + {"event": "created", "proposal": proposal}, + {"event": "replay", "proposal_id": proposal_id, "replay_evidence": replay}, + ] + log_path.write_text( + "\n".join(json.dumps(event, sort_keys=True) for event in events) + "\n", + encoding="utf-8", + ) + + summaries = list_proposals(log_path=log_path) + detail = read_proposal(proposal_id, log_path=log_path) + + assert [p.proposal_id for p in summaries] == [proposal_id] + assert summaries[0].source_kind == "contemplation" + assert summaries[0].replay_equivalent is True + assert detail.proposed_chain == proposal["proposed_chain"] + assert detail.replay_evidence == replay diff --git a/tests/test_workbench_schemas.py b/tests/test_workbench_schemas.py new file mode 100644 index 00000000..23ee7ff6 --- /dev/null +++ b/tests/test_workbench_schemas.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from workbench.schemas import RuntimeStatus, error, ok + + +def test_ok_envelope_contains_generated_at_and_data() -> None: + payload = ok(RuntimeStatus( + backend="numpy", + git_revision="abc123", + engine_state_present=False, + checkpoint_revision="unknown", + revision_warning=False, + active_session_id=None, + )) + + assert payload["ok"] is True + assert isinstance(payload["generated_at"], str) + assert payload["data"]["backend"] == "numpy" + assert payload["data"]["mutation_mode"] == "read_only" + + +def test_error_envelope_contains_generated_at_and_code() -> None: + payload = error("not_found", "missing") + + assert payload["ok"] is False + assert isinstance(payload["generated_at"], str) + assert payload["error"] == {"code": "not_found", "message": "missing"} diff --git a/workbench/__init__.py b/workbench/__init__.py new file mode 100644 index 00000000..40478feb --- /dev/null +++ b/workbench/__init__.py @@ -0,0 +1,3 @@ +"""CORE Workbench local operator API.""" + +__all__ = [] diff --git a/workbench/api.py b/workbench/api.py new file mode 100644 index 00000000..55b5b014 --- /dev/null +++ b/workbench/api.py @@ -0,0 +1,91 @@ +"""Small stdlib route layer for CORE Workbench W-026.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any +from urllib.parse import parse_qs, unquote, urlparse + +from workbench import readers +from workbench.readers import ArtifactTooLargeError +from workbench.schemas import error, ok + + +@dataclass(frozen=True, slots=True) +class ApiResponse: + status: int + payload: dict[str, Any] + + +class WorkbenchApi: + def handle(self, method: str, raw_path: str, body: bytes = b"") -> ApiResponse: + parsed = urlparse(raw_path) + path = parsed.path.rstrip("/") or "/" + query = parse_qs(parsed.query) + try: + return self._dispatch(method.upper(), path, query, body) + except json.JSONDecodeError as exc: + return ApiResponse(400, error("bad_request", "invalid JSON body", detail=str(exc))) + except ValueError as exc: + return ApiResponse(400, error("bad_request", str(exc))) + except FileNotFoundError as exc: + missing = str(exc) or "resource" + return ApiResponse(404, error("not_found", f"not found: {missing}")) + except ArtifactTooLargeError as exc: + return ApiResponse(413, error("read_error", str(exc))) + except OSError as exc: + return ApiResponse(500, error("read_error", str(exc))) + except Exception as exc: # noqa: BLE001 - API contract requires JSON errors. + return ApiResponse(500, error("runtime_unavailable", f"internal error: {exc}")) + + def _dispatch( + self, + method: str, + path: str, + query: dict[str, list[str]], + body: bytes, + ) -> ApiResponse: + if method == "GET" and path == "/health": + return ApiResponse(200, ok({"status": "ok"})) + if method == "GET" and path == "/runtime/status": + return ApiResponse(200, ok(readers.runtime_status())) + if method == "GET" and path == "/artifacts": + limit = int(query.get("limit", ["100"])[0]) + return ApiResponse(200, ok({"items": readers.list_artifacts(limit=limit)})) + if method == "GET" and path.startswith("/artifacts/"): + artifact_id = unquote(path.removeprefix("/artifacts/")) + return ApiResponse(200, ok(readers.read_artifact(artifact_id))) + if method == "GET" and path == "/proposals": + return ApiResponse(200, ok({"items": readers.list_proposals()})) + if method == "GET" and path.startswith("/proposals/"): + proposal_id = unquote(path.removeprefix("/proposals/")) + return ApiResponse(200, ok(readers.read_proposal(proposal_id))) + if method == "GET" and path == "/evals": + return ApiResponse(200, ok({"lanes": readers.list_eval_lanes()})) + if method == "GET" and path.startswith("/evals/"): + lane = unquote(path.removeprefix("/evals/")) + return ApiResponse(200, ok(readers.read_eval_lane(lane))) + if method == "POST" and path == "/evals/run": + request = json.loads(body.decode("utf-8") or "{}") + if not isinstance(request, dict): + return ApiResponse(400, error("bad_request", "eval request must be an object")) + try: + result = readers.run_safe_eval_lane( + str(request.get("lane") or ""), + version=str(request.get("version") or "v1"), + split=str(request.get("split") or "public"), + ) + except FileNotFoundError as exc: + return ApiResponse(404, error("not_found", str(exc))) + except ValueError as exc: + return ApiResponse(400, error("bad_request", str(exc))) + return ApiResponse(200, ok(result)) + if method == "GET" and path.startswith("/trace/"): + return ApiResponse(404, error("not_found", "trace storage is not wired in W-026")) + if ( + (method == "POST" and path == "/chat/turn") + or (method == "GET" and path.startswith("/replay/")) + ): + return ApiResponse(501, error("unsupported", "route is deferred beyond W-026")) + return ApiResponse(404, error("not_found", f"route not found: {method} {path}")) diff --git a/workbench/readers.py b/workbench/readers.py new file mode 100644 index 00000000..43df8d8c --- /dev/null +++ b/workbench/readers.py @@ -0,0 +1,300 @@ +"""Read-only readers for the CORE Workbench W-026 API.""" + +from __future__ import annotations + +import hashlib +import json +import os +import threading +from pathlib import Path, PurePosixPath +from typing import Any, get_args + +from engine_state import EngineStateStore, get_git_revision +from evals.framework import discover_lanes, get_lane, run_lane +from teaching.proposals import DEFAULT_PROPOSAL_LOG_PATH, ProposalLog, ReviewState +from workbench.schemas import ( + ArtifactDetail, + ArtifactRef, + EvalLaneSummary, + EvalRunResult, + ProposalDetail, + ProposalSummary, + RuntimeStatus, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] +SAFE_EVAL_LANES = frozenset({"contemplation_quality"}) +MAX_ARTIFACT_BYTES = 16 * 1024 * 1024 +READ_CHUNK_BYTES = 64 * 1024 +_EVAL_RUN_LOCK = threading.Lock() +_REVIEW_STATES = frozenset(get_args(ReviewState)) +ALLOWED_ARTIFACT_ROOTS = ( + REPO_ROOT / "engine_state", + REPO_ROOT / "teaching" / "proposals", + REPO_ROOT / "evals", + REPO_ROOT / "contemplation" / "runs", +) + + +class ArtifactTooLargeError(OSError): + """Raised when an artifact is too large for direct Workbench reads.""" + + +def _sha256_bytes(content: bytes) -> str: + return "sha256:" + hashlib.sha256(content).hexdigest() + + +def _sha256_file(path: Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(READ_CHUNK_BYTES), b""): + hasher.update(chunk) + return "sha256:" + hasher.hexdigest() + + +def _relative(path: Path) -> str: + return path.resolve().relative_to(REPO_ROOT.resolve()).as_posix() + + +def _validate_artifact_id(artifact_id: str) -> PurePosixPath: + if not artifact_id or artifact_id.startswith("/"): + raise ValueError("artifact id must be a repo-relative path") + rel = PurePosixPath(artifact_id) + if any(part in ("", ".", "..") for part in rel.parts): + raise ValueError("artifact id must not contain path traversal") + return rel + + +def _is_allowed(path: Path) -> bool: + resolved = path.resolve() + for root in ALLOWED_ARTIFACT_ROOTS: + root_resolved = root.resolve() + if resolved == root_resolved or root_resolved in resolved.parents: + return True + return False + + +def _resolve_artifact(artifact_id: str) -> Path: + rel = _validate_artifact_id(artifact_id) + candidate = (REPO_ROOT / rel.as_posix()).resolve() + if not _is_allowed(candidate): + raise ValueError("artifact path is outside allowed roots") + return candidate + + +def _artifact_kind(path: Path) -> str: + rel = _relative(path) + if rel == "engine_state/manifest.json": + return "engine_state_manifest" + if rel.startswith("teaching/proposals/"): + return "proposal" + if rel.startswith("evals/") and "/results/" in rel: + return "eval_result" + if rel.startswith("contemplation/runs/"): + return "contemplation_report" + if path.suffix == ".jsonl": + return "telemetry" + return "unknown" + + +def runtime_status() -> RuntimeStatus: + store = EngineStateStore() + manifest = store.load_manifest() or {} + current_revision = get_git_revision() + checkpoint_revision = str(manifest.get("written_at_revision") or "unknown") + backend_raw = os.environ.get("CORE_BACKEND", "numpy") + backend = backend_raw if backend_raw in {"numpy", "mlx", "rust"} else "unknown" + return RuntimeStatus( + backend=backend, # type: ignore[arg-type] + git_revision=current_revision, + engine_state_present=store.exists(), + checkpoint_revision=checkpoint_revision, + revision_warning=( + checkpoint_revision not in {"", "unknown"} + and current_revision not in {"", "unknown"} + and checkpoint_revision != current_revision + ), + active_session_id=None, + ) + + +def list_artifacts(*, limit: int = 100) -> list[ArtifactRef]: + items: list[ArtifactRef] = [] + for root in ALLOWED_ARTIFACT_ROOTS: + if not root.exists(): + continue + for path in sorted(root.rglob("*")): + if len(items) >= limit: + return items + if not path.is_file() or path.suffix not in {".json", ".jsonl", ".md", ".txt"}: + continue + if not _is_allowed(path): + continue + try: + digest = _sha256_file(path) + except OSError: + continue + rel = _relative(path) + items.append( + ArtifactRef( + artifact_id=rel, + kind=_artifact_kind(path), # type: ignore[arg-type] + path=rel, + digest=digest, + created_at=None, + ) + ) + return items + + +def read_artifact(artifact_id: str) -> ArtifactDetail: + path = _resolve_artifact(artifact_id) + if not path.exists() or not path.is_file(): + raise FileNotFoundError(artifact_id) + if path.stat().st_size > MAX_ARTIFACT_BYTES: + raise ArtifactTooLargeError( + f"artifact exceeds {MAX_ARTIFACT_BYTES} byte read limit: {artifact_id}" + ) + raw = path.read_bytes() + text = raw.decode("utf-8") + content_type = "text" + content: Any = text + if path.suffix == ".json": + content_type = "json" + content = json.loads(text) + elif path.suffix == ".jsonl": + content_type = "jsonl" + rows: list[Any] = [] + for line in text.splitlines(): + if line.strip(): + rows.append(json.loads(line)) + content = rows + rel = _relative(path) + return ArtifactDetail( + artifact_id=rel, + kind=_artifact_kind(path), # type: ignore[arg-type] + path=rel, + digest=_sha256_bytes(raw), + created_at=None, + content_type=content_type, # type: ignore[arg-type] + content=content, + ) + + +def _state_value(value: Any) -> str: + text = str(value or "unknown") + return text if text in _REVIEW_STATES else "unknown" + + +def _source_kind(source: Any) -> str: + if isinstance(source, dict): + return str(source.get("kind") or "unknown") + return "unknown" + + +def _replay_equivalent(replay: Any) -> bool | None: + if isinstance(replay, dict) and isinstance(replay.get("replay_equivalent"), bool): + return bool(replay["replay_equivalent"]) + return None + + +def _proposal_summary(proposal_id: str, record: dict[str, Any]) -> ProposalSummary: + return ProposalSummary( + proposal_id=proposal_id, + state=_state_value(record.get("state")), # type: ignore[arg-type] + source_kind=_source_kind(record.get("source")), + replay_equivalent=_replay_equivalent(record.get("replay_evidence")), + created_at=None, + downstream_effect="observed" if record.get("accepted_chain_id") else "unknown", + ) + + +def list_proposals(*, log_path: Path | None = None) -> list[ProposalSummary]: + log = ProposalLog(path=log_path or DEFAULT_PROPOSAL_LOG_PATH) + state = log.current_state() + return [ + _proposal_summary(proposal_id, state[proposal_id]) + for proposal_id in sorted(state) + ] + + +def read_proposal(proposal_id: str, *, log_path: Path | None = None) -> ProposalDetail: + log = ProposalLog(path=log_path or DEFAULT_PROPOSAL_LOG_PATH) + record = log.find(proposal_id) + if record is None: + raise FileNotFoundError(proposal_id) + proposal = record.get("proposal") if isinstance(record.get("proposal"), dict) else {} + summary = _proposal_summary(proposal_id, record) + review_state = summary.state + return ProposalDetail( + proposal_id=summary.proposal_id, + state=review_state, + source_kind=summary.source_kind, + replay_equivalent=summary.replay_equivalent, + created_at=summary.created_at, + downstream_effect=summary.downstream_effect, + proposed_chain=proposal.get("proposed_chain"), + replay_evidence=record.get("replay_evidence"), + source=record.get("source"), + evidence=proposal.get("evidence") if isinstance(proposal.get("evidence"), list) else [], + artifact_refs=[], + suggested_cli=( + f"core teaching review {proposal_id} --accept --review-date YYYY-MM-DD" + if review_state == "pending" + else None + ), + ) + + +def list_eval_lanes() -> list[EvalLaneSummary]: + return [ + EvalLaneSummary( + lane=lane.name, + versions=list(lane.versions), + read_only=lane.name in SAFE_EVAL_LANES, + description=None, + ) + for lane in discover_lanes() + ] + + +def read_eval_lane(lane_name: str) -> EvalLaneSummary: + lane = get_lane(lane_name) + return EvalLaneSummary( + lane=lane.name, + versions=list(lane.versions), + read_only=lane.name in SAFE_EVAL_LANES, + description=None, + ) + + +def run_safe_eval_lane( + lane_name: str, + *, + version: str = "v1", + split: str = "public", +) -> EvalRunResult: + if lane_name not in SAFE_EVAL_LANES: + raise ValueError(f"eval lane is not workbench-safe/read-only: {lane_name}") + if split == "holdout": + raise ValueError("holdout execution is disabled in Workbench v1") + lane = get_lane(lane_name) + if version not in lane.versions: + raise ValueError(f"unsupported eval version for {lane_name}: {version}") + with _EVAL_RUN_LOCK: + result = run_lane(lane, version=version, split=split, workers=1) + passed_raw = result.metrics.get("passed") if isinstance(result.metrics, dict) else None + passed = bool(passed_raw) if isinstance(passed_raw, bool) else None + return EvalRunResult( + lane=result.lane, + version=result.version, + split=result.split, + passed=passed, + metrics=result.metrics, + cases=result.case_details, + source_digest=( + str(result.metrics["source_digest"]) + if isinstance(result.metrics, dict) and "source_digest" in result.metrics + else None + ), + ) diff --git a/workbench/schemas.py b/workbench/schemas.py new file mode 100644 index 00000000..4238f6f9 --- /dev/null +++ b/workbench/schemas.py @@ -0,0 +1,134 @@ +"""Typed UI-facing schemas for CORE Workbench v1.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal + + +ErrorCode = Literal[ + "bad_request", + "not_found", + "unsupported", + "read_error", + "eval_failed", + "runtime_unavailable", +] + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def to_data(value: Any) -> Any: + if hasattr(value, "as_dict") and callable(value.as_dict): + return value.as_dict() + if hasattr(value, "__dataclass_fields__"): + return asdict(value) + if isinstance(value, dict): + return {str(k): to_data(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [to_data(v) for v in value] + return value + + +def ok(data: Any) -> dict[str, Any]: + return {"ok": True, "generated_at": utc_now(), "data": to_data(data)} + + +def error(code: ErrorCode, message: str, *, detail: Any | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"code": code, "message": message} + if detail is not None: + payload["detail"] = to_data(detail) + return {"ok": False, "generated_at": utc_now(), "error": payload} + + +@dataclass(frozen=True, slots=True) +class RuntimeStatus: + backend: Literal["numpy", "mlx", "rust", "unknown"] + git_revision: str + engine_state_present: bool + checkpoint_revision: str + revision_warning: bool + active_session_id: str | None + mutation_mode: Literal["read_only", "runtime_turn"] = "read_only" + + +@dataclass(frozen=True, slots=True) +class ArtifactRef: + artifact_id: str + kind: Literal[ + "trace", + "eval_result", + "proposal", + "contemplation_report", + "telemetry", + "engine_state_manifest", + "unknown", + ] + path: str + digest: str | None + created_at: str | None + + +@dataclass(frozen=True, slots=True) +class ArtifactDetail(ArtifactRef): + content_type: Literal["json", "jsonl", "text", "unknown"] + content: Any + + +@dataclass(frozen=True, slots=True) +class ProposalSummary: + proposal_id: str + state: Literal["pending", "accepted", "rejected", "withdrawn", "unknown"] + source_kind: str + replay_equivalent: bool | None + created_at: str | None + downstream_effect: Literal["unknown", "none", "observed"] + + +@dataclass(frozen=True, slots=True) +class ProposalDetail(ProposalSummary): + proposed_chain: Any + replay_evidence: Any + source: Any + evidence: list[Any] = field(default_factory=list) + artifact_refs: list[ArtifactRef] = field(default_factory=list) + suggested_cli: str | None = None + + +@dataclass(frozen=True, slots=True) +class EvalLaneSummary: + lane: str + versions: list[str] + read_only: bool + description: str | None + + +@dataclass(frozen=True, slots=True) +class EvalRunResult: + lane: str + version: str + split: str + passed: bool | None + metrics: dict[str, Any] + cases: list[Any] + source_digest: str | None = None + + +@dataclass(frozen=True, slots=True) +class ReplayDivergence: + path: str + original: Any + replay: Any + severity: Literal["info", "warning", "failure"] + + +@dataclass(frozen=True, slots=True) +class ReplayComparison: + artifact_id: str + original_hash: str | None + replay_hash: str | None + equivalent: bool + divergences: list[ReplayDivergence] = field(default_factory=list) diff --git a/workbench/server.py b/workbench/server.py new file mode 100644 index 00000000..cf178aed --- /dev/null +++ b/workbench/server.py @@ -0,0 +1,72 @@ +"""Stdlib HTTP server entrypoint for CORE Workbench W-026.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from workbench.api import WorkbenchApi + + +class WorkbenchRequestHandler(BaseHTTPRequestHandler): + api = WorkbenchApi() + + def do_GET(self) -> None: # noqa: N802 - stdlib handler API + self._handle() + + def do_POST(self) -> None: # noqa: N802 - stdlib handler API + self._handle() + + def log_message(self, format: str, *args: object) -> None: + if os.environ.get("CORE_WORKBENCH_QUIET") == "1": + return + sys.stderr.write( + "%s - - [%s] %s\n" + % (self.address_string(), self.log_date_time_string(), format % args) + ) + + def _handle(self) -> None: + try: + length = max(0, int(self.headers.get("Content-Length") or "0")) + except ValueError: + length = 0 + body = self.rfile.read(length) if length else b"" + response = self.api.handle(self.command, self.path, body) + payload = json.dumps(response.payload, ensure_ascii=False, sort_keys=True).encode("utf-8") + self.send_response(response.status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def serve(*, host: str = "127.0.0.1", port: int = 8765) -> None: + server = ThreadingHTTPServer((host, port), WorkbenchRequestHandler) + print(f"CORE Workbench API listening on http://{host}:{port}") + try: + server.serve_forever() + except KeyboardInterrupt: + print() + raise SystemExit(0) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="CORE Workbench local API") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument( + "--allow-nonlocal-bind", + action="store_true", + help="allow binding to a host other than 127.0.0.1 or localhost", + ) + args = parser.parse_args(argv) + if args.host not in {"127.0.0.1", "localhost"} and not args.allow_nonlocal_bind: + parser.error("non-local bind requires --allow-nonlocal-bind") + serve(host=args.host, port=args.port) + + +if __name__ == "__main__": + raise SystemExit(main())