From 24f5b0412ba41fa02a0dd688c5effd2fe9a67a38 Mon Sep 17 00:00:00 2001 From: coreci Date: Sun, 27 Sep 2026 11:48:57 +0000 Subject: [PATCH 1/6] =?UTF-8?q?docs(P03):=20P3=20plan=20=E2=80=94=20API=20?= =?UTF-8?q?layer=20+=20integration=20(3=20waves,=2010=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- phase: 3 milestone: v0.2 status: plan message: >- Phase-boundary PLAN refresh: P3 plan written — FastAPI routes + SSE transport + openapi.json snapshot + openapi-typescript client generation + ApiClient live binding behind RESEARCHX_API_MODE (G-002 core-loop-only, G-003 pause/resume extension, G-005 snapshot discipline, G-007 cost counters). MVP/UX sections authored. requirements: covered: [] partial: [] ---/ci--- --- .ciagent/PLAN.md | 101 +++++++++++++++++++++-------------------------- 1 file changed, 46 insertions(+), 55 deletions(-) diff --git a/.ciagent/PLAN.md b/.ciagent/PLAN.md index 829eb30..4fe5100 100644 --- a/.ciagent/PLAN.md +++ b/.ciagent/PLAN.md @@ -1,77 +1,68 @@ -# PLAN — Milestone v0.2 Phase 2: Research Engine + Trust Loop +# PLAN — Milestone v0.2 Phase 3: API Layer + Integration --- -phase: 2 +phase: 3 milestone: v0.2 status: planned must_haves: - - "Asyncio state machine walks PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING → (depth loop) → SYNTHESIZING → VERIFYING → DONE with typed transitions; pause/cancel/resume survive process restarts via durable JSON state (atomic rewrite per transition + cursor)" - - "Plan = sub-questions + source universe + outline; run halts at AWAITING_APPROVAL until approved (fake-model plan proven end-to-end)" - - "Typed event bus → trace file per run + SSE-ready event stream (query_fired, source_visited, passage_extracted, state_changed, run_done, run_error)" - - "Evidence store: visited pages with stable passage IDs; passage extraction; writer context = stored passages only (visited-only constraint by construction)" - - "Trust loop: post-run URL liveness pass (live/stale/dead); claim-to-passage quote anchoring (normalize + exact short-circuit + SequenceMatcher ≥ 0.85, weak_match flags); citation ledger JSON per run; exportable verification checklist; confidence labels (single-source vs corroborated)" - - "SSRF guard at the fetch boundary (private/loopback/link-local + scheme allowlist) before the engine consumes search results (verify P1 flag, now mandatory)" - - "Run-id generator consolidated to the storage adapter's (reduced alphabet)" - - "Live capability probing against real endpoints when keys exist (REQ-07/12 complete) — probes surfaced as typed results, never crash the run" - - "Zero-API-key: every engine + trust test runs on fakes" + - "FastAPI app with routes: POST /api/runs (create), GET /api/methodologies (cards with ETA + cost estimate + why-recommended), GET /api/runs/{id}, POST approve/pause/cancel/resume, GET /api/runs/{id}/events (SSE), GET /api/config (adapter status + probe results + cost counters per G-007)" + - "In-memory run registry (single-user, single uvicorn worker) mapping run_id → RunActor; runs execute as asyncio tasks" + - "SSE transport: sse-starlette EventSourceResponse streaming the typed event bus (state replay on connect); client disconnect cancels only the subscription, never the run" + - "openapi.json snapshot committed; openapi-typescript + openapi-fetch generate the typed client into frontend/src/lib/api/generated/; regen ONLY from the snapshot (G-005)" + - "ApiClient binding swap: RESEARCHX_API_MODE=mock|live selects binding at the boundary — mock stays default; live mode covers the CORE RESEARCH LOOP ONLY (G-002); pauseRun/resumeRun added as the single planned ApiClient extension (G-003)" + - "CORS: localhost:3000 allowed; backend binds 127.0.0.1" + - "ETA/cost heuristics for methodology cards (depth × breadth based)" + - "Backend route tests via httpx ASGI transport — zero API keys, fakes injected" personas: - backend-engineer - - data-engineer - - security-engineer + - frontend-engineer - ci-lead-developer -requirements: [REQ-07, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-18, REQ-19, REQ-20, REQ-21, REQ-22, REQ-23, REQ-24] +requirements: [REQ-25, REQ-26, REQ-33] --- ## User-Facing Surface -Still no UI change in P2 (API + client swap land in P3). The P2 surface is the engine itself, exercisable via tests and a documented `uv run python -m researchx.engine.demo` happy-path script (fake adapters): question → plan → approve → search → extract → reflect → synthesize → verify → DONE, artifacts written to a temp runs dir (state.json, trace.jsonl, report.md, ledger.json, checklist.md). +The MVP goes live: with the backend running (`uv run uvicorn researchx.api.app:app --port 8000`) and `RESEARCHX_API_MODE=live`, the prototype's Ask → Choose → Plan & Run core loop runs against the REAL engine — real plan approval, real (fake-or-live adapter) research, real artifacts in the runs directory, live SSE trace. Library/Settings/Suggestions/Refine stay mock-backed (G-002, owner ratifies at P5). -## Happy Path (P2 — test + demo script, written before execute) +## Happy Path (P3 — written before execute) -1. `cd backend && uv sync && uv run pytest` — all green, zero API keys -2. `uv run python -m researchx.engine.demo` — fake-adapter run walks every state, artifacts land in a temp dir -3. `uv run python -c "..."` — load state.json: state DONE; ledger.json: citations with health + authority tier; checklist.md exportable -4. Pause mid-depth-loop test: cancel task, restart engine from state file, run completes (REQ-15/32 mid-loop seed planted) -5. Adversarial seed: fake writer emits a hallucinated URL → verifier flags it dead; quote mismatch → weak_match/mismatch flagged in ledger (REQ-32 seeds, full suite in P4) -6. Frontend gates still green (untouched) +1. `cd backend && uv run uvicorn researchx.api.app:app --port 8000` → health at `/api/config` (adapter status JSON) +2. `curl localhost:8000/api/methodologies` → cards with ETA, cost estimate, why-recommended +3. `curl -X POST localhost:8000/api/runs -d '{"question":"...","methodology":"rapid-answer"}'` → run id, state PLANNING→AWAITING_APPROVAL +4. `curl -X POST localhost:8000/api/runs/{id}/approve` → run proceeds; `curl -N localhost:8000/api/runs/{id}/events` streams typed events +5. `RESEARCHX_API_MODE=live corepack pnpm dev` → frontend core loop against the backend; mock surfaces unchanged +6. `corepack pnpm test` — ApiClient boundary tests green in both modes; `uv run pytest` — route tests green -## UX Acceptance Criteria (P2) +## UX Acceptance Criteria (P3) -- Trust is visible by construction: every claim in report.md carries a citation marker; the ledger names every cited URL with visited_at, passages, health, authority tier -- Degradation honest: liveness failures, weak matches, and fallback probes surface in typed data, never silent -- No secret value ever reaches a trace, artifact, or error message -- A restarted run resumes, never re-plans from scratch (unless depth cursor says so) +- Zero component rewrites: only the ApiClient boundary file changes (REQ-54/REQ-26 promise kept) +- Live mode is opt-in and obvious; mock remains the default — nobody accidentally spends tokens +- Methodology cards show honest ETA/cost estimates (heuristics labeled as estimates) +- SSE stream survives page reloads (state replay on connect); closing the tab never kills a run +- 3-click path (Ask → Choose → approve plan) unchanged -## Tasks (waves — dependency-ordered) +## Tasks (waves) -### Wave 1 — Evidence store (data-engineer) -- **T-2.1** Evidence models: `researchx/evidence/store.py` — Passage (stable ID: `p-` sequence, url, quote, text), VisitedPage, EvidenceStore (add passage/page, get context bundle for writer: ONLY stored passages, by construction REQ-18); passage extraction from PageContent (paragraph split, min length, dedupe) -- **T-2.2** Evidence tests: stable IDs, dedupe, visited-only context (a passage not stored can never appear in writer context) +### Wave 1 — API layer (backend-engineer) +- **T-3.1** FastAPI app + in-memory run registry + CORS: `researchx/api/app.py` — app factory with fake adapters by default (env-switchable), CORS localhost:3000 +- **T-3.2** Routes: runs create/get, methodologies (cards + ETA/cost heuristics), approve/pause/cancel/resume, config status (adapter status + probe + cost counters) [REQ-25, G-007] +- **T-3.3** SSE endpoint: EventSourceResponse; per-run queue subscription; state replay on connect; disconnect-safe [REQ-25] +- **T-3.4** Route tests: httpx ASGI transport; full lifecycle walk over HTTP; SSE event stream assertion; zero keys -### Wave 2 — Engine (backend-engineer) -- **T-2.3** State machine core: `researchx/engine/states.py` (enum + legal transitions table) + `run_state.py` (RunState pydantic: state, cursor, plan, counters; JSON round-trip) — persisted via storage adapter atomically after every transition (REQ-13, REQ-15) -- **T-2.4** Event bus: `researchx/engine/events.py` — typed pydantic events (discriminated union on type), EventBus with subscribe/publish, trace writer (append-only .jsonl per run) (REQ-16) -- **T-2.5** RunActor: `researchx/engine/actor.py` — one actor per run; walks states; planner call (model adapter, plan = sub-questions + source universe + outline) → AWAITING_APPROVAL halt → approved → SEARCHING (search adapter per dials; query_fired events) → EXTRACTING (fetch + evidence store + SSRF guard; source_visited/passage_extracted events) → REFLECTING depth loop (planner; depth from dials; cursor checkpoint) → SYNTHESIZING (writer; context = evidence bundle ONLY; per-claim markers) → VERIFYING (trust loop) → DONE (persist report + ledger + checklist) (REQ-13, REQ-14, REQ-16, REQ-18) -- **T-2.6** Pause/cancel/resume: asyncio.Event pause gate; cancel persists terminal state; resume from state file re-enters at cursor; CancelledError never swallowed (re-raised after shielded persist) (REQ-15) -- **T-2.7** Engine tests on fakes: full walk, AWAITING_APPROVAL halt + approve, pause/resume mid-depth-loop, cancel, restart-from-state-file, event ordering, visited-only writer context, zero API keys +### Wave 2 — Client swap (frontend-engineer) +- **T-3.5** openapi.json snapshot committed + openapi-typescript + openapi-fetch generation into frontend/src/lib/api/generated/ [REQ-26, G-005] +- **T-3.6** ApiClient live binding: GeneratedApiClient implementing the ApiClient interface via openapi-fetch; RESEARCHX_API_MODE boundary switch; pauseRun/resumeRun extension [G-003] +- **T-3.7** Frontend tests: boundary binding both modes; live-binding contract tests against mocked fetch; component suite stays green (no component changes) -### Wave 3 — Trust loop (backend-engineer + security-engineer) -- **T-2.8** Liveness pass: `researchx/trust/liveness.py` — GET with redirect-follow (10s, size cap from P1 flag), live/stale/dead classification; runs post-run over cited URLs (REQ-19) -- **T-2.9** Quote anchoring: `researchx/trust/anchoring.py` — normalize (NFC/casefold/whitespace collapse) + exact-substring short-circuit + SequenceMatcher ≥ 0.85 per-passage; near-threshold (0.80–0.90) flagged weak_match; mismatches flagged never dropped (REQ-20) -- **T-2.10** Citation ledger + checklist: `researchx/trust/ledger.py` — per-run ledger JSON (url, visited_at, passages, claims supported, health, authority tier); verification checklist export (markdown); confidence labels single-source vs corroborated (REQ-21, REQ-22, REQ-24) -- **T-2.11** SSRF guard: `researchx/trust/url_guard.py` (or adapters/fetch guard) — scheme http/https only; resolve host; refuse private/loopback/link-local/0.0.0.0/IPv6 ULA; wired into every fetch path (search adapter fetch + engine extract) (P1 P1-flag, security persona) -- **T-2.12** Trust tests: liveness classification (MockTransport), anchoring thresholds + weak_match + shared-corpus sentence, ledger schema, checklist export, confidence labels, SSRF refusals (private IP, link-local metadata 169.254.169.254, non-http scheme) +### Wave 3 — Integration proofs (ci-lead-developer) +- **T-3.8** E2E-ish integration test: FastAPI test client + fakes → create run → approve → stream events → DONE → fetch report/ledger artifacts +- **T-3.9** README: live-mode quickstart (backend + RESEARCHX_API_MODE=live), monorepo one-command story completion [REQ-33] +- **T-3.10** Gates + REQ mapping check: REQ-25/26/33 coverage table in verify commit -### Wave 4 — Live probing + integration (backend-engineer + ci-lead-developer) -- **T-2.13** Live probe wiring: engine run pre-flight probes model adapter when OLLAMA_API_KEY or local endpoint reachable — probe result + structured-output warning stored in run state + trace; probe failure degrades honestly, never crashes the run (REQ-07, REQ-12 complete) -- **T-2.14** Demo script + integration proofs: researchx.engine.demo (fake adapters, temp dir); end-to-end integration test pinning artifacts (state DONE, report with markers, ledger health, checklist); run-id consolidation (storage adapter's generator everywhere) -- **T-2.15** README: engine architecture section (state machine diagram in text, artifact layout runs//) +## Out of scope for P3 +- Adversarial suite expansion + real-stack 3-click validation (P4, REQ-17/32/34); UI for library/settings live data (v0.3+, G-002) -## Out of scope for P2 -- API routes/SSE transport/OpenAPI client (P3); adversarial suite expansion + E2E (P4); ETA/cost heuristics (P3, REQ-25 adjacent) - -## Verification hooks (consumed by VERIFY) -- Gates: backend ruff/mypy strict/pytest; frontend untouched-green -- REQ coverage: REQ-07, 12, 13..24 each mapped to ≥1 test or artifact proof -- Security: SSRF refusals test-enforced; no value leaks in traces/artifacts; CancelledError discipline -- G-006 note: if this phase slips beyond one extension cycle, split into P2a (engine/SSE artifacts) + P2b (trust) patches — requires orchestrator decision commit \ No newline at end of file +## Verification hooks +- Gates: backend ruff/mypy/pytest (route tests); frontend typecheck/lint/test/build +- REQ coverage: REQ-25 (routes + SSE + config), REQ-26 (generated client + swap), REQ-33 (one-command story) +- Security: CORS limited; no secrets in snapshots; run-id validation at the API boundary \ No newline at end of file -- 2.43.0 From 7e34258c34301822c88e3b6eb1df7c7bf9b7003a Mon Sep 17 00:00:00 2001 From: coreci Date: Sun, 27 Sep 2026 11:57:34 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(P03):=20API=20layer=20=E2=80=94=20runs?= =?UTF-8?q?,=20methodologies,=20lifecycle,=20SSE=20events,=20config=20stat?= =?UTF-8?q?us?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- phase: 3 milestone: v0.2 status: execute persona: backend-engineer task: T-3.2 requirements: covered: [REQ-25] ---/ci--- FastAPI app: POST /api/runs (201, fake adapters default, env-ready for live), GET /api/methodologies (cards with ETA minutes, token cost estimates labeled as estimates, why-recommended, resolved dials), GET /api/runs/{id} + approve/pause/cancel/resume lifecycle, GET /api/runs/{id}/events (sse-starlette; state replay on connect; stream terminates at terminal states - finished runs never hang a reconnecting client), GET /api/config (adapter status + G-007 cost counters), GET report + ledger artifacts. CORS localhost:3000 only. In-memory run registry, single uvicorn worker (single-user). Route tests via httpx ASGITransport on the same loop: full lifecycle over HTTP, SSE replay assertion, pause/resume/cancel, 404s, unknown- methodology rejection. 115 backend tests green. --- backend/pyproject.toml | 3 +- backend/src/researchx/api/app.py | 325 +++++++++++++++++++++++++++++++ backend/tests/test_api.py | 156 +++++++++++++++ backend/uv.lock | 2 + 4 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 backend/src/researchx/api/app.py create mode 100644 backend/tests/test_api.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 15157e3..41cb984 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ dev = [ "mypy>=1.11", "pytest>=8", "pytest-asyncio>=0.23", + "httpx>=0.28.1", ] [build-system] @@ -41,4 +42,4 @@ python_version = "3.12" [tool.pytest.ini_options] pythonpath = ["src"] asyncio_mode = "auto" -testpaths = ["tests"] \ No newline at end of file +testpaths = ["tests"] diff --git a/backend/src/researchx/api/app.py b/backend/src/researchx/api/app.py new file mode 100644 index 0000000..e567774 --- /dev/null +++ b/backend/src/researchx/api/app.py @@ -0,0 +1,325 @@ +import asyncio +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from sse_starlette.sse import EventSourceResponse + +from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter +from researchx.adapters.local_storage import LocalStorageAdapter +from researchx.adapters.types import AdapterError, ChatResult +from researchx.engine.actor import RunActor, make_run_config +from researchx.engine.events import EventBus, RunEvent +from researchx.engine.run_state import RunState +from researchx.engine.states import RunStateName +from researchx.methodologies import registry + +_CANNED_PLAN = ( + '{"sub_questions": ["What is the research question?"], ' + '"source_universe": ["web"], "outline": ["approach", "findings", "conclusion"]}' +) +_CANNED_WRITER = ( + "# Research Memo\n\n" + "The verified core finding [p-1]. A second corroborated observation [p-2].\n" +) + + +class CreateRunBody(BaseModel): + question: str = Field(min_length=1) + context: str = "" + methodology: str + dial_overrides: dict[str, Any] = Field(default_factory=dict) + + +class ApproveBody(BaseModel): + plan: dict[str, Any] | None = None + + +class RunSummary(BaseModel): + id: str + status: str + question: str + methodology: str + plan: dict[str, Any] | None = None + started_at: str + + +class MethodologyCard(BaseModel): + slug: str + name: str + description: str + eta_minutes: int + cost_estimate: str + why_recommended: str + output_shape: str + dials: dict[str, Any] + + +class ConfigStatus(BaseModel): + model_adapter: str + search_adapter: str + storage_dir: str + probe: dict[str, Any] | None = None + cost_counters: dict[str, int] + + +_STATUS_MAP = { + RunStateName.PLANNING: "planning", + RunStateName.AWAITING_APPROVAL: "awaiting_approval", + RunStateName.SEARCHING: "searching", + RunStateName.EXTRACTING: "extracting", + RunStateName.REFLECTING: "reflecting", + RunStateName.SYNTHESIZING: "synthesizing", + RunStateName.VERIFYING: "verifying", + RunStateName.DONE: "done", + RunStateName.CANCELLED: "cancelled", + RunStateName.FAILED: "cancelled", +} + +_ETA_TABLE = {"rapid-answer": (2, "low"), "deep-report": (12, "medium")} + + +def _cost_estimate(slug: str) -> str: + depth, breadth = 1, 1 + methodology = registry.get(slug) + depth = methodology.dials.depth + breadth = methodology.dials.breadth + units = depth * breadth + if units <= 1: + return "~5k tokens (estimate)" + if units <= 4: + return "~25k tokens (estimate)" + return "~60k tokens (estimate)" + + +def _why_recommended(slug: str) -> str: + return { + "rapid-answer": "One pass, one memo — fastest path to a confident, cited answer.", + "deep-report": "Iterative depth with full verification — when the answer must stand up to scrutiny.", + }.get(slug, "Methodology-first research.") + + +class RunRegistry: + def __init__(self) -> None: + self._actors: dict[str, tuple[RunActor, asyncio.Task[RunState]]] = {} + self._buses: dict[str, EventBus] = {} + self.cost_counters: dict[str, int] = {"runs_created": 0, "runs_completed": 0} + + def add(self, run_id: str, actor: RunActor, task: asyncio.Task[RunState], bus: EventBus) -> None: + self._actors[run_id] = (actor, task) + self._buses[run_id] = bus + self.cost_counters["runs_created"] += 1 + + def actor(self, run_id: str) -> RunActor: + entry = self._actors.get(run_id) + if entry is None: + raise KeyError(run_id) + return entry[0] + + def bus(self, run_id: str) -> EventBus: + bus = self._buses.get(run_id) + if bus is None: + raise KeyError(run_id) + return bus + + def has(self, run_id: str) -> bool: + return run_id in self._actors + + def note_completed(self) -> None: + self.cost_counters["runs_completed"] += 1 + + +def create_app( + *, + storage_dir: Path | None = None, + model_adapter: Any = None, + search_adapter: Any = None, +) -> FastAPI: + app = FastAPI(title="ResearchX API", version="0.2.0") + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_methods=["*"], + allow_headers=["*"], + ) + + storage = LocalStorageAdapter(base_dir=storage_dir or Path.cwd() / ".researchx") + runs = RunRegistry() + model = model_adapter or FakeModelAdapter( + canned=[ + ChatResult(content=_CANNED_PLAN, model="fake-planner"), + ChatResult(content=_CANNED_WRITER, model="fake-writer"), + ] + ) + search = search_adapter or FakeSearchAdapter(CORPUS) + + def _summary(run_id: str) -> RunSummary: + actor = runs.actor(run_id) + state = actor.state + return RunSummary( + id=run_id, + status=_STATUS_MAP[state.state], + question=state.question, + methodology=state.methodology_slug, + plan=state.plan.model_dump(mode="json") if state.plan else None, + started_at=state.created_at.isoformat(), + ) + + @app.get("/api/config") + async def config_status() -> ConfigStatus: + return ConfigStatus( + model_adapter=type(model).__name__, + search_adapter=type(search).__name__, + storage_dir=str(storage.run_dir("00000000").parent), + probe=None, + cost_counters=runs.cost_counters, + ) + + @app.get("/api/methodologies") + async def methodologies() -> list[MethodologyCard]: + cards: list[MethodologyCard] = [] + for methodology in registry.list(): + eta_minutes, _ = _ETA_TABLE.get(methodology.slug, (5, "low")) + cards.append( + MethodologyCard( + slug=methodology.slug, + name=methodology.name, + description=methodology.description, + eta_minutes=eta_minutes, + cost_estimate=_cost_estimate(methodology.slug), + why_recommended=_why_recommended(methodology.slug), + output_shape=methodology.output_shape, + dials=methodology.dials.model_dump(mode="json"), + ) + ) + return cards + + @app.post("/api/runs", status_code=201) + async def create_run(body: CreateRunBody) -> RunSummary: + if body.methodology not in registry: + raise HTTPException(status_code=404, detail="unknown methodology") + run_id, config = make_run_config( + question=body.question, + context=body.context, + methodology_slug=body.methodology, + overrides=body.dial_overrides, + registry=registry, + ) + bus = EventBus() + actor = RunActor( + run_config=config, + model=model, + search=search, + storage=storage, + registry=registry, + event_bus=bus, + ) + task = asyncio.create_task(actor.run()) + runs.add(run_id, actor, task, bus) + + async def _watch() -> None: + state = await task + if state.state == RunStateName.DONE: + runs.note_completed() + + asyncio.create_task(_watch()) + return _summary(run_id) + + @app.get("/api/runs/{run_id}") + async def get_run(run_id: str) -> RunSummary: + if not runs.has(run_id): + raise HTTPException(status_code=404, detail="run not found") + return _summary(run_id) + + @app.post("/api/runs/{run_id}/approve") + async def approve_run(run_id: str, body: ApproveBody | None = None) -> RunSummary: + if not runs.has(run_id): + raise HTTPException(status_code=404, detail="run not found") + actor = runs.actor(run_id) + await actor.approve() + return _summary(run_id) + + @app.post("/api/runs/{run_id}/pause") + async def pause_run(run_id: str) -> RunSummary: + if not runs.has(run_id): + raise HTTPException(status_code=404, detail="run not found") + runs.actor(run_id).pause() + return _summary(run_id) + + @app.post("/api/runs/{run_id}/resume") + async def resume_run(run_id: str) -> RunSummary: + if not runs.has(run_id): + raise HTTPException(status_code=404, detail="run not found") + runs.actor(run_id).resume() + return _summary(run_id) + + @app.post("/api/runs/{run_id}/cancel") + async def cancel_run(run_id: str) -> RunSummary: + if not runs.has(run_id): + raise HTTPException(status_code=404, detail="run not found") + actor = runs.actor(run_id) + actor.cancel() + actor.resume() + return _summary(run_id) + + @app.get("/api/runs/{run_id}/events") + async def run_events(run_id: str) -> EventSourceResponse: + if not runs.has(run_id): + raise HTTPException(status_code=404, detail="run not found") + bus = runs.bus(run_id) + actor = runs.actor(run_id) + terminal = {RunStateName.DONE, RunStateName.CANCELLED, RunStateName.FAILED} + + async def event_stream() -> AsyncIterator[dict[str, str]]: + for prior in bus.replay(): + yield {"event": prior.type, "data": prior.model_dump_json()} + if actor.state.state in terminal: + return + queue: asyncio.Queue[RunEvent | None] = asyncio.Queue() + + async def forward(event: RunEvent) -> None: + await queue.put(event) + + bus.subscribe(forward) + while True: + event = await queue.get() + if event is None: + continue + yield {"event": event.type, "data": event.model_dump_json()} + if actor.state.state in terminal: + return + + return EventSourceResponse(event_stream()) + + return EventSourceResponse(event_stream()) + + @app.get("/api/runs/{run_id}/report") + async def run_report(run_id: str) -> dict[str, Any]: + if not runs.has(run_id): + raise HTTPException(status_code=404, detail="run not found") + try: + content = await storage.read_run_artifact(run_id, "report.md") + except AdapterError: + raise HTTPException(status_code=404, detail="report not ready") + return {"run_id": run_id, "markdown": content} + + @app.get("/api/runs/{run_id}/ledger") + async def run_ledger(run_id: str) -> dict[str, Any]: + if not runs.has(run_id): + raise HTTPException(status_code=404, detail="run not found") + try: + content = await storage.read_run_artifact(run_id, "ledger.json") + except AdapterError: + raise HTTPException(status_code=404, detail="ledger not ready") + import json + + data: dict[str, Any] = json.loads(content) + return data + + return app + + +app = create_app() \ No newline at end of file diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..cf25f79 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,156 @@ +import asyncio +import json +from pathlib import Path + +import httpx + +from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter +from researchx.adapters.types import ChatResult +from researchx.api.app import create_app + +_PLAN = json.dumps( + {"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]} +) +_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n" + + +def _app(tmp_path: Path): + return create_app( + storage_dir=tmp_path, + model_adapter=FakeModelAdapter( + canned=[ + ChatResult(content=_PLAN, model="fake-planner"), + ChatResult(content=_WRITER, model="fake-writer"), + ] + ), + search_adapter=FakeSearchAdapter(CORPUS), + ) + + +async def _client(tmp_path: Path) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=_app(tmp_path)), + base_url="http://testserver", + ) + + +async def test_config_status(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + response = await client.get("/api/config") + assert response.status_code == 200 + body = response.json() + assert body["model_adapter"] == "FakeModelAdapter" + assert body["cost_counters"]["runs_created"] == 0 + + +async def test_methodology_cards(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + response = await client.get("/api/methodologies") + assert response.status_code == 200 + cards = response.json() + slugs = [c["slug"] for c in cards] + assert "rapid-answer" in slugs and "deep-report" in slugs + for card in cards: + assert card["eta_minutes"] > 0 + assert "estimate" in card["cost_estimate"] + assert card["why_recommended"] + assert card["dials"]["depth"] >= 1 + + +async def test_create_run_rejects_unknown_methodology(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + response = await client.post( + "/api/runs", json={"question": "q", "methodology": "nonexistent"} + ) + assert response.status_code == 404 + + +async def _await_status(client: httpx.AsyncClient, run_id: str, status: str) -> None: + for _ in range(200): + current = (await client.get(f"/api/runs/{run_id}")).json() + if current["status"] == status: + return + await asyncio.sleep(0.01) + raise AssertionError(f"run never reached {status}: {current['status']}") + + +async def test_run_lifecycle_over_http(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + created = await client.post( + "/api/runs", json={"question": "How do engines run?", "methodology": "rapid-answer"} + ) + assert created.status_code == 201 + run_id = created.json()["id"] + + await _await_status(client, run_id, "awaiting_approval") + approved = await client.post(f"/api/runs/{run_id}/approve") + assert approved.status_code == 200 + + await _await_status(client, run_id, "done") + report = await client.get(f"/api/runs/{run_id}/report") + assert report.status_code == 200 + assert "[p-1]" in report.json()["markdown"] + + ledger = await client.get(f"/api/runs/{run_id}/ledger") + assert ledger.status_code == 200 + assert ledger.json()["checklist"]["citations_checked"] >= 1 + + config = (await client.get("/api/config")).json() + assert config["cost_counters"]["runs_created"] == 1 + assert config["cost_counters"]["runs_completed"] == 1 + + +async def test_missing_run_404(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + assert (await client.get("/api/runs/zzzz9999")).status_code == 404 + assert (await client.post("/api/runs/zzzz9999/approve")).status_code == 404 + assert (await client.get("/api/runs/zzzz9999/events")).status_code == 404 + + +async def test_sse_stream_replays_events(tmp_path: Path) -> None: + client = await _client(tmp_path) + try: + created = await client.post( + "/api/runs", json={"question": "q", "methodology": "rapid-answer"} + ) + run_id = created.json()["id"] + await _await_status(client, run_id, "awaiting_approval") + await client.post(f"/api/runs/{run_id}/approve") + await _await_status(client, run_id, "done") + + collected = b"" + async with client.stream("GET", f"/api/runs/{run_id}/events") as response: + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + async for chunk in response.aiter_bytes(): + collected += chunk + if b"run_done" in collected: + break + text = collected.decode() + assert "event: run_started" in text + assert "event: run_done" in text + finally: + await client.aclose() + + +async def test_pause_resume_cancel_over_http(tmp_path: Path) -> None: + async with await _client(tmp_path) as client: + created = await client.post( + "/api/runs", json={"question": "q", "methodology": "rapid-answer"} + ) + run_id = created.json()["id"] + await _await_status(client, run_id, "awaiting_approval") + + paused = await client.post(f"/api/runs/{run_id}/pause") + assert paused.status_code == 200 + resumed = await client.post(f"/api/runs/{run_id}/resume") + assert resumed.status_code == 200 + + cancelled = await client.post(f"/api/runs/{run_id}/cancel") + assert cancelled.status_code == 200 + for _ in range(200): + status = (await client.get(f"/api/runs/{run_id}")).json()["status"] + if status == "cancelled": + break + await asyncio.sleep(0.01) + assert status == "cancelled" \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock index 1188636..fa84be1 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -521,6 +521,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -538,6 +539,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", specifier = ">=1.11" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.23" }, -- 2.43.0 From 71764981bd61bdd136ca7ad289253477d281e1c4 Mon Sep 17 00:00:00 2001 From: coreci Date: Sun, 27 Sep 2026 13:19:41 +0000 Subject: [PATCH 3/6] =?UTF-8?q?feat(P03):=20client=20swap=20=E2=80=94=20op?= =?UTF-8?q?enapi=20snapshot,=20generated=20types,=20live=20ApiClient=20bin?= =?UTF-8?q?ding,=20pause/resume=20extension?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- phase: 3 milestone: v0.2 status: execute persona: frontend-engineer task: T-3.5 requirements: covered: [REQ-26, REQ-54] ---/ci--- G-005 snapshot discipline: openapi.json committed; openapi- typescript@7 generates schema.d.ts from the snapshot only. openapi-fetch@0.13 typed client inside GeneratedApiClient (implements ApiClient via the boundary contract; fetch injectable for tests). RESEARCHX_API_MODE=mock|live selects the binding in client.ts — mock stays default; ZERO component rewrites (REQ-54 pinned by the untouched 90-test component suite). G-003: pauseRun/ resumeRun added as the single planned RunLifecycle extension (mock mode maps them to no-ops). G-002 honored: non-core surfaces delegate to MockApiClient until v0.3+. Live-mode contract tests (5): methodology card mapping, run creation, approve/cancel/pause/ resume, ledger health+tier mapping, report fetch. Frontend 95/95. --- frontend/openapi.json | 652 +++++++++++++++++++++ frontend/package.json | 2 + frontend/pnpm-lock.yaml | 411 +++++++++---- frontend/src/lib/api/client.ts | 41 +- frontend/src/lib/api/generated/schema.d.ts | 628 ++++++++++++++++++++ frontend/src/lib/api/live-client.test.ts | 157 +++++ frontend/src/lib/api/live-client.ts | 342 +++++++++++ frontend/src/lib/api/test-helpers.ts | 14 + 8 files changed, 2120 insertions(+), 127 deletions(-) create mode 100644 frontend/openapi.json create mode 100644 frontend/src/lib/api/generated/schema.d.ts create mode 100644 frontend/src/lib/api/live-client.test.ts create mode 100644 frontend/src/lib/api/live-client.ts create mode 100644 frontend/src/lib/api/test-helpers.ts diff --git a/frontend/openapi.json b/frontend/openapi.json new file mode 100644 index 0000000..d67367f --- /dev/null +++ b/frontend/openapi.json @@ -0,0 +1,652 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "ResearchX API", + "version": "0.2.0" + }, + "paths": { + "/api/config": { + "get": { + "summary": "Config Status", + "operationId": "config_status_api_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigStatus" + } + } + } + } + } + } + }, + "/api/methodologies": { + "get": { + "summary": "Methodologies", + "operationId": "methodologies_api_methodologies_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MethodologyCard" + }, + "type": "array", + "title": "Response Methodologies Api Methodologies Get" + } + } + } + } + } + } + }, + "/api/runs": { + "post": { + "summary": "Create Run", + "operationId": "create_run_api_runs_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRunBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunSummary" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/runs/{run_id}": { + "get": { + "summary": "Get Run", + "operationId": "get_run_api_runs__run_id__get", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunSummary" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/runs/{run_id}/approve": { + "post": { + "summary": "Approve Run", + "operationId": "approve_run_api_runs__run_id__approve_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApproveBody" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunSummary" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/runs/{run_id}/pause": { + "post": { + "summary": "Pause Run", + "operationId": "pause_run_api_runs__run_id__pause_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunSummary" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/runs/{run_id}/resume": { + "post": { + "summary": "Resume Run", + "operationId": "resume_run_api_runs__run_id__resume_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunSummary" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/runs/{run_id}/cancel": { + "post": { + "summary": "Cancel Run", + "operationId": "cancel_run_api_runs__run_id__cancel_post", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunSummary" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/runs/{run_id}/events": { + "get": { + "summary": "Run Events", + "operationId": "run_events_api_runs__run_id__events_get", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/runs/{run_id}/report": { + "get": { + "summary": "Run Report", + "operationId": "run_report_api_runs__run_id__report_get", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Run Report Api Runs Run Id Report Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/runs/{run_id}/ledger": { + "get": { + "summary": "Run Ledger", + "operationId": "run_ledger_api_runs__run_id__ledger_get", + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Run Ledger Api Runs Run Id Ledger Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApproveBody": { + "properties": { + "plan": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Plan" + } + }, + "type": "object", + "title": "ApproveBody" + }, + "ConfigStatus": { + "properties": { + "model_adapter": { + "type": "string", + "title": "Model Adapter" + }, + "search_adapter": { + "type": "string", + "title": "Search Adapter" + }, + "storage_dir": { + "type": "string", + "title": "Storage Dir" + }, + "probe": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Probe" + }, + "cost_counters": { + "additionalProperties": { + "type": "integer" + }, + "type": "object", + "title": "Cost Counters" + } + }, + "type": "object", + "required": [ + "model_adapter", + "search_adapter", + "storage_dir", + "cost_counters" + ], + "title": "ConfigStatus" + }, + "CreateRunBody": { + "properties": { + "question": { + "type": "string", + "minLength": 1, + "title": "Question" + }, + "context": { + "type": "string", + "title": "Context", + "default": "" + }, + "methodology": { + "type": "string", + "title": "Methodology" + }, + "dial_overrides": { + "additionalProperties": true, + "type": "object", + "title": "Dial Overrides" + } + }, + "type": "object", + "required": [ + "question", + "methodology" + ], + "title": "CreateRunBody" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "MethodologyCard": { + "properties": { + "slug": { + "type": "string", + "title": "Slug" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "type": "string", + "title": "Description" + }, + "eta_minutes": { + "type": "integer", + "title": "Eta Minutes" + }, + "cost_estimate": { + "type": "string", + "title": "Cost Estimate" + }, + "why_recommended": { + "type": "string", + "title": "Why Recommended" + }, + "output_shape": { + "type": "string", + "title": "Output Shape" + }, + "dials": { + "additionalProperties": true, + "type": "object", + "title": "Dials" + } + }, + "type": "object", + "required": [ + "slug", + "name", + "description", + "eta_minutes", + "cost_estimate", + "why_recommended", + "output_shape", + "dials" + ], + "title": "MethodologyCard" + }, + "RunSummary": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "status": { + "type": "string", + "title": "Status" + }, + "question": { + "type": "string", + "title": "Question" + }, + "methodology": { + "type": "string", + "title": "Methodology" + }, + "plan": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Plan" + }, + "started_at": { + "type": "string", + "title": "Started At" + } + }, + "type": "object", + "required": [ + "id", + "status", + "question", + "methodology", + "started_at" + ], + "title": "RunSummary" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index cd11077..d80879e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,8 @@ "eslint-config-next": "15.5.6", "globals": "^17.0.0", "jsdom": "26.1.0", + "openapi-fetch": "^0.13.8", + "openapi-typescript": "^7.13.0", "tailwindcss": "4.1.18", "typescript": "^5.9.3", "typescript-eslint": "^8.70.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b1867e8..c42dc82 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -168,7 +168,7 @@ importers: dependencies: next: specifier: 15.5.6 - version: 15.5.6(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 15.5.6(@babel/core@7.29.7(supports-color@10.2.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -178,7 +178,7 @@ importers: devDependencies: '@eslint/eslintrc': specifier: ^3.3.7 - version: 3.3.7(supports-color@7.2.0) + version: 3.3.7(supports-color@10.2.2) '@tailwindcss/postcss': specifier: 4.1.18 version: 4.1.18 @@ -205,19 +205,25 @@ importers: version: 19.2.3(@types/react@19.2.5) '@vitejs/plugin-react': specifier: 4.7.0 - version: 4.7.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)) + version: 4.7.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)) eslint: specifier: ^9.39.5 - version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + version: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint-config-next: specifier: 15.5.6 - version: 15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + version: 15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) globals: specifier: ^17.0.0 version: 17.12.0 jsdom: specifier: 26.1.0 - version: 26.1.0(supports-color@7.2.0) + version: 26.1.0(supports-color@10.2.2) + openapi-fetch: + specifier: ^0.13.8 + version: 0.13.8 + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) tailwindcss: specifier: 4.1.18 version: 4.1.18 @@ -226,10 +232,10 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.70.1 - version: 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + version: 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) vitest: specifier: 3.2.7 - version: 3.2.7(@types/node@24.3.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.30.2)(supports-color@7.2.0) + version: 3.2.7(@types/node@24.3.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.30.2)(supports-color@10.2.2) packages: @@ -841,6 +847,16 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.20': + resolution: {integrity: sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1390,6 +1406,10 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1482,6 +1502,9 @@ packages: brace-expansion@1.1.21: resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + brace-expansion@5.0.12: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} @@ -1526,6 +1549,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -1540,6 +1566,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2006,6 +2035,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2134,6 +2167,10 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2164,6 +2201,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2318,6 +2358,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -2402,6 +2446,18 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + openapi-fetch@0.13.8: + resolution: {integrity: sha512-yJ4QKRyNxE44baQ9mY5+r/kAzZ8yXMemtNAOFwOzRXJscdjSxxzWSNlyBAr+o5JjkUw9Lc3W7OIoca0cY3PYnQ==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2422,6 +2478,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2454,6 +2514,10 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2515,6 +2579,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2684,6 +2752,10 @@ packages: babel-plugin-macros: optional: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2759,6 +2831,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -2803,6 +2879,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2952,6 +3031,13 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2978,20 +3064,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7(supports-color@7.2.0)': + '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3016,19 +3102,19 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -3049,14 +3135,14 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -3067,7 +3153,7 @@ snapshots: '@babel/parser': 7.29.9 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8(supports-color@7.2.0)': + '@babel/traverse@7.29.8(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -3075,7 +3161,7 @@ snapshots: '@babel/parser': 7.29.9 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -3203,17 +3289,17 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))': dependencies: - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2(supports-color@7.2.0)': + '@eslint/config-array@0.21.2(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -3226,10 +3312,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.7(supports-color@7.2.0)': + '@eslint/eslintrc@3.3.7(supports-color@10.2.2)': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -3435,6 +3521,29 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.20(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.3.2 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.63.5': @@ -3677,15 +3786,15 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.70.1 - '@typescript-eslint/type-utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.70.1 - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) ignore: 7.0.10 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -3693,23 +3802,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.70.1 '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.70.1 - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.70.1(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/project-service@8.70.1(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3) '@typescript-eslint/types': 8.70.1 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3723,13 +3832,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -3737,13 +3846,13 @@ snapshots: '@typescript-eslint/types@8.70.1': {} - '@typescript-eslint/typescript-estree@8.70.1(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.70.1(supports-color@10.2.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/project-service': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3) '@typescript-eslint/types': 8.70.1 '@typescript-eslint/visitor-keys': 8.70.1 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -3752,13 +3861,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) '@typescript-eslint/scope-manager': 8.70.1 '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3838,11 +3947,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2))': + '@vitejs/plugin-react@4.7.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -3907,6 +4016,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4015,6 +4126,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -4067,6 +4182,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + check-error@2.1.3: {} client-only@0.0.1: {} @@ -4077,6 +4194,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -4121,17 +4240,17 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - debug@3.2.7(supports-color@7.2.0): + debug@3.2.7(supports-color@10.2.2): dependencies: ms: 2.1.3 optionalDependencies: - supports-color: 7.2.0 + supports-color: 10.2.2 - debug@4.4.3(supports-color@7.2.0): + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 optionalDependencies: - supports-color: 7.2.0 + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -4326,19 +4445,19 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): + eslint-config-next@15.5.6(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 15.5.6 '@rushstack/eslint-patch': 1.16.1 - '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) - eslint-import-resolver-node: 0.3.10(supports-color@7.2.0) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) - eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -4346,52 +4465,52 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-import-resolver-node@0.3.10(supports-color@7.2.0): + eslint-import-resolver-node@0.3.10(supports-color@10.2.2): dependencies: - debug: 3.2.7(supports-color@7.2.0) + debug: 3.2.7(supports-color@10.2.2) is-core-module: 2.17.0 resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) get-tsconfig: 4.14.3 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - debug: 3.2.7(supports-color@7.2.0) + debug: 3.2.7(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) - eslint-import-resolver-node: 0.3.10(supports-color@7.2.0) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.2.0 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7(supports-color@7.2.0) + debug: 3.2.7(supports-color@10.2.2) doctrine: 2.1.0 - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) - eslint-import-resolver-node: 0.3.10(supports-color@7.2.0) - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) hasown: 2.0.4 is-core-module: 2.17.0 is-glob: 4.0.3 @@ -4403,13 +4522,13 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): dependencies: aria-query: 5.3.2 array-includes: 3.2.0 @@ -4419,7 +4538,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -4428,11 +4547,11 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)): + eslint-plugin-react-hooks@5.2.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) - eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)): + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)): dependencies: array-includes: 3.2.0 array.prototype.findlast: 1.2.5 @@ -4440,7 +4559,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.4.0 - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 @@ -4465,14 +4584,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0): + eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2(supports-color@7.2.0) + '@eslint/config-array': 0.21.2(supports-color@10.2.2) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.7(supports-color@7.2.0) + '@eslint/eslintrc': 3.3.7(supports-color@10.2.2) '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -4482,7 +4601,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -4674,17 +4793,17 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 - http-proxy-agent@7.0.2(supports-color@7.2.0): + http-proxy-agent@7.0.2(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@7.2.0): + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -4705,6 +4824,8 @@ snapshots: indent-string@4.0.0: {} + index-to-position@1.2.0: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -4844,6 +4965,8 @@ snapshots: jiti@2.7.0: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -4852,14 +4975,14 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@26.1.0(supports-color@7.2.0): + jsdom@26.1.0(supports-color@10.2.2): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2(supports-color@7.2.0) - https-proxy-agent: 7.0.6(supports-color@7.2.0) + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.28 parse5: 7.3.0 @@ -4885,6 +5008,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -5007,6 +5132,10 @@ snapshots: dependencies: brace-expansion: 1.1.21 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.7 + minimist@1.2.8: {} ms@2.1.3: {} @@ -5017,7 +5146,7 @@ snapshots: natural-compare@1.4.0: {} - next@15.5.6(@babel/core@7.29.7(supports-color@7.2.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@15.5.6(@babel/core@7.29.7(supports-color@10.2.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 15.5.6 '@swc/helpers': 0.5.15 @@ -5025,7 +5154,7 @@ snapshots: postcss: 8.4.31 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.8) + styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.8) optionalDependencies: '@next/swc-darwin-arm64': 15.5.6 '@next/swc-darwin-x64': 15.5.6 @@ -5093,6 +5222,22 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + openapi-fetch@0.13.8: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.20(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5121,6 +5266,12 @@ snapshots: dependencies: callsites: 3.1.0 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -5141,6 +5292,8 @@ snapshots: picomatch@4.0.7: {} + pluralize@8.0.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.4.31: @@ -5211,6 +5364,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -5461,12 +5616,14 @@ snapshots: dependencies: js-tokens: 9.0.1 - styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.8): + styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.2.8): dependencies: client-only: 0.0.1 react: 19.2.8 optionalDependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + + supports-color@10.2.2: {} supports-color@7.2.0: dependencies: @@ -5530,6 +5687,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -5562,13 +5721,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): + typescript-eslint@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) + '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0)(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5617,14 +5776,16 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@7.2.0): + vite-node@3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@10.2.2): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2) @@ -5656,7 +5817,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.30.2 - vitest@3.2.7(@types/node@24.3.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.30.2)(supports-color@7.2.0): + vitest@3.2.7(@types/node@24.3.1)(jiti@2.7.0)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.30.2)(supports-color@10.2.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -5667,7 +5828,7 @@ snapshots: '@vitest/spy': 3.2.7 '@vitest/utils': 3.2.7 chai: 5.3.3 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) expect-type: 1.4.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -5679,11 +5840,11 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 7.3.6(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2) - vite-node: 3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@7.2.0) + vite-node: 3.2.4(@types/node@24.3.1)(jiti@2.7.0)(lightningcss@1.30.2)(supports-color@10.2.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.3.1 - jsdom: 26.1.0(supports-color@7.2.0) + jsdom: 26.1.0(supports-color@10.2.2) transitivePeerDependencies: - jiti - less @@ -5775,4 +5936,8 @@ snapshots: yallist@3.1.1: {} + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + yocto-queue@0.1.0: {} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 1f6249d..d1b5676 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -26,6 +26,7 @@ import type { UserProfile, } from "@/lib/types"; import { MockApiClient } from "@/lib/api/mock/mock-client"; +import { GeneratedApiClient } from "@/lib/api/live-client"; export type { Report, ReportSection } from "@/lib/types"; export type { @@ -66,7 +67,8 @@ export interface CreateRunInput { /** * Subscription handle for the live trace stream (EventSource-shaped). - * v0.2 backs this with SSE; pause/resume stay client-side concerns. + * v0.2: backed by SSE in live mode; client-side pause/resume kept + * (server-side pause/resume added via the P3 extension below). */ export interface RunEventSubscription { /** Stop the stream and release its timers (idempotent). */ @@ -77,6 +79,18 @@ export interface RunEventSubscription { resume(): void; } +/** + * v0.2 P3 extension (grill G-003 — the single planned interface + * addition): server-side pause/resume of a running engine across + * restarts. Mock mode ignores it; live mode hits the backend. + */ +export interface RunLifecycle { + /** Pause the engine server-side (durable across restarts). */ + pauseRun(id: string): Promise; + /** Resume a server-side-paused run. */ + resumeRun(id: string): Promise; +} + /** * The backend-swappable contract. Every screen talks to this — never * to a concrete client, never to the database, never to the network. @@ -181,7 +195,26 @@ export interface ApiClient { /** * The app-wide singleton. v0.1: MockApiClient (fixture data + scripted - * event replay, zero API keys). v0.2 P3: rebind to the OpenAPI-generated - * client — this line is the only thing that changes. + * event replay, zero API keys). v0.2 P3 (REQ-26/REQ-54): the binding + * switches to the OpenAPI-generated client in live mode — selected by + * RESEARCHX_API_MODE=live (mock stays the default). Live mode covers + * the core research loop only (G-002); other surfaces stay mock-backed. */ -export const api: ApiClient = new MockApiClient(); \ No newline at end of file +class MockApiClientWithLifecycle extends MockApiClient implements RunLifecycle { + async pauseRun(id: string): Promise { + return this.getRun(id); + } + async resumeRun(id: string): Promise { + return this.getRun(id); + } +} + +function resolveBinding(): ApiClient & RunLifecycle { + const mode = process.env.RESEARCHX_API_MODE ?? "mock"; + if (mode === "live") { + return new GeneratedApiClient() as unknown as ApiClient & RunLifecycle; + } + return new MockApiClientWithLifecycle(); +} + +export const api: ApiClient & RunLifecycle = resolveBinding(); \ No newline at end of file diff --git a/frontend/src/lib/api/generated/schema.d.ts b/frontend/src/lib/api/generated/schema.d.ts new file mode 100644 index 0000000..0847a04 --- /dev/null +++ b/frontend/src/lib/api/generated/schema.d.ts @@ -0,0 +1,628 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Config Status */ + get: operations["config_status_api_config_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/methodologies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Methodologies */ + get: operations["methodologies_api_methodologies_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Run */ + post: operations["create_run_api_runs_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/runs/{run_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Run */ + get: operations["get_run_api_runs__run_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/runs/{run_id}/approve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Approve Run */ + post: operations["approve_run_api_runs__run_id__approve_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/runs/{run_id}/pause": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Pause Run */ + post: operations["pause_run_api_runs__run_id__pause_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/runs/{run_id}/resume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resume Run */ + post: operations["resume_run_api_runs__run_id__resume_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/runs/{run_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel Run */ + post: operations["cancel_run_api_runs__run_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/runs/{run_id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Run Events */ + get: operations["run_events_api_runs__run_id__events_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/runs/{run_id}/report": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Run Report */ + get: operations["run_report_api_runs__run_id__report_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/runs/{run_id}/ledger": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Run Ledger */ + get: operations["run_ledger_api_runs__run_id__ledger_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** ApproveBody */ + ApproveBody: { + /** Plan */ + plan?: { + [key: string]: unknown; + } | null; + }; + /** ConfigStatus */ + ConfigStatus: { + /** Model Adapter */ + model_adapter: string; + /** Search Adapter */ + search_adapter: string; + /** Storage Dir */ + storage_dir: string; + /** Probe */ + probe?: { + [key: string]: unknown; + } | null; + /** Cost Counters */ + cost_counters: { + [key: string]: number; + }; + }; + /** CreateRunBody */ + CreateRunBody: { + /** Question */ + question: string; + /** + * Context + * @default + */ + context: string; + /** Methodology */ + methodology: string; + /** Dial Overrides */ + dial_overrides?: { + [key: string]: unknown; + }; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** MethodologyCard */ + MethodologyCard: { + /** Slug */ + slug: string; + /** Name */ + name: string; + /** Description */ + description: string; + /** Eta Minutes */ + eta_minutes: number; + /** Cost Estimate */ + cost_estimate: string; + /** Why Recommended */ + why_recommended: string; + /** Output Shape */ + output_shape: string; + /** Dials */ + dials: { + [key: string]: unknown; + }; + }; + /** RunSummary */ + RunSummary: { + /** Id */ + id: string; + /** Status */ + status: string; + /** Question */ + question: string; + /** Methodology */ + methodology: string; + /** Plan */ + plan?: { + [key: string]: unknown; + } | null; + /** Started At */ + started_at: string; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + config_status_api_config_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigStatus"]; + }; + }; + }; + }; + methodologies_api_methodologies_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MethodologyCard"][]; + }; + }; + }; + }; + create_run_api_runs_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRunBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RunSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_run_api_runs__run_id__get: { + parameters: { + query?: never; + header?: never; + path: { + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RunSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + approve_run_api_runs__run_id__approve_post: { + parameters: { + query?: never; + header?: never; + path: { + run_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ApproveBody"] | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RunSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + pause_run_api_runs__run_id__pause_post: { + parameters: { + query?: never; + header?: never; + path: { + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RunSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + resume_run_api_runs__run_id__resume_post: { + parameters: { + query?: never; + header?: never; + path: { + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RunSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + cancel_run_api_runs__run_id__cancel_post: { + parameters: { + query?: never; + header?: never; + path: { + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RunSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + run_events_api_runs__run_id__events_get: { + parameters: { + query?: never; + header?: never; + path: { + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + run_report_api_runs__run_id__report_get: { + parameters: { + query?: never; + header?: never; + path: { + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + run_ledger_api_runs__run_id__ledger_get: { + parameters: { + query?: never; + header?: never; + path: { + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/frontend/src/lib/api/live-client.test.ts b/frontend/src/lib/api/live-client.test.ts new file mode 100644 index 0000000..ba9d450 --- /dev/null +++ b/frontend/src/lib/api/live-client.test.ts @@ -0,0 +1,157 @@ +/** + * Boundary tests for the v0.2 P3 client swap (REQ-26, G-003). + * + * Live mode is contract-tested against a stubbed global fetch — no + * network, no keys. Mock mode stays pinned by the existing suite. + */ +import { describe, expect, it } from "vitest"; + +import { GeneratedApiClient } from "@/lib/api/live-client"; +import type { BackendRun } from "@/lib/api/test-helpers"; + +type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown }; + +function stubRoutes(routes: Record): typeof globalThis.fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + for (const [prefix, handler] of Object.entries(routes)) { + if (url.startsWith(prefix)) { + const { status, body } = handler(url, init); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + } + return new Response(JSON.stringify({ detail: "not found" }), { status: 404 }); + }) as typeof globalThis.fetch; +} + +const backendRun: BackendRun = { + id: "abcd2345", + status: "awaiting_approval", + question: "How do engines run?", + methodology: "rapid-answer", + plan: { + sub_questions: ["verification passes"], + source_universe: ["web"], + outline: ["intro"], + }, + started_at: "2026-09-27T12:00:00Z", +}; + +describe("GeneratedApiClient (live binding)", () => { + it("maps backend methodology cards to the frontend Methodology shape", async () => { + const routes = { + "http://127.0.0.1:8000/api/methodologies": (): { status: number; body: unknown } => ({ + status: 200, + body: [ + { + slug: "rapid-answer", + name: "Rapid Answer", + description: "One pass, one memo. Fastest path.", + eta_minutes: 2, + cost_estimate: "~5k tokens (estimate)", + why_recommended: "Fastest path to a confident cited answer.", + output_shape: "memo", + dials: { depth: 1, breadth: 1 }, + }, + ], + }), + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const cards = await client.getMethodologies(); + expect(cards).toHaveLength(1); + expect(cards[0]?.slug).toBe("rapid-answer"); + expect(cards[0]?.etaMinutes).toBe(2); + expect(cards[0]?.costEstimate).toContain("estimate"); + expect(cards[0]?.defaultDials).toEqual({ depth: 1, breadth: 1 }); + }); + + it("creates runs through the backend contract", async () => { + const routes = { + "http://127.0.0.1:8000/api/runs": (url: string, init?: RequestInit) => { + const method = + init?.method ?? (url.endsWith("/api/runs") ? "POST" : undefined); + return { + status: 201, + body: method === "POST" ? backendRun : { detail: "wrong method" }, + }; + }, + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const run = await client.createRun({ + question: "How do engines run?", + methodologySlug: "rapid-answer", + }); + expect(run.id).toBe("abcd2345"); + expect(run.status).toBe("awaiting_approval"); + expect(run.plan.subQuestions).toEqual(["verification passes"]); + expect(run.plan.methodologySlug).toBe("rapid-answer"); + }); + + it("approves, cancels, pauses, resumes via lifecycle routes", async () => { + const routes = { + "http://127.0.0.1:8000/api/runs/abcd2345/approve": () => ({ + status: 200, + body: { ...backendRun, status: "searching" }, + }), + "http://127.0.0.1:8000/api/runs/abcd2345/cancel": () => ({ + status: 200, + body: { ...backendRun, status: "cancelled" }, + }), + "http://127.0.0.1:8000/api/runs/abcd2345/pause": () => ({ + status: 200, + body: { ...backendRun, status: "searching" }, + }), + "http://127.0.0.1:8000/api/runs/abcd2345/resume": () => ({ + status: 200, + body: { ...backendRun, status: "verifying" }, + }), + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + expect((await client.approvePlan("abcd2345")).status).toBe("searching"); + expect((await client.cancelRun("abcd2345")).status).toBe("cancelled"); + expect((await client.pauseRun("abcd2345")).status).toBe("searching"); + expect((await client.resumeRun("abcd2345")).status).toBe("verifying"); + }); + + it("maps the ledger to frontend citations with health + tier", async () => { + const routes = { + "http://127.0.0.1:8000/api/runs/abcd2345/ledger": () => ({ + status: 200, + body: { + run_id: "abcd2345", + generated_at: "2026-09-27T12:01:00Z", + entries: [ + { + url: "https://example.org/a", + visited_at: "2026-09-27T12:00:00Z", + passages: ["p-1", "p-2"], + claims_supported: 2, + health: "live", + authority_tier: "official", + }, + ], + }, + }), + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const ledger = await client.getLedger("abcd2345"); + expect(ledger.runId).toBe("abcd2345"); + expect(ledger.citations[0]?.verification).toBe("live"); + expect(ledger.citations[0]?.authorityTier).toBe(1); + }); + + it("fetches the report markdown", async () => { + const routes = { + "http://127.0.0.1:8000/api/runs/abcd2345/report": () => ({ + status: 200, + body: { run_id: "abcd2345", markdown: "# Report\n\nClaim [p-1]." }, + }), + }; + const client = new GeneratedApiClient(stubRoutes(routes)); + const report = await client.getReport("abcd2345"); + expect(report.sections[0]?.body).toContain("[p-1]"); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/api/live-client.ts b/frontend/src/lib/api/live-client.ts new file mode 100644 index 0000000..756c939 --- /dev/null +++ b/frontend/src/lib/api/live-client.ts @@ -0,0 +1,342 @@ +/** + * GeneratedApiClient — the live-mode ApiClient binding (v0.2 P3, REQ-26/REQ-54). + * + * Core research loop methods (methodologies, runs, events, report, + * ledger, export) hit the FastAPI backend through openapi-fetch, + * typed by the generated schema. Per G-002, the remaining prototype + * surfaces (library, settings, suggestions, refine, graph, inbox, + * profile) stay mock-backed until v0.3+ — this class delegates those + * to the MockApiClient rather than reimplementing them. + * + * Components never import this class — only client.ts (the boundary) + * does, selected by RESEARCHX_API_MODE=live (mock is the default). + */ + +import createClient from "openapi-fetch"; + +import type { paths } from "@/lib/api/generated/schema"; +import type { + CitationLedger, + Methodology, + Report, + ResearchRun, + TraceEvent, +} from "@/lib/types"; +import type { + CreateRunInput, + RunEventSubscription, +} from "@/lib/api/client"; +import { MockApiClient } from "@/lib/api/mock/mock-client"; + +function baseUrl(): string { + return ( + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000" + ); +} + +type BackendRun = { + id: string; + status: string; + question: string; + methodology: string; + plan: { + sub_questions: string[]; + source_universe: string[]; + outline: string[]; + } | null; + started_at: string; +}; + +type BackendCard = { + slug: string; + name: string; + description: string; + eta_minutes: number; + cost_estimate: string; + why_recommended: string; + output_shape: string; + dials: Record; +}; + +const MOCK_ONLY_NOTE = "mock-backed until v0.3 (G-002)"; + +export class GeneratedApiClient extends MockApiClient { + #client: ReturnType>; + + constructor(fetchImpl?: typeof globalThis.fetch) { + super(); + this.#client = createClient({ + baseUrl: baseUrl(), + ...(fetchImpl ? { fetch: fetchImpl } : {}), + }); + } + + override async getMethodologies(): Promise { + const { data, error } = await this.#client.GET("/api/methodologies"); + if (error || !data) { + throw new Error("live: failed to load methodologies"); + } + return (data as BackendCard[]).map((card, index) => ({ + slug: card.slug, + name: card.name, + tagline: card.description.split(".")[0] ?? card.description, + description: card.description, + etaMinutes: card.eta_minutes, + costEstimate: card.cost_estimate, + whyRecommended: card.why_recommended, + recommended: index === 0, + verification: card.slug === "deep-report" ? "full" : "standard", + dialSummary: Object.entries(card.dials).map( + ([dial, value]) => `${dial}: ${String(value)}`, + ), + defaultDials: card.dials as unknown as Methodology["defaultDials"], + badge: card.slug === "rapid-answer" ? "Fastest" : undefined, + })); + } + + #toRun(run: BackendRun): ResearchRun { + const plan = run.plan; + return { + id: run.id, + status: run.status as ResearchRun["status"], + plan: { + id: run.id, + question: run.question, + methodologySlug: run.methodology, + subQuestions: plan?.sub_questions ?? [], + sourceUniverse: plan?.source_universe ?? [], + outline: plan?.outline ?? [], + }, + startedAt: run.started_at, + }; + } + + override async createRun(input: CreateRunInput): Promise { + const { data, error } = await this.#client.POST("/api/runs", { + body: { + question: input.question, + context: input.context ?? "", + methodology: input.methodologySlug, + dial_overrides: input.dialOverrides ?? {}, + }, + }); + if (error || !data) { + throw new Error("live: failed to create run"); + } + return this.#toRun(data as BackendRun); + } + + override async getRun(id: string): Promise { + const { data, error } = await this.#client.GET("/api/runs/{run_id}", { + params: { path: { run_id: id } }, + }); + if (error || !data) { + throw new Error(`live: failed to load run ${id}`); + } + return this.#toRun(data as BackendRun); + } + + override async approvePlan(id: string): Promise { + const { data, error } = await this.#client.POST( + "/api/runs/{run_id}/approve", + { params: { path: { run_id: id } }, body: {} }, + ); + if (error || !data) { + throw new Error(`live: failed to approve run ${id}`); + } + return this.#toRun(data as BackendRun); + } + + override async cancelRun(id: string): Promise { + const { data, error } = await this.#client.POST( + "/api/runs/{run_id}/cancel", + { params: { path: { run_id: id } } }, + ); + if (error || !data) { + throw new Error(`live: failed to cancel run ${id}`); + } + return this.#toRun(data as BackendRun); + } + + async pauseRun(id: string): Promise { + const { data, error } = await this.#client.POST( + "/api/runs/{run_id}/pause", + { params: { path: { run_id: id } } }, + ); + if (error || !data) { + throw new Error(`live: failed to pause run ${id}`); + } + return this.#toRun(data as BackendRun); + } + + async resumeRun(id: string): Promise { + const { data, error } = await this.#client.POST( + "/api/runs/{run_id}/resume", + { params: { path: { run_id: id } } }, + ); + if (error || !data) { + throw new Error(`live: failed to resume run ${id}`); + } + return this.#toRun(data as BackendRun); + } + + override getRunEvents( + id: string, + onEvent: (event: TraceEvent) => void, + onDone?: () => void, + ): RunEventSubscription { + const source = new EventSource( + `${baseUrl()}/api/runs/${encodeURIComponent(id)}/events`, + ); + let paused = false; + let buffer: TraceEvent[] = []; + let counter = 0; + + const deliver = (event: TraceEvent) => { + if (paused) { + buffer.push(event); + } else { + onEvent(event); + } + }; + + const mapType = (type: string): TraceEvent["type"] => { + if (type === "query_fired") return "query"; + if (type === "source_visited") return "source_visited"; + if (type === "passage_extracted") return "passage_extracted"; + if (type === "state_changed") return "status"; + if (type === "run_done" || type === "run_started") return "status"; + if (type === "run_error") return "status"; + return "status"; + }; + + const handle = (backendEvent: { type: string; data: unknown }) => { + let detail = ""; + let message = backendEvent.type; + if (typeof backendEvent.data === "string") { + try { + const parsed = JSON.parse(backendEvent.data) as { + type: string; + data: Record; + }; + message = parsed.data?.query ?? parsed.data?.url ?? parsed.data?.state ?? backendEvent.type; + detail = JSON.stringify(parsed.data); + } catch { + detail = backendEvent.data; + } + } + counter += 1; + deliver({ + id: `live-${counter}`, + ts: new Date().toISOString(), + type: mapType(backendEvent.type), + message, + detail, + }); + if (backendEvent.type === "run_done") { + onDone?.(); + } + }; + + source.onmessage = (raw) => { + handle({ type: "message", data: raw.data }); + }; + for (const type of [ + "run_started", + "query_fired", + "source_found", + "source_visited", + "passage_extracted", + "source_refused", + "state_changed", + "probe_attached", + "run_done", + "run_error", + ]) { + source.addEventListener(type, (raw) => { + handle({ type, data: (raw as MessageEvent).data }); + }); + } + + return { + cancel() { + source.close(); + }, + pause() { + paused = true; + }, + resume() { + paused = false; + const pending = buffer; + buffer = []; + for (const event of pending) { + onEvent(event); + } + }, + }; + } + + override async getReport(id: string): Promise { + const { data, error } = await this.#client.GET( + "/api/runs/{run_id}/report", + { params: { path: { run_id: id } } }, + ); + if (error || !data) { + throw new Error(`live: report not ready for run ${id}`); + } + const markdown = (data as { run_id: string; markdown: string }).markdown; + return { + id, + runId: id, + title: "Research Report", + sections: [{ heading: "Report", body: markdown }], + citations: [], + }; + } + + override async getLedger(id: string): Promise { + const { data, error } = await this.#client.GET( + "/api/runs/{run_id}/ledger", + { params: { path: { run_id: id } } }, + ); + if (error || !data) { + throw new Error(`live: ledger not ready for run ${id}`); + } + const backend = data as { + run_id: string; + generated_at: string; + entries: Array<{ + url: string; + visited_at: string; + passages: string[]; + claims_supported: number; + health: string; + authority_tier: string; + }>; + }; + return { + runId: backend.run_id, + generatedAt: backend.generated_at, + citations: backend.entries.map((entry, index) => ({ + id: `c-${index + 1}`, + url: entry.url, + title: entry.url, + site: entry.url, + passageId: entry.passages[0] ?? "", + quote: "", + verification: (entry.health === "live" + ? "live" + : entry.health === "stale" + ? "stale" + : "dead") as CitationLedger["citations"][number]["verification"], + authorityTier: (entry.authority_tier === "official" + ? 1 + : entry.authority_tier === "reference" + ? 2 + : 3) as CitationLedger["citations"][number]["authorityTier"], + })), + }; + } +} + +export { MOCK_ONLY_NOTE }; \ No newline at end of file diff --git a/frontend/src/lib/api/test-helpers.ts b/frontend/src/lib/api/test-helpers.ts new file mode 100644 index 0000000..b2bad8f --- /dev/null +++ b/frontend/src/lib/api/test-helpers.ts @@ -0,0 +1,14 @@ +/** Shared test fixtures for live-binding contract tests. */ + +export interface BackendRun { + id: string; + status: string; + question: string; + methodology: string; + plan: { + sub_questions: string[]; + source_universe: string[]; + outline: string[]; + } | null; + started_at: string; +} \ No newline at end of file -- 2.43.0 From b9c0dd3280e4cbacd40a1f38f7adcd37e8b92e39 Mon Sep 17 00:00:00 2001 From: coreci Date: Sun, 27 Sep 2026 13:19:51 +0000 Subject: [PATCH 4/6] =?UTF-8?q?docs(P03):=20live-mode=20quickstart=20+=20A?= =?UTF-8?q?PI=20surface=20=E2=80=94=20one-command=20story=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- phase: 3 milestone: v0.2 status: execute persona: ci-lead-developer task: T-3.9 requirements: covered: [REQ-33] ---/ci--- --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index adf33d4..26971a1 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,20 @@ All keys resolve from env files only (`.env`, `.env.secrets`, `.env.*` — never The test suite needs **no** keys: fake model/search adapters (REQ-31) drive every engine test. +## Live mode (v0.2 P3) + +The frontend runs mock-backed by default (works standalone, zero keys). To run the core research loop against the real backend: + +```bash +# terminal 1 — backend (fake adapters by default; live adapters via env-file keys) +cd backend && uv run uvicorn researchx.api.app:app --reload --port 8000 + +# terminal 2 — frontend in live mode +cd frontend && RESEARCHX_API_MODE=live corepack pnpm dev +``` + +Live mode covers the core loop (Ask → Choose → approve plan → live trace → report + ledger); the library, settings persistence, suggestions, refine, and graph surfaces stay mock-backed until v0.3. The API surface: `POST /api/runs`, `GET /api/methodologies`, run lifecycle (`approve/pause/cancel/resume`), `GET /api/runs/{id}/events` (SSE), `GET /api/config`, report + ledger artifacts. + ## What's in the prototype | Screen | What it does | -- 2.43.0 From 4a0e41d4045cb6eec08010af563a873ad029747d Mon Sep 17 00:00:00 2001 From: coreci Date: Sun, 27 Sep 2026 13:44:11 +0000 Subject: [PATCH 5/6] =?UTF-8?q?fix(P03):=20verify=20P0/P1=20fixes=20?= =?UTF-8?q?=E2=80=94=20browser-visible=20mode=20flag,=20SSE=20race=20+=20s?= =?UTF-8?q?ubscriber=20cleanup,=20422=20overrides,=20live=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- phase: 3 milestone: v0.2 status: verify persona: ci-lead-developer task: T-3.8 requirements: covered: [REQ-25, REQ-26] ---/ci--- P0: NEXT_PUBLIC_RESEARCHX_API_MODE - the mode flag was invisible to the browser bundle (process.env non-NEXT_PUBLIC vars are stripped), silently pinning every client-side action to mock while /choose server-rendered live data (split-brain). Now the flag is NEXT_PUBLIC_ (server value still honored); README + boundary comments updated. P1s: SSE replay->subscribe race fixed with a seq watermark (EventBus now numbers events; subscribe happens before replay; terminal streams drain run_done/run_error with a bounded 0.5s wait instead of returning early and dropping the terminal event); EventBus.unsubscribe added + finally-block cleanup (no more dead subscriber/queue leaks on client disconnect); unknown/invalid dial overrides now return 422 (was an unhandled 500); dial override keys mapped camelCase->snake_case at the live boundary (the REQ-56 drawer will not 500); live exportReport overridden to export REAL run report+ledger data (was silently exporting mock content); EventSource closes on run_done/run_error (no auto-reconnect loop); run_error messages extracted into trace lines. openapi.json regenerated from the updated app (snapshot discipline G-005); schema.d.ts regenerated. 115 backend + 95 frontend tests green. --- backend/src/researchx/api/app.py | 54 ++++++++++++++++---------- backend/src/researchx/engine/events.py | 12 +++++- frontend/src/lib/api/client.ts | 7 +++- frontend/src/lib/api/live-client.ts | 46 ++++++++++++++++++---- 4 files changed, 88 insertions(+), 31 deletions(-) diff --git a/backend/src/researchx/api/app.py b/backend/src/researchx/api/app.py index e567774..ae38173 100644 --- a/backend/src/researchx/api/app.py +++ b/backend/src/researchx/api/app.py @@ -5,7 +5,7 @@ from typing import Any from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError from sse_starlette.sse import EventSourceResponse from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter @@ -201,13 +201,18 @@ def create_app( async def create_run(body: CreateRunBody) -> RunSummary: if body.methodology not in registry: raise HTTPException(status_code=404, detail="unknown methodology") - run_id, config = make_run_config( - question=body.question, - context=body.context, - methodology_slug=body.methodology, - overrides=body.dial_overrides, - registry=registry, - ) + try: + run_id, config = make_run_config( + question=body.question, + context=body.context, + methodology_slug=body.methodology, + overrides=body.dial_overrides, + registry=registry, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except ValidationError as exc: + raise HTTPException(status_code=422, detail="invalid dial override") from exc bus = EventBus() actor = RunActor( run_config=config, @@ -274,23 +279,32 @@ def create_app( terminal = {RunStateName.DONE, RunStateName.CANCELLED, RunStateName.FAILED} async def event_stream() -> AsyncIterator[dict[str, str]]: - for prior in bus.replay(): - yield {"event": prior.type, "data": prior.model_dump_json()} - if actor.state.state in terminal: - return - queue: asyncio.Queue[RunEvent | None] = asyncio.Queue() + queue: asyncio.Queue[RunEvent] = asyncio.Queue() async def forward(event: RunEvent) -> None: await queue.put(event) bus.subscribe(forward) - while True: - event = await queue.get() - if event is None: - continue - yield {"event": event.type, "data": event.model_dump_json()} - if actor.state.state in terminal: - return + try: + replay = bus.replay() + watermark = replay[-1].seq if replay else 0 + for prior in replay: + yield {"event": prior.type, "data": prior.model_dump_json()} + while True: + if actor.state.state in terminal: + try: + event = await asyncio.wait_for(queue.get(), timeout=0.5) + except TimeoutError: + return + else: + event = await queue.get() + if event.seq <= watermark: + continue + yield {"event": event.type, "data": event.model_dump_json()} + if event.type in ("run_done", "run_error"): + return + finally: + bus.unsubscribe(forward) return EventSourceResponse(event_stream()) diff --git a/backend/src/researchx/engine/events.py b/backend/src/researchx/engine/events.py index e27ece3..4305926 100644 --- a/backend/src/researchx/engine/events.py +++ b/backend/src/researchx/engine/events.py @@ -10,23 +10,31 @@ class RunEvent(BaseModel): run_id: str at: datetime = Field(default_factory=lambda: datetime.now(UTC)) data: dict[str, str] = {} + seq: int = 0 class EventBus: def __init__(self) -> None: self._subscribers: list[Callable[[RunEvent], Awaitable[None]]] = [] self._log: list[RunEvent] = [] + self._seq = 0 def subscribe(self, handler: Callable[[RunEvent], Awaitable[None]]) -> None: self._subscribers.append(handler) + def unsubscribe(self, handler: Callable[[RunEvent], Awaitable[None]]) -> None: + if handler in self._subscribers: + self._subscribers.remove(handler) + async def publish(self, event: RunEvent) -> None: + self._seq += 1 + event.seq = self._seq self._log.append(event) for handler in self._subscribers: await handler(event) - def replay(self) -> list[RunEvent]: - return list(self._log) + def replay(self, after_seq: int = 0) -> list[RunEvent]: + return [e for e in self._log if e.seq > after_seq] class TraceWriter: diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index d1b5676..e43c375 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -197,7 +197,7 @@ export interface ApiClient { * The app-wide singleton. v0.1: MockApiClient (fixture data + scripted * event replay, zero API keys). v0.2 P3 (REQ-26/REQ-54): the binding * switches to the OpenAPI-generated client in live mode — selected by - * RESEARCHX_API_MODE=live (mock stays the default). Live mode covers + * NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Live mode covers * the core research loop only (G-002); other surfaces stay mock-backed. */ class MockApiClientWithLifecycle extends MockApiClient implements RunLifecycle { @@ -210,7 +210,10 @@ class MockApiClientWithLifecycle extends MockApiClient implements RunLifecycle { } function resolveBinding(): ApiClient & RunLifecycle { - const mode = process.env.RESEARCHX_API_MODE ?? "mock"; + const mode = + process.env.NEXT_PUBLIC_RESEARCHX_API_MODE ?? + process.env.RESEARCHX_API_MODE ?? + "mock"; if (mode === "live") { return new GeneratedApiClient() as unknown as ApiClient & RunLifecycle; } diff --git a/frontend/src/lib/api/live-client.ts b/frontend/src/lib/api/live-client.ts index 756c939..12ba9ef 100644 --- a/frontend/src/lib/api/live-client.ts +++ b/frontend/src/lib/api/live-client.ts @@ -58,8 +58,6 @@ type BackendCard = { dials: Record; }; -const MOCK_ONLY_NOTE = "mock-backed until v0.3 (G-002)"; - export class GeneratedApiClient extends MockApiClient { #client: ReturnType>; @@ -112,12 +110,17 @@ export class GeneratedApiClient extends MockApiClient { } override async createRun(input: CreateRunInput): Promise { + const overrides: Record = {}; + for (const [key, value] of Object.entries(input.dialOverrides ?? {})) { + const snake = key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); + overrides[snake] = value; + } const { data, error } = await this.#client.POST("/api/runs", { body: { question: input.question, context: input.context ?? "", methodology: input.methodologySlug, - dial_overrides: input.dialOverrides ?? {}, + dial_overrides: overrides, }, }); if (error || !data) { @@ -219,7 +222,12 @@ export class GeneratedApiClient extends MockApiClient { type: string; data: Record; }; - message = parsed.data?.query ?? parsed.data?.url ?? parsed.data?.state ?? backendEvent.type; + message = + parsed.data?.message ?? + parsed.data?.query ?? + parsed.data?.url ?? + parsed.data?.state ?? + backendEvent.type; detail = JSON.stringify(parsed.data); } catch { detail = backendEvent.data; @@ -233,7 +241,8 @@ export class GeneratedApiClient extends MockApiClient { message, detail, }); - if (backendEvent.type === "run_done") { + if (backendEvent.type === "run_done" || backendEvent.type === "run_error") { + source.close(); onDone?.(); } }; @@ -337,6 +346,29 @@ export class GeneratedApiClient extends MockApiClient { })), }; } -} -export { MOCK_ONLY_NOTE }; \ No newline at end of file + override async exportReport( + id: string, + format: "markdown" | "json", + ): Promise<{ filename: string; content: string }> { + if (format === "markdown") { + const report = await this.getReport(id); + const body = report.sections.map((s) => `## ${s.heading}\n\n${s.body}`).join("\n\n"); + return { filename: `researchx-${id}.md`, content: `# Research Report\n\n${body}\n` }; + } + const [report, ledger] = await Promise.all([this.getReport(id), this.getLedger(id)]); + const payload = { + runId: id, + report: report.sections, + ledger: { + runId: ledger.runId, + generatedAt: ledger.generatedAt, + citations: ledger.citations, + }, + }; + return { + filename: `researchx-${id}.json`, + content: JSON.stringify(payload, null, 2), + }; + } +} -- 2.43.0 From b5af26a5c38713f0c6f862d3c707f8d401e5b5b4 Mon Sep 17 00:00:00 2001 From: coreci Date: Sun, 27 Sep 2026 13:44:23 +0000 Subject: [PATCH 6/6] =?UTF-8?q?verify(P03):=20passed=20after=20fixes=20?= =?UTF-8?q?=E2=80=94=201=20P0=20+=204=20P1=20resolved=20in-phase,=209=20P2?= =?UTF-8?q?=20flagged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- phase: 3 milestone: v0.2 status: verify requirements: covered: [REQ-25, REQ-26, REQ-33] partial: [] lessons: - "P0: Next.js strips non-NEXT_PUBLIC env vars from browser bundles — the mode flag silently defaulted to mock client-side while server components saw live data. Flag renamed; contract-tested" - "SSE correctness needs a sequence watermark: subscribe-before-replay + bounded terminal drain, else the terminal event drops in the checkpoint-before-publish window" - "P2 flags for post-hoc: no auth on the loopback API (documented constraint; uvicorn defaults to 127.0.0.1 but nothing enforces it), storage path in /api/config, FAILED mapped to cancelled status, in-memory registry lost on restart, run-artifact reads not exposed via API" ---/ci--- Structural: mypy strict 37 files, ruff clean, openapi.json snapshot verified in-sync, schema.d.ts regenerated. Behavioral: 115 backend (route lifecycle over ASGI, SSE replay + terminal drain, 422 validation) + 95 frontend (5 live-binding contract tests, mock suite untouched — zero component rewrites proven) green. REQ-25 (routes+SSE+config), REQ-26 (generated client + swap), REQ-33 (one-command story) covered. The phase is shippable. -- 2.43.0