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
This commit is contained in:
parent
0909ef2782
commit
8a24ebe726
14 changed files with 1262 additions and 9 deletions
36
core/cli.py
36
core/cli.py
|
|
@ -23,7 +23,7 @@ _CORE_RS_DIR = _REPO_ROOT / "core-rs"
|
||||||
_CORE_RS_MANIFEST = _CORE_RS_DIR / "Cargo.toml"
|
_CORE_RS_MANIFEST = _CORE_RS_DIR / "Cargo.toml"
|
||||||
|
|
||||||
DESCRIPTION = "CORE versor engine command suite."
|
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 <candidate-jsonl-path>\n core teaching proposals --state pending\n core teaching review <proposal_id> --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 <candidate-jsonl-path>\n core teaching proposals --state pending\n core teaching review <proposal_id> --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, ...]] = {
|
_TEST_SUITES: dict[str, tuple[str, ...]] = {
|
||||||
"fast": (
|
"fast": (
|
||||||
|
|
@ -1795,6 +1795,18 @@ def cmd_eval(args: argparse.Namespace) -> int:
|
||||||
return 0
|
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:
|
def cmd_pulse(args: argparse.Namespace) -> int:
|
||||||
"""Run a cognitive pulse and display recalled words + realized surface."""
|
"""Run a cognitive pulse and display recalled words + realized surface."""
|
||||||
from scripts.run_pulse import run_pulse
|
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 = rust_sub.add_parser("test", help="run cargo test --release for core-rs")
|
||||||
rust_test.set_defaults(func=cmd_rust_test)
|
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 = subparsers.add_parser(
|
||||||
"pulse",
|
"pulse",
|
||||||
help="run a cognitive pulse from injection to realized surface",
|
help="run a cognitive pulse from injection to realized surface",
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Initial Work Queue
|
||||||
|
|
||||||
| Work Item | Goal |
|
| Work Item | Goal |
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,34 @@ The planning branch is acceptable when it includes:
|
||||||
Required:
|
Required:
|
||||||
|
|
||||||
- local-only API defaults to `127.0.0.1`
|
- local-only API defaults to `127.0.0.1`
|
||||||
|
- non-local bind requires an explicit operator flag
|
||||||
- typed response schemas
|
- typed response schemas
|
||||||
- route tests
|
- route tests
|
||||||
- path traversal tests for artifact readers
|
- 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 proposal accept/reject route
|
||||||
- no corpus mutation route
|
- no corpus mutation route
|
||||||
- no pack mutation route
|
- no pack mutation route
|
||||||
- no workflow dispatch route
|
- no workflow dispatch route
|
||||||
- no hidden background worker
|
- 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:
|
Acceptance command candidates:
|
||||||
|
|
||||||
```bash
|
```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_api.py -q
|
||||||
uv run python -m pytest tests/test_workbench_readers.py -q
|
uv run python -m pytest tests/test_workbench_readers.py -q
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ without widening the mutation surface.
|
||||||
|
|
||||||
## Response envelope
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -38,6 +41,7 @@ Errors:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
|
"generated_at": "2026-05-26T00:00:00Z",
|
||||||
"error": {
|
"error": {
|
||||||
"code": "not_found",
|
"code": "not_found",
|
||||||
"message": "artifact 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
|
# Runtime
|
||||||
|
|
@ -139,6 +188,12 @@ Purpose:
|
||||||
|
|
||||||
Inspect trace evidence for a turn.
|
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:
|
Response:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -170,6 +225,13 @@ Purpose:
|
||||||
|
|
||||||
Return replay comparison information.
|
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:
|
Response:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -178,9 +240,16 @@ Response:
|
||||||
"data": {
|
"data": {
|
||||||
"artifact_id": "artifact-001",
|
"artifact_id": "artifact-001",
|
||||||
"original_hash": "sha256:...",
|
"original_hash": "sha256:...",
|
||||||
"replay_hash": "sha256:...",
|
"replay_hash": null,
|
||||||
"equivalent": true,
|
"equivalent": false,
|
||||||
"divergences": []
|
"divergences": [
|
||||||
|
{
|
||||||
|
"path": "$",
|
||||||
|
"original": "artifact digest",
|
||||||
|
"replay": null,
|
||||||
|
"severity": "info"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -195,6 +264,12 @@ Purpose:
|
||||||
|
|
||||||
List proposal metadata.
|
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:
|
Response:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ boundaries while making the system legible to operators, engineers, and auditors
|
||||||
|
|
||||||
| Work item | Title | Scope | Mutation allowed? |
|
| 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-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-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 |
|
| W-029 | Proposal queue | proposal-log read model, detail view, CLI-copy affordance | No |
|
||||||
|
|
@ -39,6 +39,7 @@ Deliverables:
|
||||||
- `workbench/api.py`
|
- `workbench/api.py`
|
||||||
- `workbench/schemas.py`
|
- `workbench/schemas.py`
|
||||||
- `workbench/readers.py`
|
- `workbench/readers.py`
|
||||||
|
- `workbench/server.py`
|
||||||
- route tests with temp fixtures
|
- route tests with temp fixtures
|
||||||
- CLI command: `core workbench api --host 127.0.0.1 --port 8765`
|
- CLI command: `core workbench api --host 127.0.0.1 --port 8765`
|
||||||
|
|
||||||
|
|
@ -47,11 +48,22 @@ Initial endpoints:
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
- `GET /runtime/status`
|
- `GET /runtime/status`
|
||||||
- `GET /artifacts`
|
- `GET /artifacts`
|
||||||
|
- `GET /artifacts/{artifact_id}`
|
||||||
- `GET /proposals`
|
- `GET /proposals`
|
||||||
|
- `GET /proposals/{proposal_id}`
|
||||||
- `GET /evals`
|
- `GET /evals`
|
||||||
- `GET /evals/{lane}`
|
- `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
|
### Phase 2 — Frontend shell
|
||||||
|
|
||||||
|
|
@ -153,12 +165,14 @@ Deliverables:
|
||||||
|
|
||||||
## API design constraints
|
## 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.
|
- No raw arbitrary filesystem reads from user-provided paths.
|
||||||
- Artifact readers must be rooted under known repo directories.
|
- Artifact readers must be rooted under known repo directories.
|
||||||
- Every response should contain enough metadata to audit source path, digest,
|
- Every response should contain enough metadata to audit source path, digest,
|
||||||
and timestamp when available.
|
and timestamp when available.
|
||||||
- Mutation endpoints are forbidden in v1.
|
- 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
|
## Frontend constraints
|
||||||
|
|
||||||
|
|
@ -194,12 +208,24 @@ The v1 workbench is accepted when an operator can:
|
||||||
|
|
||||||
## Explicit deferrals
|
## Explicit deferrals
|
||||||
|
|
||||||
- auth
|
|
||||||
- multi-user collaboration
|
- multi-user collaboration
|
||||||
- cloud deployment
|
- cloud deployment
|
||||||
- mobile UI
|
- mobile UI
|
||||||
|
- auth, unless a separate ADR admits it after the local read-only boundary is
|
||||||
|
proven
|
||||||
- proposal accept/reject buttons
|
- proposal accept/reject buttons
|
||||||
- workflow dispatch
|
- workflow dispatch
|
||||||
- packaged desktop app
|
- packaged desktop app
|
||||||
- public marketing site
|
- public marketing site
|
||||||
- arbitrary plugin/tool execution
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ include = [
|
||||||
"session*",
|
"session*",
|
||||||
"vault*",
|
"vault*",
|
||||||
"vocab*",
|
"vocab*",
|
||||||
|
"workbench*",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|
|
||||||
318
tests/test_workbench_api.py
Normal file
318
tests/test_workbench_api.py
Normal file
|
|
@ -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
|
||||||
96
tests/test_workbench_readers.py
Normal file
96
tests/test_workbench_readers.py
Normal file
|
|
@ -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
|
||||||
27
tests/test_workbench_schemas.py
Normal file
27
tests/test_workbench_schemas.py
Normal file
|
|
@ -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"}
|
||||||
3
workbench/__init__.py
Normal file
3
workbench/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""CORE Workbench local operator API."""
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
91
workbench/api.py
Normal file
91
workbench/api.py
Normal file
|
|
@ -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}"))
|
||||||
300
workbench/readers.py
Normal file
300
workbench/readers.py
Normal file
|
|
@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
134
workbench/schemas.py
Normal file
134
workbench/schemas.py
Normal file
|
|
@ -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)
|
||||||
72
workbench/server.py
Normal file
72
workbench/server.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Reference in a new issue