stepcheck-dev 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
backend/server.py ADDED
@@ -0,0 +1,584 @@
1
+ """DualDoorGateway — the whole HTTP surface behind Seam 2 (see GLOSSARY.md).
2
+
3
+ Door 1 (TokenFirewall proxy): POST /v1/chat/completions · POST /v1/messages.
4
+ Intercepts bloated `messages[]`, commits the latest verified code step into the
5
+ StateKernel, parks dead-end exploration turns as drafts, injects the compact
6
+ CheckoutPayload, and forwards upstream with the caller's own BYOK key — or
7
+ answers a deterministic local dry-run when no upstream is configured.
8
+
9
+ Door 2 (explicit surface): REST /v1/step/*, Streamable HTTP MCP at /mcp
10
+ (FastMCP, sharing the FastAPI lifespan — no legacy /sse), and
11
+ WS /v1/ws/{session_id} for real-time UI state streaming.
12
+
13
+ Tested only at this boundary (tests/test_seam2_gateway.py); kernel internals
14
+ are Seam 1's business.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import math
20
+ import os
21
+ import re
22
+ import uuid
23
+ from contextlib import asynccontextmanager
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import json
28
+
29
+ import httpx
30
+ from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
31
+ from fastapi.responses import RedirectResponse
32
+ from fastapi.staticfiles import StaticFiles
33
+ from mcp.server.fastmcp import FastMCP
34
+ from pydantic import BaseModel
35
+
36
+ from kernel import StateKernel
37
+
38
+ KERNEL = StateKernel()
39
+
40
+ # Compiled Studio UI (frontend/dist, built with Vite base "/studio/"). Mounted
41
+ # at /studio when present so one port serves UI + API + MCP + WS.
42
+ STUDIO_DIST = Path(
43
+ os.environ.get("STEPCHECK_STUDIO_DIST", Path(__file__).resolve().parents[1] / "frontend" / "dist")
44
+ )
45
+
46
+ _CODE_RE = re.compile(r"```python\n(.*?)```", re.S)
47
+ _PATCH_RE = re.compile(r"```stepcheck-patch\n(.*?)```", re.S)
48
+
49
+
50
+ def _count(text: str) -> int:
51
+ return math.ceil(len(text) / 4)
52
+
53
+
54
+ # --------------------------------------------------------------- WS streaming
55
+
56
+
57
+ class _WsManager:
58
+ def __init__(self) -> None:
59
+ self._conns: dict[str, list[WebSocket]] = {}
60
+
61
+ async def connect(self, session_id: str, ws: WebSocket) -> None:
62
+ await ws.accept()
63
+ self._conns.setdefault(session_id, []).append(ws)
64
+
65
+ def drop(self, session_id: str, ws: WebSocket) -> None:
66
+ conns = self._conns.get(session_id, [])
67
+ if ws in conns:
68
+ conns.remove(ws)
69
+
70
+ async def broadcast(self, session_id: str, message: dict) -> None:
71
+ for ws in list(self._conns.get(session_id, [])):
72
+ try:
73
+ await ws.send_json(message)
74
+ except Exception:
75
+ self.drop(session_id, ws)
76
+
77
+
78
+ WS = _WsManager()
79
+
80
+
81
+ def _telemetry_json(session_id: str) -> dict:
82
+ return dataclasses.asdict(KERNEL.get_telemetry(session_id))
83
+
84
+
85
+ def _verdict_json(verdict) -> dict:
86
+ return {
87
+ "accepted": verdict.accepted,
88
+ "gate": verdict.gate,
89
+ "error": str(verdict.error) if verdict.error else None,
90
+ "subtype": getattr(verdict.error, "subtype", None),
91
+ "attestation": verdict.attestation,
92
+ "dirtied": verdict.dirtied,
93
+ }
94
+
95
+
96
+ async def _broadcast_mutation(session_id: str, gate_event: dict | None = None) -> None:
97
+ if gate_event is not None:
98
+ await WS.broadcast(session_id, {"type": "gate_event", **gate_event})
99
+ await WS.broadcast(session_id, {"type": "graph", "graph": KERNEL.graph(session_id)})
100
+ await WS.broadcast(
101
+ session_id, {"type": "telemetry", "telemetry": _telemetry_json(session_id)}
102
+ )
103
+
104
+
105
+ # ---------------------------------------------- Door 2: Streamable HTTP MCP
106
+
107
+
108
+ def _build_mcp() -> FastMCP:
109
+ """Fresh FastMCP per app startup — its StreamableHTTPSessionManager only
110
+ runs once per instance, and the FastAPI lifespan may re-enter (tests,
111
+ reloads)."""
112
+ m = FastMCP(
113
+ "stepcheck-dev",
114
+ stateless_http=True,
115
+ streamable_http_path="/",
116
+ instructions=(
117
+ "Anti-context-rot state engine for coding agents: checkout a compact "
118
+ "OKF frame, commit verified steps through the 3-tier gates, lock "
119
+ "invariants, watch omission risks, and read token savings."
120
+ ),
121
+ )
122
+
123
+ @m.tool()
124
+ async def init_project_session(project_root: str) -> dict[str, Any]:
125
+ """Index a Python project into an OKF graph and open a session."""
126
+ session_id = KERNEL.init_session(project_root)
127
+ return {"session_id": session_id, "graph": KERNEL.graph(session_id)}
128
+
129
+ @m.tool()
130
+ async def checkout_context_frame(
131
+ session_id: str, node_id: str, focus: list[str] | None = None
132
+ ) -> dict[str, Any]:
133
+ """Bounded stateless checkout: the node's frame + dependency interface
134
+ skeletons. `focus=[names]` slices the target to those defs (verbatim)
135
+ plus a skeleton of the rest; `focus=[]` is a pure skeleton."""
136
+ try:
137
+ payload = KERNEL.checkout(session_id, node_id, focus=focus)
138
+ except KeyError as exc:
139
+ raise ValueError(f"unknown session or node: {exc}")
140
+ return {
141
+ "session_id": session_id,
142
+ "node_id": node_id,
143
+ "rendered": payload.rendered(),
144
+ "dependency_signatures": payload.dependency_signatures,
145
+ "locked_invariants": payload.locked_invariants,
146
+ "token_count": payload.token_count,
147
+ }
148
+
149
+ @m.tool()
150
+ async def commit_verified_step(
151
+ session_id: str,
152
+ node_id: str,
153
+ source: str | None = None,
154
+ patch: list[dict[str, Any]] | None = None,
155
+ allow_deletion: bool = False,
156
+ ) -> dict[str, Any]:
157
+ """Run a staged `source` — or a `patch` of ops folded into canonical
158
+ state ({"op":"append"|"replace_symbol"|"delete_symbol", …}) — through
159
+ Gates A/B/C; attest on pass."""
160
+ verdict = KERNEL.commit(
161
+ session_id, node_id, source, patch=patch, allow_deletion=allow_deletion
162
+ )
163
+ out = _verdict_json(verdict)
164
+ await _broadcast_mutation(session_id, gate_event={"node_id": node_id, **out})
165
+ return out
166
+
167
+ @m.tool()
168
+ async def lock_module_invariants(session_id: str, node_id: str) -> dict[str, Any]:
169
+ """Pin a verified module: every further mutation is rejected at Gate A."""
170
+ KERNEL.lock_node(session_id, node_id)
171
+ await _broadcast_mutation(session_id)
172
+ return {"node_id": node_id, "status": "LOCKED", "locked": True}
173
+
174
+ @m.tool()
175
+ async def check_omission_risks(session_id: str) -> dict[str, Any]:
176
+ """Nodes holding DIRTY_OMISSION_RISK and whether the session can finalize."""
177
+ graph = KERNEL.graph(session_id)
178
+ dirty = [
179
+ n["node_id"] for n in graph["nodes"] if n["status"] == "DIRTY_OMISSION_RISK"
180
+ ]
181
+ return {"dirty": dirty, "can_finalize": not dirty}
182
+
183
+ @m.tool()
184
+ async def get_token_savings(session_id: str) -> dict[str, Any]:
185
+ """raw_bloated_tokens vs compacted_okf_tokens and dollars saved."""
186
+ return _telemetry_json(session_id)
187
+
188
+ return m
189
+
190
+
191
+ class _McpMountProxy:
192
+ """Stable mount point whose inner ASGI app is swapped on each startup."""
193
+
194
+ def __init__(self) -> None:
195
+ self.inner = None
196
+
197
+ async def __call__(self, scope, receive, send):
198
+ if self.inner is None: # pragma: no cover — requests before startup
199
+ raise RuntimeError("MCP app not started")
200
+ await self.inner(scope, receive, send)
201
+
202
+
203
+ _MCP_MOUNT = _McpMountProxy()
204
+
205
+
206
+ # ----------------------------------------------------------------- FastAPI app
207
+
208
+
209
+ @asynccontextmanager
210
+ async def lifespan(app: FastAPI):
211
+ # The MCP session manager shares the FastAPI lifespan (streamable-http).
212
+ m = _build_mcp()
213
+ _MCP_MOUNT.inner = m.streamable_http_app()
214
+ async with m.session_manager.run():
215
+ yield
216
+
217
+
218
+ app = FastAPI(title="StepCheck DualDoorGateway", lifespan=lifespan)
219
+ app.mount("/mcp", _MCP_MOUNT)
220
+
221
+
222
+ # ------------------------------------------------------------ Door 2: REST
223
+
224
+
225
+ class InitBody(BaseModel):
226
+ project_root: str
227
+
228
+
229
+ class NodeBody(BaseModel):
230
+ session_id: str
231
+ node_id: str
232
+
233
+
234
+ class CheckoutBody(NodeBody):
235
+ focus: list[str] | None = None
236
+
237
+
238
+ class CommitBody(NodeBody):
239
+ source: str | None = None
240
+ patch: list[dict] | None = None
241
+ allow_deletion: bool = False
242
+
243
+
244
+ def _or_404(fn, *args, **kwargs):
245
+ try:
246
+ return fn(*args, **kwargs)
247
+ except KeyError as exc:
248
+ raise HTTPException(status_code=404, detail=f"unknown session or node: {exc}")
249
+
250
+
251
+ @app.post("/v1/step/init")
252
+ async def step_init(body: InitBody) -> dict:
253
+ session_id = KERNEL.init_session(body.project_root)
254
+ return {"session_id": session_id, **KERNEL.graph(session_id)}
255
+
256
+
257
+ @app.post("/v1/step/checkout")
258
+ async def step_checkout(body: CheckoutBody) -> dict:
259
+ payload = _or_404(KERNEL.checkout, body.session_id, body.node_id, focus=body.focus)
260
+ return {
261
+ "session_id": body.session_id,
262
+ "node_id": body.node_id,
263
+ "frame_markdown": payload.frame_markdown,
264
+ "dependency_signatures": payload.dependency_signatures,
265
+ "locked_invariants": payload.locked_invariants,
266
+ "token_count": payload.token_count,
267
+ "rendered": payload.rendered(),
268
+ }
269
+
270
+
271
+ @app.post("/v1/step/commit")
272
+ async def step_commit(body: CommitBody) -> dict:
273
+ verdict = _or_404(
274
+ KERNEL.commit,
275
+ body.session_id,
276
+ body.node_id,
277
+ body.source,
278
+ patch=body.patch,
279
+ allow_deletion=body.allow_deletion,
280
+ )
281
+ out = _verdict_json(verdict)
282
+ await _broadcast_mutation(body.session_id, gate_event={"node_id": body.node_id, **out})
283
+ return out
284
+
285
+
286
+ @app.post("/v1/step/lock")
287
+ async def step_lock(body: NodeBody) -> dict:
288
+ _or_404(KERNEL.lock_node, body.session_id, body.node_id)
289
+ await _broadcast_mutation(body.session_id)
290
+ return {"node_id": body.node_id, "status": "LOCKED", "locked": True}
291
+
292
+
293
+ @app.get("/v1/step/graph/{session_id}")
294
+ async def step_graph(session_id: str) -> dict:
295
+ return _or_404(KERNEL.graph, session_id)
296
+
297
+
298
+ @app.get("/v1/step/telemetry/{session_id}")
299
+ async def step_telemetry(session_id: str) -> dict:
300
+ return _or_404(_telemetry_json, session_id)
301
+
302
+
303
+ @app.get("/v1/step/drafts/{session_id}")
304
+ async def step_drafts(session_id: str) -> dict:
305
+ drafts = _or_404(KERNEL.drafts, session_id)
306
+ return {"count": len(drafts), "drafts": drafts}
307
+
308
+
309
+ @app.post("/v1/step/finalize")
310
+ async def step_finalize(body: InitBody | None = None, session_id: str | None = None) -> dict:
311
+ sid = session_id or (body and body.project_root)
312
+ raise HTTPException(status_code=400, detail="use POST /v1/step/finalize/{session_id}")
313
+
314
+
315
+ @app.post("/v1/step/finalize/{session_id}")
316
+ async def step_finalize_sid(session_id: str) -> dict:
317
+ from kernel import OmissionError
318
+
319
+ try:
320
+ _or_404(KERNEL.finalize_session, session_id)
321
+ except OmissionError as exc:
322
+ return {"finalized": False, "error": str(exc), "subtype": exc.subtype}
323
+ return {"finalized": True}
324
+
325
+
326
+ # ------------------------------------------------------------------------- WS
327
+
328
+
329
+ @app.websocket("/v1/ws/{session_id}")
330
+ async def ws_session(ws: WebSocket, session_id: str) -> None:
331
+ await WS.connect(session_id, ws)
332
+ try:
333
+ await ws.send_json(
334
+ {
335
+ "type": "snapshot",
336
+ "graph": KERNEL.graph(session_id),
337
+ "telemetry": _telemetry_json(session_id),
338
+ }
339
+ )
340
+ while True:
341
+ await ws.receive_text() # keepalive / client pings
342
+ except (WebSocketDisconnect, KeyError):
343
+ pass
344
+ finally:
345
+ WS.drop(session_id, ws)
346
+
347
+
348
+ # ------------------------------------------- Door 1: TokenFirewall proxy
349
+
350
+
351
+ def _msg_text(message: dict) -> str:
352
+ content = message.get("content", "")
353
+ if isinstance(content, str):
354
+ return content
355
+ return "\n".join(
356
+ block.get("text", "") for block in content if isinstance(block, dict)
357
+ )
358
+
359
+
360
+ def _extract_step(text: str) -> tuple[str, object] | None:
361
+ """Latest verified step in an assistant turn: ("patch", ops) folds AST-level
362
+ ops into canonical state; ("source", code) stages a whole module."""
363
+ patches = _PATCH_RE.findall(text)
364
+ if patches:
365
+ try:
366
+ return ("patch", json.loads(patches[-1]))
367
+ except json.JSONDecodeError:
368
+ return None
369
+ matches = _CODE_RE.findall(text)
370
+ return ("source", matches[-1]) if matches else None
371
+
372
+
373
+ async def _run_firewall(
374
+ body: dict,
375
+ session_id: str,
376
+ node_id: str | None,
377
+ api: str,
378
+ focus: list[str] | None = None,
379
+ ) -> dict:
380
+ messages: list[dict] = body.get("messages", [])
381
+ systems = [
382
+ _msg_text(m) for m in messages if m.get("role") == "system"
383
+ ]
384
+ if api == "anthropic" and isinstance(body.get("system"), str):
385
+ systems.insert(0, body["system"])
386
+
387
+ # The latest assistant ```python block is the step output; everything else
388
+ # by the agent is dead-end scratch to park.
389
+ last_step, last_idx = None, -1
390
+ for i, m in enumerate(messages):
391
+ if m.get("role") == "assistant":
392
+ step = _extract_step(_msg_text(m))
393
+ if step:
394
+ last_step, last_idx = step, i
395
+
396
+ commit_info = None
397
+ for i, m in enumerate(messages):
398
+ if m.get("role") not in ("assistant", "tool"):
399
+ continue
400
+ if i == last_idx and last_step is not None and node_id:
401
+ kind, value = last_step
402
+ if kind == "patch":
403
+ verdict = KERNEL.commit(session_id, node_id, patch=value)
404
+ else:
405
+ verdict = KERNEL.commit(session_id, node_id, value)
406
+ commit_info = _verdict_json(verdict)
407
+ await _broadcast_mutation(
408
+ session_id, gate_event={"node_id": node_id, **commit_info}
409
+ )
410
+ else:
411
+ KERNEL.record_draft(session_id, node_id or "__chat__", _msg_text(m))
412
+
413
+ sent: list[dict] = [{"role": "system", "content": s} for s in systems]
414
+ if node_id:
415
+ payload = _or_404(KERNEL.checkout, session_id, node_id, focus=focus)
416
+ sent.append(
417
+ {
418
+ "role": "system",
419
+ "content": "## StepCheck CheckoutPayload\n\n" + payload.rendered(),
420
+ }
421
+ )
422
+ final_user = next(
423
+ (m for m in reversed(messages) if m.get("role") == "user"), None
424
+ )
425
+ if final_user is not None:
426
+ sent.append({"role": "user", "content": _msg_text(final_user)})
427
+
428
+ raw_tokens = sum(_count(_msg_text(m)) for m in messages) + sum(
429
+ _count(s) for s in systems if api == "anthropic"
430
+ )
431
+ sent_tokens = sum(_count(m["content"]) for m in sent)
432
+ return {
433
+ "session_id": session_id,
434
+ "node_id": node_id,
435
+ "raw_tokens": raw_tokens,
436
+ "sent_tokens": sent_tokens,
437
+ "tokens_pruned": raw_tokens - sent_tokens,
438
+ "sent_messages": sent,
439
+ "commit": commit_info,
440
+ "telemetry": _telemetry_json(session_id),
441
+ }
442
+
443
+
444
+ def _session_from(request: Request, body: dict) -> tuple[str, str | None, list[str] | None]:
445
+ sc = body.get("stepcheck", {}) if isinstance(body.get("stepcheck"), dict) else {}
446
+ session_id = request.headers.get("x-stepcheck-session") or sc.get("session_id")
447
+ node_id = request.headers.get("x-stepcheck-node") or sc.get("node_id")
448
+ # focus: header "a,b" or "" (pure skeleton); body.stepcheck.focus as a list.
449
+ focus: list[str] | None = None
450
+ if "x-stepcheck-focus" in request.headers:
451
+ raw = request.headers["x-stepcheck-focus"].strip()
452
+ focus = [f.strip() for f in raw.split(",") if f.strip()] if raw else []
453
+ elif isinstance(sc.get("focus"), list):
454
+ focus = [str(f) for f in sc["focus"]]
455
+ if not session_id:
456
+ raise HTTPException(
457
+ status_code=400,
458
+ detail="x-stepcheck-session header (or body.stepcheck.session_id) required",
459
+ )
460
+ return session_id, node_id, focus
461
+
462
+
463
+ async def _maybe_forward(
464
+ request: Request, body: dict, fw: dict, api: str
465
+ ) -> dict | None:
466
+ """BYOK upstream routing: only when the caller supplies key AND base URL."""
467
+ upstream = request.headers.get("x-stepcheck-upstream") or os.environ.get(
468
+ "STEPCHECK_UPSTREAM_BASE"
469
+ )
470
+ key = request.headers.get("authorization") or request.headers.get("x-api-key")
471
+ if not upstream or not key:
472
+ return None
473
+ path = "/v1/messages" if api == "anthropic" else "/v1/chat/completions"
474
+ out_body = dict(body)
475
+ out_body["messages"] = [
476
+ m for m in fw["sent_messages"] if not (api == "anthropic" and m["role"] == "system")
477
+ ]
478
+ headers = {"content-type": "application/json"}
479
+ if api == "anthropic":
480
+ out_body["system"] = "\n\n".join(
481
+ m["content"] for m in fw["sent_messages"] if m["role"] == "system"
482
+ )
483
+ headers["x-api-key"] = request.headers.get("x-api-key", "")
484
+ headers["anthropic-version"] = request.headers.get(
485
+ "anthropic-version", "2023-06-01"
486
+ )
487
+ else:
488
+ headers["authorization"] = request.headers.get("authorization", "")
489
+ out_body.pop("stepcheck", None)
490
+ async with httpx.AsyncClient(timeout=120) as client:
491
+ resp = await client.post(upstream.rstrip("/") + path, json=out_body, headers=headers)
492
+ data = resp.json()
493
+ data["stepcheck"] = fw
494
+ return data
495
+
496
+
497
+ _DRY_NOTICE = (
498
+ "StepCheck dry-run: no BYOK upstream configured. The compacted request this "
499
+ "firewall would have forwarded is in `stepcheck.sent_messages`."
500
+ )
501
+
502
+
503
+ @app.post("/v1/chat/completions")
504
+ async def chat_completions(request: Request) -> dict:
505
+ body = await request.json()
506
+ session_id, node_id, focus = _session_from(request, body)
507
+ fw = await _run_firewall(body, session_id, node_id, api="openai", focus=focus)
508
+ forwarded = await _maybe_forward(request, body, fw, api="openai")
509
+ if forwarded is not None:
510
+ return forwarded
511
+ return {
512
+ "id": f"sc-{uuid.uuid4().hex[:12]}",
513
+ "object": "chat.completion",
514
+ "created": 0,
515
+ "model": "stepcheck-dry-run",
516
+ "choices": [
517
+ {
518
+ "index": 0,
519
+ "message": {"role": "assistant", "content": _DRY_NOTICE},
520
+ "finish_reason": "stop",
521
+ }
522
+ ],
523
+ "usage": {
524
+ "prompt_tokens": fw["sent_tokens"],
525
+ "completion_tokens": 0,
526
+ "total_tokens": fw["sent_tokens"],
527
+ },
528
+ "stepcheck": fw,
529
+ }
530
+
531
+
532
+ @app.post("/v1/messages")
533
+ async def anthropic_messages(request: Request) -> dict:
534
+ body = await request.json()
535
+ session_id, node_id, focus = _session_from(request, body)
536
+ fw = await _run_firewall(body, session_id, node_id, api="anthropic", focus=focus)
537
+ forwarded = await _maybe_forward(request, body, fw, api="anthropic")
538
+ if forwarded is not None:
539
+ return forwarded
540
+ return {
541
+ "id": f"msg-sc-{uuid.uuid4().hex[:12]}",
542
+ "type": "message",
543
+ "role": "assistant",
544
+ "model": "stepcheck-dry-run",
545
+ "content": [{"type": "text", "text": _DRY_NOTICE}],
546
+ "stop_reason": "end_turn",
547
+ "usage": {"input_tokens": fw["sent_tokens"], "output_tokens": 0},
548
+ "stepcheck": fw,
549
+ }
550
+
551
+
552
+ # ----------------------------------------------------- Studio static mount
553
+
554
+
555
+ @app.get("/studio", include_in_schema=False)
556
+ async def studio_root() -> RedirectResponse:
557
+ return RedirectResponse(url="/studio/", status_code=307)
558
+
559
+
560
+ class _SpaStaticFiles(StaticFiles):
561
+ """Serve real files; fall back to index.html for SPA deep links, but keep a
562
+ genuine 404 for missing hashed assets so broken builds surface."""
563
+
564
+ async def get_response(self, path: str, scope):
565
+ from starlette.exceptions import HTTPException as StarletteHTTPException
566
+
567
+ # Starlette builds `path` with os.path.join, so Windows yields backslashes.
568
+ path = path.replace("\\", "/")
569
+ try:
570
+ return await super().get_response(path, scope)
571
+ except StarletteHTTPException as exc:
572
+ if exc.status_code == 404 and not path.startswith("assets/"):
573
+ return await super().get_response("index.html", scope)
574
+ raise
575
+
576
+
577
+ if STUDIO_DIST.exists():
578
+ app.mount("/studio", _SpaStaticFiles(directory=str(STUDIO_DIST), html=True), name="studio")
579
+
580
+
581
+ if __name__ == "__main__":
582
+ import uvicorn
583
+
584
+ uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "8420")))