diff --git a/fusionlab/api.py b/fusionlab/api.py index 2a208ca..ed6e0de 100644 --- a/fusionlab/api.py +++ b/fusionlab/api.py @@ -15,6 +15,7 @@ from fusionlab.api_agent import router as agent_router from fusionlab.api_replay import router as replay_router from fusionlab.api_replay import start_warm_up +from fusionlab.api_studies import router as studies_router from fusionlab.api_virtual import router as virtual_router from fusionlab.physics import DEVICES, Controls, sigmav_dt, simulate @@ -219,6 +220,7 @@ def index(): app.include_router(replay_router) app.include_router(virtual_router) app.include_router(agent_router) +app.include_router(studies_router) @app.get("/static/agent.js", include_in_schema=False) diff --git a/fusionlab/api_studies.py b/fusionlab/api_studies.py new file mode 100644 index 0000000..e4cf2de --- /dev/null +++ b/fusionlab/api_studies.py @@ -0,0 +1,280 @@ +"""Named session studies (backlog #44): a saved view — workspace, shot(s), slice, what-if/explore +parameters — plus a markdown note, stored server-side so a short `#/study/{id}` link restores the +same view in any browser. The router is list/get/create/update beside the other three routers in +`fusionlab/api.py`. + +Storage follows the protocol-store pattern (`fusionlab/protocol.py`): one JSON document per id +under `data/studies/`, written only through `atomicio.atomic_write` (an interrupted write never +leaves a half-file), ids minted server-side matching a strict pattern (`s_[0-9a-f]{12}`) so a +tampered request can never name a path — a malformed id is rejected as 422 before it can become +one, and a well-formed unknown id is a clean 404, checked before any write. Runtime data (ignored +with the rest of `data/*`), single-user demo scale. + +Honesty: a study is a comparison record. The server stamps the house words into every study's +provenance — stored copy and rendered copy say "compared with MAST data, never predictive". The +stored `route` must be one of the router's own hash routes (no `#/study/…` nesting — a study +points at a concrete view, never at another study link). +""" + +from __future__ import annotations + +import json +import logging +import re +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from fusionlab.atomicio import atomic_write + +logger = logging.getLogger(__name__) + +router = APIRouter() + +STUDY_ID_PATTERN = re.compile(r"s_[0-9a-f]{12}") +# A study's route is a view the workspace router (web/workspaces.js) owns: replay/compare/explore. +# `#/study/…` is deliberately absent — a study points at a view, never at another study link. The +# path parameter is only ever shot ids and compare's comma list (`[0-9,]*`), so traversal-shaped +# strings ("//../..") cannot ride in as a stored route. +ROUTE_RE = re.compile(r"^#/(replay|compare|explore)(?:/[0-9,]*)?(?:\?.*)?$") + +STUDIES_DIR = Path(__file__).resolve().parent.parent / "data" / "studies" +MAX_NOTE_CHARS = 20_000 # a page-length note; a bound on the field, checked by the schema +MAX_NAME_CHARS = 200 +MAX_ROUTE_CHARS = 500 + +HOUSE_WORDS = "compared with MAST data, never predictive" + + +class StudyIdError(ValueError): + """A malformed study id — rejected before it can name a path (the route answers 422).""" + + +class InvalidStudy(ValueError): + """A study body that does not fit the house shape (e.g. a non-hash route) — clean 422.""" + + +class StudyProvenance(BaseModel): + """Server-built provenance: where the study was made from, and the honesty rule it rides with. + The client cannot supply `house_words` — the server always stamps the house words.""" + + created_from: str = "palette" + house_words: str = HOUSE_WORDS + + +class StudyIn(BaseModel): + """The client's fields of a new study. Ids, timestamps, workspace and provenance are + server-built: a request body cannot mint an id, forge a stamp, or point a study at another + study link. Non-finite numbers are rejected one layer down, at the route door.""" + + name: str = Field(min_length=1, max_length=MAX_NAME_CHARS) + route: str = Field(min_length=1, max_length=MAX_ROUTE_CHARS) + shot_ids: list[int] = Field(default_factory=list, max_length=16) + ref_shot: int | None = None + what_if: dict[str, float] | None = Field(default=None, max_length=16) + explore: dict[str, float | str] | None = Field(default=None, max_length=32) + notes_md: str = Field(default="", max_length=MAX_NOTE_CHARS) + created_from: str = Field(default="palette", max_length=40) + + +class StudyUpdate(BaseModel): + """The fields an update may change; omitted fields are left as stored. `created_from` and the + provenance block are deliberately absent — where a study came from never changes.""" + + name: str | None = Field(default=None, min_length=1, max_length=MAX_NAME_CHARS) + route: str | None = Field(default=None, min_length=1, max_length=MAX_ROUTE_CHARS) + shot_ids: list[int] | None = Field(default=None, max_length=16) + ref_shot: int | None = None + what_if: dict[str, float] | None = Field(default=None, max_length=16) + explore: dict[str, float | str] | None = Field(default=None, max_length=32) + notes_md: str | None = Field(default=None, max_length=MAX_NOTE_CHARS) + + +class Study(BaseModel): + """One stored study — the full document at `data/studies/{id}.json`.""" + + id: str + name: str + created: str + updated: str + workspace: str + route: str + shot_ids: list[int] + ref_shot: int | None + what_if: dict[str, float] | None + explore: dict[str, float | str] | None + notes_md: str + provenance: StudyProvenance + + +# ---------------------------------------------------------------- the store +def _now() -> str: + return datetime.now(UTC).isoformat(timespec="seconds") + + +def new_study_id() -> str: + return "s_" + uuid.uuid4().hex[:12] + + +def _assert_study_id(study_id: str) -> None: + """Drafts and studies are named from the id — a malformed id is unknown before it can become a + path (protocol.py's `_assert_protocol_id` discipline).""" + if not STUDY_ID_PATTERN.fullmatch(study_id): + raise StudyIdError(f"unknown study id '{study_id}'") + + +def _validated_route(route: str) -> str: + if not ROUTE_RE.fullmatch(route): + raise InvalidStudy( + "route must be a FusionLab hash view — '#/replay/…', '#/compare/…' or '#/explore'" + ) + return route + + +def study_path(study_id: str) -> Path: + _assert_study_id(study_id) + return STUDIES_DIR / f"{study_id}.json" + + +def _dump(study: Study) -> str: + return json.dumps(study.model_dump(mode="json"), indent=2, allow_nan=False) + + +def save_study(study: Study) -> None: + """Atomic write (atomicio): an interrupted save never leaves a half-file.""" + STUDIES_DIR.mkdir(parents=True, exist_ok=True) + atomic_write(study_path(study.id), lambda tmp: tmp.write_text(_dump(study), encoding="utf-8")) + + +def load_study(study_id: str) -> Study: + """The stored study, re-validated on read — a hand-edited study file fails here, not in the UI.""" + path = study_path(study_id) + if not path.is_file(): + raise LookupError(f"no study '{study_id}' — the link may be broken or it was deleted") + try: + return Study.model_validate(json.loads(path.read_text(encoding="utf-8"))) + except (ValueError, json.JSONDecodeError) as e: + logger.info("study %s failed validation: %s", study_id, e) + raise ValueError(f"study '{study_id}' is not a valid study — it may have been edited " + "outside the app") from e + + +def list_studies() -> list[Study]: + """Every stored study, newest-updated first (ties broken by id, so the order is stable across + calls). One unreadable file is logged and skipped — it must not hide every other study.""" + studies: list[Study] = [] + if not STUDIES_DIR.is_dir(): + return studies + for path in STUDIES_DIR.glob("*.json"): + if not STUDY_ID_PATTERN.fullmatch(path.stem): + continue + try: + studies.append(Study.model_validate(json.loads(path.read_text(encoding="utf-8")))) + except (ValueError, json.JSONDecodeError) as e: + logger.warning("skipping unreadable study file %s: %s", path.name, e) + studies.sort(key=lambda s: (s.updated, s.id), reverse=True) + return studies + + +def create_study(payload: StudyIn) -> Study: + """A new study: the id, timestamps, workspace (derived from the route) and provenance are + server-built; only the study's content comes from the client.""" + route = _validated_route(payload.route) + study = Study( + id=new_study_id(), name=payload.name, created=_now(), updated=_now(), + workspace=route.split("/")[1], route=route, shot_ids=payload.shot_ids, + ref_shot=payload.ref_shot, what_if=payload.what_if, explore=payload.explore, + notes_md=payload.notes_md, + provenance=StudyProvenance(created_from=payload.created_from or "palette"), + ) + save_study(study) + return study + + +def update_study(study_id: str, payload: StudyUpdate) -> Study: + """Patch the stored study (404-before-write: an unknown id never touches the filesystem) and + stamp `updated`. Fields the request omits stay as stored; an explicit null clears.""" + study = load_study(study_id) # no study, nothing to update — 404 before any write + data: dict[str, Any] = payload.model_dump(exclude_unset=True) + route = data.pop("route", None) + if route is not None: + validated = _validated_route(str(route)) + study.route = validated + study.workspace = validated.split("/")[1] + for key in ("name", "shot_ids", "ref_shot", "what_if", "explore", "notes_md"): + if key in data: + setattr(study, key, data[key]) + study.updated = _now() + save_study(study) + return study + + +# ---------------------------------------------------------------- routes +def _assert_standard_json(body: bytes) -> None: + """Python's json.loads silently accepts Infinity/NaN literals, but this store's dump refuses + them (allow_nan=False) — and a validation-error reply cannot even echo an unserializable value + without crashing. So non-standard bodies are rejected at the door, before pydantic sees them: + a clean 422 that never echoes the value. Malformed JSON cannot reach here — pydantic 422s it + during body binding.""" + + def reject(lit: str) -> Any: + raise ValueError(lit) + + try: + json.loads(body, parse_constant=reject) + except ValueError as e: + raise HTTPException(422, "study parameters must be finite numbers") from e + + +@router.get("/studies") +def studies_list() -> dict[str, Any]: + """Every stored study, newest-updated first — the palette's Studies source (and its recent + pointers when the fetch fails).""" + return {"studies": [s.model_dump(mode="json") for s in list_studies()]} + + +@router.get("/studies/{study_id}") +def studies_get(study_id: str) -> dict[str, Any]: + """One study, whole. A malformed id is a 422 (rejected before it can name a path); a + well-formed unknown id is a clean 404.""" + try: + study = load_study(study_id) + except StudyIdError as e: + raise HTTPException(422, str(e)) from e + except LookupError as e: + raise HTTPException(404, str(e)) from e + return study.model_dump(mode="json") + + +@router.post("/studies", status_code=201) +async def studies_create(request: Request, payload: StudyIn) -> dict[str, Any]: + """Save the current view as a named study. Caps are schema bounds (a note over the cap is a + 422, nothing written); the route must be one of the app's own hash views; non-finite numbers + are rejected at the door.""" + _assert_standard_json(await request.body()) + try: + study = create_study(payload) + except InvalidStudy as e: + raise HTTPException(422, str(e)) from e + return study.model_dump(mode="json") + + +@router.patch("/studies/{study_id}") +async def studies_update(study_id: str, request: Request, payload: StudyUpdate) -> dict[str, Any]: + """Update a study's fields (the note after another look, a renamed study). Unknown ids are 404 + before any write; malformed ids are 422; an oversized note is a 422 that leaves the stored + study untouched.""" + _assert_standard_json(await request.body()) + try: + study = update_study(study_id, payload) + except StudyIdError as e: + raise HTTPException(422, str(e)) from e + except LookupError as e: + raise HTTPException(404, str(e)) from e + except InvalidStudy as e: + raise HTTPException(422, str(e)) from e + return study.model_dump(mode="json") diff --git a/tests/test_studies.py b/tests/test_studies.py new file mode 100644 index 0000000..af436d6 --- /dev/null +++ b/tests/test_studies.py @@ -0,0 +1,215 @@ +"""Named session studies (backlog #44): the store's CRUD round trip, its tamper-resistance (a +malformed id never names a path; an unknown id is a clean 404 before any write), the caps, atomic +writes, stable listing, and the honesty rule — provenance is server-stamped with the house words, +and a stored route is always one of the app's own hash views. Tripwires for the served JavaScript +live in test_web_checks.py, per the house split; the browser-side restore e2e is the recorded +dogfood in the fix PR (no JS harness, on purpose). +""" + +import json + +import pytest +from fastapi.testclient import TestClient + +from fusionlab import api_studies +from fusionlab.api import app +from fusionlab.atomicio import atomic_write + +client = TestClient(app) + + +@pytest.fixture() +def studies_dir(tmp_path, monkeypatch): + """The store pointed at an empty temporary directory — the repo's runtime data/ stays untouched + and every test starts from an empty store.""" + monkeypatch.setattr(api_studies, "STUDIES_DIR", tmp_path) + return tmp_path + + +def _body(**over): + body = { + "name": "NBI shift sweep on shot 30192", + "route": "#/replay/30192?t=0.412&edit=1", + "shot_ids": [30192], + "ref_shot": 30192, + "what_if": {"P_nbi": 1.3}, + "notes_md": "At −30 ms the shifted NBI pulse still lands inside the flattop.", + } + body.update(over) + return body + + +# ---------------------------------------------------------------- the round trip +def test_create_get_update_round_trip(studies_dir): + """Save → read → edit, through the HTTP surface: the stored document is the one served, and an + update stamps `updated` without touching `created`.""" + r = client.post("/studies", json=_body()) + assert r.status_code == 201, r.text + s = r.json() + assert api_studies.STUDY_ID_PATTERN.fullmatch(s["id"]) + assert s["workspace"] == "replay" + assert s["created"] == s["updated"] + + got = client.get(f"/studies/{s['id']}") + assert got.status_code == 200 + assert got.json() == s # the served study is exactly the stored one, ids to provenance + + p = client.patch(f"/studies/{s['id']}", json={"notes_md": "Re-read next review."}) + assert p.status_code == 200 + patched = p.json() + assert patched["notes_md"] == "Re-read next review." + assert patched["created"] == s["created"] and patched["updated"] >= s["updated"] + assert client.get(f"/studies/{s['id']}").json() == patched + + # and on disk: one JSON document, same content (the store is the file, not a parallel truth) + on_disk = json.loads((studies_dir / f"{s['id']}.json").read_text(encoding="utf-8")) + assert on_disk == patched + + +def test_omitted_fields_survive_patch_and_null_clears(studies_dir): + """PATCH semantics: omitted fields stay as stored; an explicit null clears (ref_shot).""" + s = client.post("/studies", json=_body()).json() + p = client.patch(f"/studies/{s['id']}", json={"name": "Renamed", "ref_shot": None}).json() + assert p["name"] == "Renamed" + assert p["ref_shot"] is None + assert p["what_if"] == {"P_nbi": 1.3} # omitted → untouched + assert p["notes_md"] == _body()["notes_md"] + + +def test_workspace_follows_route_on_update(studies_dir): + s = client.post("/studies", json=_body(route="#/explore")).json() + assert s["workspace"] == "explore" + p = client.patch(f"/studies/{s['id']}", json={"route": "#/compare/30166,30420?ref=30166"}).json() + assert p["workspace"] == "compare" + + +# ---------------------------------------------------------------- tamper-resistance +@pytest.mark.parametrize("bad", ["s_hack", "s_TOOSHORT", "s_0123456789zz", "x_s_01234567890", "30192"]) +def test_malformed_id_is_a_clean_422(studies_dir, bad): + """A malformed id is rejected as 422 before it can become a path — GET and PATCH alike, with + the store still empty (no read of, no write to, any filesystem name).""" + assert client.get(f"/studies/{bad}").status_code == 422 + assert client.patch(f"/studies/{bad}", json={"name": "x"}).status_code == 422 + assert list(studies_dir.glob("*")) == [] + + +def test_unknown_id_is_404_before_any_write(studies_dir): + """A well-formed unknown id is a clean 404 — and PATCH checks before writing (404-before-write, + the protocol-store discipline): no file, no directory, nothing.""" + for method, kwargs in (("get", {}), ("patch", {"json": {"notes_md": "x"}})): + r = getattr(client, method)("/studies/s_000000000000", **kwargs) + assert r.status_code == 404, r.text + assert "no study" in r.json()["detail"] + assert list(studies_dir.glob("*")) == [] + + +def test_client_cannot_forge_ids_or_stamps(studies_dir): + """Ids, timestamps and provenance are server-built: a request body carrying them is ignored, and + `house_words` always holds the house honesty rule.""" + forged = _body(id="s_ffffffffffff", created="1999-01-01T00:00:00+00:00", + updated="1999-01-01T00:00:00+00:00", workspace="exploit", + provenance={"house_words": "validated, predictive"}) + s = client.post("/studies", json=forged).json() + assert s["id"] != "s_ffffffffffff" and api_studies.STUDY_ID_PATTERN.fullmatch(s["id"]) + assert s["workspace"] == "replay" + assert s["provenance"]["house_words"] == "compared with MAST data, never predictive" + assert s["created"].startswith("202") # server clock, not the forged 1999 + + +def test_provenance_is_immutable_after_create(studies_dir): + s = client.post("/studies", json=_body()).json() + p = client.patch(f"/studies/{s['id']}", json={"name": "Renamed"}).json() + assert p["provenance"] == s["provenance"] # where a study came from never changes + + +# ---------------------------------------------------------------- the caps and the route rule +def test_oversized_note_is_rejected_with_nothing_written(studies_dir): + r = client.post("/studies", json=_body(notes_md="x" * 20_001)) + assert r.status_code == 422 + assert list(studies_dir.glob("*")) == [] # rejected at the schema — no file, no directory + + +def test_note_cap_leaves_the_stored_study_untouched(studies_dir): + """A rejected update must not half-apply: the stored note is exactly what it was.""" + s = client.post("/studies", json=_body()).json() + r = client.patch(f"/studies/{s['id']}", json={"notes_md": "y" * 20_001}) + assert r.status_code == 422 + assert client.get(f"/studies/{s['id']}").json()["notes_md"] == _body()["notes_md"] + + +@pytest.mark.parametrize("route", ["javascript:alert(1)", "http://example.com", "#/study/s_012345678901", "#/replay//../../etc", ""]) +def test_route_must_be_a_house_hash_view(studies_dir, route): + """A study points at a view the router owns — never at another study link, never at a URL the + server should fetch. All of those are clean 422s.""" + assert client.post("/studies", json=_body(route=route)).status_code == 422 + assert list(studies_dir.glob("*")) == [] + + +def test_nonfinite_parameter_is_rejected(studies_dir): + """json cannot carry inf — but Python's json.loads parses an `Infinity` literal, so a hand-built + request can carry one. Finite values only, rejected at the route door (a plain httpx client + refuses to even send inf, so the body is hand-rolled).""" + raw = json.dumps(_body(what_if={"P_nbi": 0.0})).replace('"P_nbi": 0.0', '"P_nbi": Infinity') + r = client.post("/studies", content=raw, headers={"Content-Type": "application/json"}) + assert r.status_code == 422 + assert list(studies_dir.glob("*")) == [] + + +# ---------------------------------------------------------------- atomicity and listing +def test_interrupted_write_leaves_no_half_file(tmp_path, monkeypatch): + """The store's one write is atomicio's: an interrupted save can never publish half a document — + the target never appears and the store still reads clean.""" + monkeypatch.setattr(api_studies, "STUDIES_DIR", tmp_path) + target = tmp_path / "s_a000000000.json" + + def interrupted(tmp): + tmp.write_text('{"id": "s_a000000000", "name": "half', encoding="utf-8") + raise OSError("interrupted — as if the process died mid-write") + + with pytest.raises(OSError): + atomic_write(target, interrupted) + assert not target.exists() # no half-study, ever + assert list(tmp_path.glob("*.json")) == [] # the temp is dot-prefixed .tmp, invisible to the glob + assert api_studies.list_studies() == [] # the store still reads clean + + +def test_list_is_newest_first_and_stable(studies_dir, monkeypatch): + stamps = iter(["2026-10-12T10:00:00+00:00", "2026-10-12T10:00:00+00:00", + "2026-10-12T11:00:00+00:00", "2026-10-12T11:00:00+00:00", + "2026-10-12T12:00:00+00:00", "2026-10-12T12:00:00+00:00"]) + monkeypatch.setattr(api_studies, "_now", lambda: next(stamps)) + a = client.post("/studies", json=_body(name="A")).json() + b = client.post("/studies", json=_body(name="B")).json() + c = client.post("/studies", json=_body(name="C")).json() + ids = [s["id"] for s in client.get("/studies").json()["studies"]] + assert ids == [c["id"], b["id"], a["id"]] # newest-updated first + assert [s["id"] for s in client.get("/studies").json()["studies"]] == ids # stable across calls + + monkeypatch.setattr(api_studies, "_now", lambda: "2026-10-12T13:00:00+00:00") + client.patch(f"/studies/{a['id']}", json={"notes_md": "re-read"}) + ids_after = [s["id"] for s in client.get("/studies").json()["studies"]] + assert ids_after == [a["id"], c["id"], b["id"]] # the edited study rises, the rest keep order + + +def test_list_skips_unreadable_files(studies_dir): + """A hand-edited study file must not hide every other study — it is logged and skipped; reading + THAT id directly still fails loudly (ValueError, the route maps it to a 500).""" + client.post("/studies", json=_body(name="good")) + (studies_dir / "s_b000000000.json").write_text("{not json", encoding="utf-8") + listed = client.get("/studies").json()["studies"] + assert [s["name"] for s in listed] == ["good"] + with pytest.raises(ValueError): + api_studies.load_study("s_b000000000") + + +def test_degraded_studies_are_stored_as_they_are(studies_dir): + """A compare study and a bare-explore study store the same shape — the store never reinterprets + the view, it keeps what the capture sent.""" + cmp_s = client.post("/studies", json=_body(name="cmp", route="#/compare/30166,30420?ref=30166", + shot_ids=[30166, 30420], ref_shot=30166)).json() + assert cmp_s["workspace"] == "compare" + ex_s = client.post("/studies", json=_body(name="ex", route="#/explore", shot_ids=[], + ref_shot=None, what_if=None, + explore={"device": "mast", "Ip": 0.8})).json() + assert ex_s["workspace"] == "explore" + assert client.get(f"/studies/{ex_s['id']}").json()["explore"] == {"device": "mast", "Ip": 0.8} diff --git a/tests/test_web_checks.py b/tests/test_web_checks.py index 4b00ceb..579ce46 100644 --- a/tests/test_web_checks.py +++ b/tests/test_web_checks.py @@ -217,3 +217,48 @@ def test_abort_overlay_is_conditional_on_the_catalog_flag(): assert "a distance, never a forecast" in replay # the house wording, on the panel itself ins_fn = replay.split("function insight(s)")[1].split("const ohmic")[0] assert "flags this shot aborted" in ins_fn and "catalog bookkeeping" in ins_fn + + +def test_study_restore_fetch_has_the_error_discipline(): + """#44: the study mount fetches the stored study with the house discipline — the id is + encodeURIComponent'd, a non-OK reply throws (the failure names itself in the study landing, + never silent), the stored route is re-checked against the app's own views before it is + navigated to, and the replay half of the restore rides the shared load machine so the chip + stays truthful. The house words ride the restore status.""" + ws = _served("workspaces.js") + mount = ws.split("study(param) {")[1].split("function rememberStudy")[0] + assert "fetch('/studies/' + encodeURIComponent(param))" in mount + assert "if (!r.ok) throw new Error" in mount + assert "Couldn't open the study" in mount + assert "/^#\\/(replay|compare|explore)\\b/.test(s.route)" in mount # the route is re-checked, not trusted + assert "(replay|compare|explore|study)" in ws # the grammar grew a fourth entry + apply_fn = ws.split("function applyStudy")[1].split("function restoreExplore")[0] + assert "FusionLabShotLoad" in apply_fn and "L.onChange(check)" in apply_fn + assert "the rest have no slider on this machine" in ws # a partial what-if apply is said out loud + assert "compared with MAST data, never predictive" in ws + + +def test_study_save_flow_has_the_error_discipline(): + """#44: the palette's save posts the capture with the fetch discipline — a non-OK reply is + unwrapped to the server's own detail, a failure says nothing was written, and the studies list + degrades to recent pointers when the server list is unavailable (named, never silent).""" + pal = _served("palette.js") + save = pal.split("saveForm.addEventListener('submit'")[1] + assert "fetch('/studies', { method: 'POST'" in save + assert "if (!r.ok) return r.json().catch(() => null).then" in save + assert "Couldn't save" in save and "nothing was written" in save + assert "store.del(NOTE_KEY)" in save # the draft is consumed once the server holds the note + refresh = pal.split("function refreshDynamic")[1].split("let opener = null")[0] + assert "recent studies still work" in refresh + + +def test_study_note_draft_is_consumed_on_restore(): + """#44: the drawer's note field drafts to localStorage but a restore overwrites it from the + server's copy (setNote) and consumes the draft — pointers and drafts only, the server store is + the source of truth.""" + replay = _served("replay.js") + assert 'id="rp-wi-note"' in replay # the field lives in the drawer + assert "localStorage.getItem('fusionlab-study-note')" in replay # the draft loads into an empty field + assert "localStorage.setItem('fusionlab-study-note'" in replay # keyed on the input event + assert "localStorage.removeItem('fusionlab-study-note')" in replay # restore consumes the draft + diff --git a/web/app.js b/web/app.js index 83d09ce..d336ed0 100644 --- a/web/app.js +++ b/web/app.js @@ -162,6 +162,9 @@ device(k, reset) { if (!(k in devices) || ($('device').value === k && !reset)) return; $('device').value = k; loadDevice(); }, set(k, v) { if (!SLIDERS.includes(k)) return; $(k).value = v; $(k).dispatchEvent(new Event('input')); }, get: (k) => (k === 'device' ? $('device').value : val(k)), + // The whole control surface, for a study's explore snapshot (workspaces.js saves it, then + // re-applies it through device()/set()): plain numbers, the same ones the readouts show. + values: () => ({ device: $('device').value, ...Object.fromEntries(SLIDERS.map((k) => [k, val(k)])) }), // lock(['Ip']) leaves one knob live; lock(null) frees them all lock(keys) { LOCKABLE.forEach((k) => { $(k).disabled = !!keys && !keys.includes(k); }); }, ready: () => ready, // resolves once /devices has filled the controls diff --git a/web/index.html b/web/index.html index ddabebc..81ce696 100644 --- a/web/index.html +++ b/web/index.html @@ -40,6 +40,18 @@
Open a study from the command palette, or follow a #/study/… link — a study reopens the view it was saved from, with its note and what-if values.
+A study is a comparison record: compared with MAST data, never predictive.
+${esc(c)}
`).join(''); + return gate; + })(); + wi.gateP.catch(() => { wi.gateP = null; }); // a rejected open (fetches are caught above; guard anyway) clears the memo + } + const opened = await wi.gateP; + if (!opened) { $('rp-wi-out').innerHTML = 'The anchored editor needs the FusionLab server (/virtual/gate).
'; $('rp-wi-retry').addEventListener('click', () => whatIf(true)); $('rp-whatif').classList.remove('on'); @@ -899,18 +927,6 @@ showEditDrawer(); // open the drawer just enough to carry the one-line error and its Retry return; } - wi.gate = gate; - // a slider only where the archive can test the edit; the rest are named with the reason, not drawn - const G = wi.gate.sliders, open = WI.filter(([k]) => G[k].enabled), shut = WI.filter(([k]) => !G[k].enabled); - $('rp-wi-sliders').innerHTML = WI.map(([k, label, min, max, step, zero]) => ``).join(''); - $('rp-wi-evidence').innerHTML = (wi.gate.validated ? 'Evidence, from matched pairs of real shots (same session, one input changed by > 20%, the rest within 5%): ' + - open.filter(([k]) => k !== 'nbi_shift_s').map(([k, label]) => `${label.toLowerCase()} ${G[k].n_pairs.toLocaleString()} pairs (${esc(G[k].admissible.join(', '))})`).join('; ') + '. ' - : 'Not validated on this machine yet: run scripts/validate_virtual.py. ') + - (shut.length ? `No slider for ${shut.map(([k, label]) => `${label.toLowerCase()} (${G[k].n_pairs} pairs)`).join(' or ')}: too few pairs in 6,353 shots to test any law.` : ''); - $('rp-wi-sliders').addEventListener('input', () => { clearTimeout(wi.timer); wi.timer = setTimeout(askWhatIf, 120); labelsWhatIf(); }); - $('rp-wi-notes').innerHTML = wi.gate.caveats.map(c => `${esc(c)}
`).join(''); } wi.on = true; $('rp-whatif').classList.add('on'); @@ -926,6 +942,8 @@ // drawer's DOM and the memoized gate survive; "Reset edits" restores the measured programme. function showEditDrawer() { if ($('rp-wi').open) return; + const note = $('rp-wi-note'); + if (note && !note.value) { try { note.value = localStorage.getItem('fusionlab-study-note') || ''; } catch (e) { /* no storage: the note just doesn't draft */ } } wi.opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; // focus returns here on close $('rp-wi').showModal(); } @@ -1679,6 +1697,37 @@ view(v) { const b = $('rp-view').querySelector(`button[data-v="${v}"]`); if (b && !$('rp-view').hidden) b.click(); }, state: () => (shot ? sliceState(+$('rp-t').value) : null), whatIf: on => whatIf(on), + // Study seam (backlog #44): snapshot or apply the anchored editor's scale factors, keyed like + // the drawer's sliders. Applying opens the editor and follows the ?whatif= deep-link discipline + // — only sliders the gate enabled, set like a hand on them, then the debounced re-run. A + // snapshot exists only while the editor is open (the drawn strips are what a study restores). + whatIfValues(values) { + // read live: the sliders exist only once the editor has opened and the gate has rendered them — + // a snapshot reads them now; an apply reads them after whatIf(true) resolves (a fresh restore has + // no sliders yet, and a list captured earlier would silently set nothing) + const live = () => WI.map(([k]) => [k, $('rp-wi-' + k)]).filter((pair) => pair[1] && !pair[1].disabled); + if (!values) { + if (!wi.on) return null; + const out = {}; + live().forEach(([k, el]) => { out[k] = +el.value; }); + return out; + } + return whatIf(true).then(() => { // opens the editor; the sliders exist once it resolves + if (!wi.on) return Promise.reject(new Error('the anchored editor could not open — no shot is drawn, or the what-if gate is unavailable')); + let applied = 0; + live().forEach(([k, el]) => { if (Object.prototype.hasOwnProperty.call(values, k)) { el.value = values[k]; applied++; } }); + if (applied) { labelsWhatIf(); askWhatIf(); } // one re-run of the debounced edit, not one per slider + return applied; + }); + }, + // The drawer's study-note field, set on restore (the server copy is the truth, so the local + // draft is consumed); the palette reads the field when saving a study. + setNote(text) { + const el = $('rp-wi-note'); + if (!el) return; + el.value = String(text == null ? '' : text); + try { localStorage.removeItem('fusionlab-study-note'); } catch (e) { /* no storage */ } + }, compare(ids) { // public hook for the router's #/compare/{ids} deep links (workspaces.js) return init().then(() => startCompare(ids)); }, diff --git a/web/style.css b/web/style.css index bb29129..2fb15e2 100644 --- a/web/style.css +++ b/web/style.css @@ -321,6 +321,18 @@ dialog.palette::backdrop { background:rgba(8,10,13,.72); backdrop-filter:blur(2p .pal-status { margin:6px 2px 0; min-height:1em; color:var(--accent); font:12px/1.5 var(--mono); } @media (max-width:760px) { dialog.palette { margin-top:6vh; } #pal-list { max-height:60vh; } } +/* save-study dialog (web/palette.js) and the anchored editor's study note (web/replay.js): the + palette's dialog idiom (class="palette save-study") with #pal-q's input look. */ +dialog.save-study { padding:14px 16px; } +dialog.save-study form { display:grid; gap:10px; width:100%; } +dialog.save-study h2 { margin:0; } +dialog.save-study input[type="text"], #rp-wi-note { width:100%; background:var(--bg); color:var(--fg); + border:1px solid var(--line); padding:8px 10px; font:13px/1.4 var(--mono); } +#rp-wi-note { resize:vertical; min-height:70px; } +#study-save-status { min-height:1em; margin:0; } +.save-actions { display:flex; justify-content:flex-end; gap:8px; } +.save-actions .tool { padding:6px 14px; } + /* Reduced motion: the gauge bars' width tween and the guided-study spotlight dim are orientation aids only — under the user's preference they apply instantly. The two JS smooth scrolls (guide.js spotlight, the catalog panel jump) gate on the same query at their call sites. */ diff --git a/web/workspaces.js b/web/workspaces.js index 8dfd37f..40eed14 100644 --- a/web/workspaces.js +++ b/web/workspaces.js @@ -1,5 +1,7 @@ // FusionLab workspace router: the single writer of which workspace is visible, and the owner of the -// URL grammar — #/replay/{shot}?t=SECONDS[&edit=1] · #/compare/{ids}?ref=SHOT · #/explore. One route() reads +// URL grammar — #/replay/{shot}?t=SECONDS[&edit=1] · #/compare/{ids}?ref=SHOT · #/explore · +// #/study/{id} (a saved view: the mount fetches it, routes into the view it captured, and re-applies +// its note and parameters). One route() reads // the hash; apply() is the only place that toggles sections, tab state or fires 'fusionlab:tab'. // Everything else (header clicks, guided-study steps, the agent) navigates through showTab()/ // navigate() and lands here. Landing workspace: last visited, else the welcome-modal pick, else @@ -10,11 +12,12 @@ (() => { 'use strict'; - const NAMES = ['replay', 'compare', 'explore']; + const NAMES = ['replay', 'compare', 'explore', 'study']; const LEGACY = { db: 'compare', sandbox: 'explore' }; // pre-redesign names (lessons.json, old links) const LAST_KEY = 'fusionlab-workspace'; // written on every applied route const PICK_KEY = 'fusionlab-landing'; // written when a welcome-modal choice is made - const ROUTE_RE = /^#\/(replay|compare|explore)(?:\/([^?]*))?(\?.*)?$/; + const RECENTS_KEY = 'fusionlab-recent-studies'; // pointers only (id + name); the server store is the truth + const ROUTE_RE = /^#\/(replay|compare|explore|study)(?:\/([^?]*))?(\?.*)?$/; let current = null, applied = null, defaultLanding = false; // applied = the exact route string the DOM was last built for; // defaultLanding = the current view is the bare landing default, @@ -35,7 +38,9 @@ const q = new URLSearchParams(location.search); if (q.get('shot')) return 'replay'; // legacy deep links: a shot replays on the Replay room, if (q.get('compare')) return 'compare'; // a compared set reads on the Compare workspace - return normalize(store.get(LAST_KEY)) || normalize(store.get(PICK_KEY)) || 'replay'; + const last = normalize(store.get(LAST_KEY)); + if (last && last !== 'study') return last; // a study is never the landing: it needs its id from the link + return normalize(store.get(PICK_KEY)) || 'replay'; } // Mounts run from route() — after DOMContentLoaded, so window.FusionLab exists (replay.js exposes @@ -63,8 +68,114 @@ if (ids.length >= 1) F.replay.compare(ids); // a one-id link preloads the reference; the bar prompts for more }, explore() {}, // static section; app.js owns its controls + // A study restores a saved view (backlog #44): fetch the stored study, route into the view it + // captured (the router re-runs on the new hash), then re-apply the note and parameters through + // public hooks. A failed fetch names itself in the study landing — never silent, never a blank page. + study(param) { + const F = window.FusionLab; + if (!F || !F.replay) return; + const title = document.getElementById('study-title'); + const status = (t) => { const el = document.getElementById('study-status'); if (el) el.textContent = t; }; + status('Loading study…'); + fetch('/studies/' + encodeURIComponent(param)).then((r) => { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }).then((s) => { + rememberStudy(s); + if (title && s.name) title.textContent = s.name + ' — study'; + const route = typeof s.route === 'string' && /^#\/(replay|compare|explore)\b/.test(s.route) ? s.route : '#/replay'; + if (location.hash !== route) location.hash = route; // the router applies the stored view + applyStudy(s, status); + }).catch((e) => { + status("Couldn't open the study (" + (e && e.message ? e.message : e) + ") — the link may be broken or the server unreachable. The palette lists the studies that exist."); + }); + }, }; + // Recent-study pointers (id + name, capped): enough for the palette's Studies source when the + // server list is unavailable. Pointers, never state — the server store is the source of truth. + function rememberStudy(s) { + if (!s || !s.id) return; + let list = []; + try { const raw = store.get(RECENTS_KEY); list = raw ? JSON.parse(raw) : []; } catch (e) { list = []; } + if (!Array.isArray(list)) list = []; + list = list.filter((r) => r && r.id !== s.id); + list.unshift({ id: String(s.id), name: String(s.name || '') }); + store.set(RECENTS_KEY, JSON.stringify(list.slice(0, 5))); + } + + // The parameter half of a study restore, after the stored route applied: what-if values and the + // note on the Replay workspace's anchored editor, explore values through the sandbox's public + // hooks. The replay shot load rides FusionLabShotLoad (the shared machine) so the load chip stays + // truthful while the restore waits; a failed load leaves the parameters unapplied, the shot's own + // error already rendered in the replay cell. + function applyStudy(s, status) { + const F = window.FusionLab; + const route = String(s.route || ''); + const isReplay = /^#\/replay\//.test(route); + const finish = () => { + if (isReplay) { + // what-if edits apply through the drawer's own setter (opens it, sets what the gate enabled); + // it resolves to how many keys landed. A partial or failed apply is said out loud — the study + // must not look identical to the author's view when it is not. + if (typeof s.notes_md === 'string' && s.notes_md && F.replay.setNote) F.replay.setNote(s.notes_md); + const total = s.what_if ? Object.keys(s.what_if).length : 0; + const say = (applied) => { + let missed = ''; + if (applied < 0) missed = ' — its what-if edits could not be applied (the view is partial)'; + else if (applied < total) missed = ' — what-if edits applied: ' + applied + ' of ' + total + '; the rest have no slider on this machine'; + status('Study "' + (s.name || '') + '" opened' + missed + ' — compared with MAST data, never predictive.'); + }; + const p = (s.what_if && F.replay.whatIfValues) ? F.replay.whatIfValues(s.what_if) : Promise.resolve(total ? -1 : 0); + p.then((applied) => say(typeof applied === 'number' ? applied : -1)).catch(() => say(-1)); + } else if (/^#\/explore/.test(route) && s.explore) { + restoreExplore(s.explore); + status('Study "' + (s.name || '') + '" opened — compared with MAST data, never predictive.'); + } else status('Study "' + (s.name || '') + '" opened — compared with MAST data, never predictive.'); + }; + const L = window.FusionLabShotLoad; + const shotId = Array.isArray(s.shot_ids) && s.shot_ids.length ? Number(s.shot_ids[0]) : null; + if (isReplay && shotId && L && L.onChange) { + // Finish on the DRAW (fusionlab:shot), not on fetch-ready: the anchored editor can only open + // once the shot is on screen (the replay module's `shot` is set), and the draw event is that + // moment's public signal. Fetch-ready alone fires a beat earlier and a what-if apply in the + // gap would silently no-op (the drawer's whatIf(true) sees no shot and takes its close path). + let settled = false, off = null; + const settle = (fn) => { + if (!settled) { + settled = true; clearTimeout(giveUp); if (off) off(); + document.removeEventListener('fusionlab:shot', onDraw); + fn(); + } + }; + const onDraw = (e) => { if (e && e.detail && Number(e.detail.shot_id) === shotId) settle(finish); }; + const check = () => { + const st = L.get(shotId); + if (st === 'error') settle(() => status('The study opened, but shot #' + shotId + " didn't load — the view is partial.")); + else if (st === 'ready' && F.replay && typeof F.replay.state === 'function' && F.replay.state() != null) settle(finish); // already drawn + }; + const giveUp = setTimeout(() => settle(() => status('The study opened, but the shot never finished loading — the view is partial.')), 20000); + off = L.onChange(check); + document.addEventListener('fusionlab:shot', onDraw); + check(); // already up: the draw event will not fire again + } else finish(); + } + + // Explore values re-apply through the sandbox's public hooks once its controls exist (ready() + // resolves when /devices has filled them). + function restoreExplore(values) { + const S = window.FusionLab && window.FusionLab.sandbox; + if (!S || !values || typeof values !== 'object') return; + const apply = () => { + if (values.device && S.device) S.device(String(values.device)); + Object.keys(values).forEach((k) => { + if (k !== 'device' && typeof values[k] === 'number' && S.set) S.set(k, values[k]); + }); + }; + if (S.ready && S.ready().then) S.ready().then(apply); + else apply(); + } + function apply(ws, param, search, routeStr) { if (routeStr !== null && routeStr === applied) return; // the same route twice: the DOM already matches applied = routeStr;