Repository navigation
feat(studies): session workspace — named studies, notes, share links - #72
Merged
Merged
Conversation
…re pattern
A study is 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 (backlog #44). One JSON document per id under
data/studies/, written only through atomicio.atomic_write; ids are minted
server-side (s_[0-9a-f]{12}, the protocol.py discipline) so a tampered request
can never name a path — a malformed id is a 422 before it can become one, and a
well-formed unknown id is a clean 404, checked before any write. Schema bounds
cap the note (422, nothing written); the stored route must be one of the app's
own hash views, so a study never points at another study link. Provenance is
server-stamped: house_words "compared with MAST data, never predictive" — a
study is a comparison record. List is newest-updated first, ties by id.
Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
The client half of backlog #44. The router gains a fourth grammar entry #/study/{id}: the mount fetches the study, routes into the view it captured (the stored hash is the router's own), then re-applies the note and parameters through public hooks — what-if values and the note on the replay room's anchored editor (which gains a study-note field), explore values through the sandbox. The replay restore rides FusionLabShotLoad so the load chip stays truthful; a failed fetch or load names itself in the study landing, never silent. The command palette gains "Save this view as a study…" (capture the canonical route + drawer note, store it, copy its link) and a Studies source that falls back to recent pointers when the server list is unavailable. localStorage keeps pointers (fusionlab-recent-studies) and the unsaved-note draft (fusionlab-study-note) only — the server store is the source of truth. Existing share behavior (rp-share, compare share, deep links) unchanged. Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
tests/test_studies.py (23 cases): CRUD round trip through the HTTP surface (stored doc == served doc == on-disk doc), PATCH semantics (omitted fields survive, explicit null clears), tamper-resistance (five malformed ids are clean 422s before any path is named; unknown ids are 404-before-write with the store still empty), server-built ids/stamps/provenance (a forged body cannot mint an id, forge a 1999 stamp, or replace the house words; provenance is immutable after create), the caps (a 20,001-char note is a 422 that writes nothing and a rejected update leaves the stored note untouched), strict stored-route validation (javascript:/http:/#/study/traversal-shaped routes are all 422), atomic writes (an interrupted save publishes no half-file and the store still reads clean), listing (newest-updated first, stable across calls, the edited study rises, unreadable files are skipped but not hidden), and degraded studies stored as captured (compare/explore shapes). tests/test_web_checks.py (+3, house style): the study mount's fetch has the error discipline (encodeURIComponent, non-OK throws into the study landing, the stored route is re-checked against the app's own views before it is navigated to, restore rides FusionLabShotLoad with the house words in the status); the palette save posts with the discipline (non-OK unwraps the server's detail, failure says nothing was written, the draft is consumed, the list degrades to recents named); the drawer note drafts to localStorage but restore overwrites from the server copy and consumes the draft. api_studies.py hardening found by these tests: the stored-route 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; and Infinity/NaN bodies are rejected at the route door with a clean 422 (json.loads accepts the literals, the store's dump refuses them, and a validation-error reply cannot echo an unserializable value without crashing). Full suite: 502 passed, 1 skipped (476 baseline + 26 new); ruff, mypy clean. Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
…-if apply Fresh-profile restore applied what-if edits in the gap between the shot fetch reporting ready and the replay module drawing the shot: the drawer's whatIf(true) saw no shot and took its close path, so the apply silently no-oped and the study looked like the author's view while showing defaults. - applyStudy now settles on the fusionlab:shot draw event for the study's shot (the public signal that the editor can open), with the fetch-ready + already-drawn check as the in-session fallback; error and give-up paths still name the failure. - whatIfValues' apply branch rejects when the editor could not open instead of resolving "applied 0", and reads the gate's live sliders only after the editor opens; applyStudy says a partial apply out loud (N of M, or "could not be applied"). - Tripwire pins the partial-apply wording. Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
…d values The route's own ?edit=1 open and a study restore's what-if apply both call whatIf(true) while the gate memo is unset, so each fetched /virtual/gate and rendered the sliders independently; the later render wiped the values an earlier caller had just applied, and the study restore could land on a reset editor (last-renderer wins — a race). The first open is now the single writer: the memo is the in-flight promise, every racing caller rides the same fetch + render, and a failed open stays unmemoized so Retry (or a later call) refetches. Co-authored-by: Tapan Ganatma Nakkina <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A physicist who has just found something — a slice where the pedestal holds while the core flattens, say — has no way to keep it. The view lives in the URL hash and the note lives nowhere; pasting a link to a colleague shares the shot, not the finding. Issue #44 asked for a session workspace: name what you're looking at, note what you saw, and hand someone a link that shows them the same thing. The backlog spec's locked decision: a hybrid — hash-encoding stays the carrier for view shares (the house idiom, unchanged), while a small server-side file store holds named studies + notes, because notes are arbitrary text and enumeration needs a list. localStorage holds pointers and drafts only — the server is the source of truth, so a study link is not a lie the way a localStorage-only "share" would be.
What
Server —
fusionlab/api_studies.py(mounted inapi.pybeside the existing three routers): list / get / create / update overdata/studies/{id}.json, on the proven protocol-store pattern — ids are server-minteds_[0-9a-f]{12}(a tampered request cannot name a path), 404-before-write, every write throughatomicio.atomic_write(an interrupted write never leaves a half-file), a note size cap rejected with 422, list ordered recent-first and stable. Study routes are validated against the app-owned grammar (#/replay/{id},#/compare/{ids},#/explore) — traversal-shaped values are 422, and non-standard JSONInfinity/NaNare rejected cleanly at the boundary. The stored object carries the study'swhat_ifvalues, note, shot ids, and a server-stampedprovenanceblock whose house words are "compared with MAST data, never predictive" — a study is a comparison record, kept honest in stored and rendered copy.Client — a fourth grammar entry
#/study/{id}inweb/workspaces.js(MOUNT.study): fetch the study, route into the view it captured (the stored route is re-checked, never trusted), then re-apply the note and parameters through the publicwindow.FusionLabhooks. The restore ridesFusionLabShotLoad— the shared shot-load state machine — so the load chip stays truthful while the restore waits. The palette gains "Save this view as a study…" (captures the canonical routeshareUrl()already builds, the drawer's what-if values, and the note text) and a Studies source (list / open, recent-study pointers as an offline fallback). The note anchors to the what-if drawer (rp-wi), which already keeps state across close/reopen. Existing share behavior (rp-share,shareCompare, every deep link) is untouched — view-only shares keep working exactly as before.Two restore-correctness fixes the dogfooding forced out (both in
web/replay.js, both general):whatIf(true)saw no shot and silently no-oped. The restore now settles on thefusionlab:shotdraw event (the public signal that the editor can open), andwhatIfValuesrejects when the editor cannot open instead of resolving "applied 0"; a partial apply is said out loud ("what-if edits applied: 2 of 3…").?edit=1open and the study's apply raced the unset gate memo, each fetching/virtual/gateand rendering the sliders — the later render wiped the earlier apply's values. The first open is now the single writer: the in-flight open is memoized, every racing caller rides one fetch + render; a failed open stays unmemoized so Retry refetches.How to Review
fusionlab/api_studies.py(the whole store is ~200 lines) and its teststests/test_studies.py— CRUD round-trip, patch semantics, id/route validation, forged metadata, note cap, atomicity, degraded study shapes.web/workspaces.js(thestudymount +applyStudy+ recent-study pointers),web/palette.js(save dialog + Studies source),web/replay.js(the note field in the drawer,whatIfValues, the shared gate open).tests/test_web_checks.py(20 checks total; the 17 pre-existing ones pass untouched).Test Evidence
make teston the tested head919b5f8857d18cba7900e9ade8aaff0ecd2b9f52: 552 passed, 1 skipped;uv run ruff checkclean;uv run mypy fusionlabclean.#/study/{id}copied).FusionLabShotLoad, Beam power ×1.20 re-applied with the drawer's re-run results, note rendered — server-backed truth, not the author's localStorage.Fixes #44
Human author: Tapan Ganatma Nakkina ([email protected])
🔗 Obvious Project · 🧵 Obvious Thread · 🔀 Obvious PR Artifact