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:
Shay 2026-05-26 10:16:35 -07:00 committed by GitHub
parent 0909ef2782
commit 8a24ebe726
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1262 additions and 9 deletions

View file

@ -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 <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, ...]] = {
"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",

View file

@ -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 |

View file

@ -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
```

View file

@ -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

View file

@ -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

View file

@ -46,6 +46,7 @@ include = [
"session*",
"vault*",
"vocab*",
"workbench*",
]
[tool.pytest.ini_options]

318
tests/test_workbench_api.py Normal file
View 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

View 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

View 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
View file

@ -0,0 +1,3 @@
"""CORE Workbench local operator API."""
__all__ = []

91
workbench/api.py Normal file
View 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
View 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
View 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
View 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())