Skip to content

Commit 04ea3ff

Browse files
mnm-matinclaude
andcommitted
Name the HyperView home directory and let a re-authored Samples collection win
Two footguns from the Precision Regions rebuild. The workspace registry was the parent of the datasets directory, so runs with sibling datasets dirs shared workspaces.json and stale panel state leaked into an export. HYPERVIEW_HOME now names the directory that holds datasets, media, and the registries; HYPERVIEW_DATASETS_DIR alone behaves as before. Re-applying a view over a persisted workspace kept the previous run's panel state even when the view authored a different collection_id for a Samples panel. A changed authored collection now reopens the panel on it, the same way authored initial state already wins; an unchanged view still leaves a visitor's navigation alone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7Xrrx3fhVzCCSZSBGjEDa
1 parent 2309f52 commit 04ea3ff

8 files changed

Lines changed: 171 additions & 23 deletions

File tree

‎docs/architecture-plan-2026-09.md‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,13 @@ collection when the workspace stores it and falls back to every sample.
247247

248248
Two operational facts that came out of the same debugging:
249249

250-
- The workspace registry lives in the parent of `HYPERVIEW_DATASETS_DIR`
250+
- The workspace registry lived in the parent of `HYPERVIEW_DATASETS_DIR`
251251
(`<parent>/workspaces.json`), so two datasets dirs with the same parent
252-
share workspaces. A reproducible export needs its own parent directory, not
253-
just its own datasets dir.
254-
- Re-applying a view over a persisted workspace keeps the previous run's panel
255-
state when the panel id already exists, even if the authored `collectionId`
256-
prop changed. Authored *initial state* wins over a previous run; authored
257-
*props* do not. Whether the Samples collection prop should be treated as
258-
initial state is open.
252+
shared workspaces. Fixed by naming the home directory: `HYPERVIEW_HOME`
253+
holds datasets, media, and the registries; `HYPERVIEW_DATASETS_DIR` alone
254+
keeps the old parent rule. Build-then-export runs set `HYPERVIEW_HOME`.
255+
- Re-applying a view over a persisted workspace kept the previous run's panel
256+
state even when the authored `collectionId` prop changed. Now a changed
257+
authored collection reopens the Samples panel on it, the same way authored
258+
initial state wins over a previous run; an unchanged view still leaves a
259+
visitor's navigation alone.

‎docs/datasets.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ dataset = hv.Dataset("my_dataset")
1212
dataset = hv.Dataset("my_dataset", persist=False)
1313
```
1414

15-
**Storage location:** `~/.hyperview/datasets/` (configurable via `HYPERVIEW_DATABASE_DIR`)
15+
**Storage location:** `~/.hyperview/datasets/`. `HYPERVIEW_HOME` moves the whole `~/.hyperview` tree (datasets, media, and the workspace registry together); `HYPERVIEW_DATASETS_DIR` moves only the datasets.
1616

1717
Internally, each dataset is stored as two Lance tables (directories) inside that folder:
1818
- `hyperview_{dataset_name}.lance/` (samples)

‎docs/static-spaces.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,15 @@ the image and passes the flag, so the container keeps one copy of the media
192192
instead of two. Do not use it on a workstation, where the bundle can be moved,
193193
deleted, or exported over.
194194

195+
HyperView keeps its state under one home directory: `~/.hyperview`, or
196+
`HYPERVIEW_HOME` when set. Datasets, media, and the workspace registry
197+
(`workspaces.json`) all live there. `HYPERVIEW_DATASETS_DIR` moves the datasets
198+
alone; without `HYPERVIEW_HOME` the registry then sits in that directory's
199+
parent, so two datasets directories with the same parent share one registry.
200+
A reproducible build-then-export run should set `HYPERVIEW_HOME` to a fresh
201+
directory, so nothing a previous run persisted (panel state included) leaks
202+
into the bundle.
203+
195204
Restore is idempotent. A container that restarts against the same datasets
196205
directory reuses the dataset it finds rather than re-ingesting, and
197206
re-registering vectors and coordinates is an upsert.

‎src/hyperview/runtime.py‎

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import importlib
88
import inspect
99
import json
10+
import os
1011
import queue
1112
import threading
1213
import time
@@ -33,7 +34,7 @@
3334
merge_default_props,
3435
validate_json_contract,
3536
)
36-
from hyperview.storage.config import StorageConfig
37+
from hyperview.storage.config import StorageConfig, get_default_home_dir
3738
from hyperview.storage.schema import (
3839
index_id_for_space_key,
3940
parse_layout_dimension,
@@ -156,8 +157,19 @@ def _json_merge_patch(current: dict[str, Any], patch: dict[str, Any]) -> dict[st
156157

157158

158159
def get_runtime_config_dir() -> Path:
159-
datasets_dir = StorageConfig.default().datasets_dir
160-
config_dir = datasets_dir.parent
160+
"""Where the workspace, provider, and job registries live.
161+
162+
``HYPERVIEW_HOME`` when set. Otherwise the parent of the datasets
163+
directory, which is ``~/.hyperview`` by default -- and, when only
164+
``HYPERVIEW_DATASETS_DIR`` is set, whatever directory contains it. Two
165+
datasets directories with the same parent therefore share one registry;
166+
set ``HYPERVIEW_HOME`` to keep a run fully separate.
167+
"""
168+
169+
if os.environ.get("HYPERVIEW_HOME"):
170+
config_dir = get_default_home_dir()
171+
else:
172+
config_dir = StorageConfig.default().datasets_dir.parent
161173
config_dir.mkdir(parents=True, exist_ok=True)
162174
return config_dir
163175

@@ -752,6 +764,15 @@ def to_dict(self) -> dict[str, Any]:
752764
}
753765

754766

767+
def _authored_collection_id(panel: CustomPanelSpec) -> str | None:
768+
"""The collection a view authored for a Samples panel, if any."""
769+
770+
authored = panel.props.get("collectionId", panel.props.get("collection_id"))
771+
if isinstance(authored, str) and authored.strip():
772+
return authored.strip()
773+
return None
774+
775+
755776
def _samples_collection_state(collection: CollectionState) -> dict[str, Any]:
756777
return {
757778
"collection_id": collection.id,
@@ -1619,13 +1640,43 @@ def _opening_samples_collection_locked(
16191640
know -- opens on every sample, as before.
16201641
"""
16211642

1622-
authored = panel.props.get("collectionId", panel.props.get("collection_id"))
1623-
if isinstance(authored, str) and authored.strip():
1624-
stored = workspace.collections.get(authored.strip())
1643+
authored = _authored_collection_id(panel)
1644+
if authored is not None:
1645+
stored = workspace.collections.get(authored)
16251646
if stored is not None:
16261647
return stored
16271648
return self._build_all_collection_locked(workspace)
16281649

1650+
def _reopen_samples_collection_locked(
1651+
self,
1652+
workspace: WorkspaceState,
1653+
panel: CustomPanelSpec,
1654+
) -> None:
1655+
"""Move a Samples panel the workspace already knows onto its newly authored collection.
1656+
1657+
A previous run's state normally survives re-applying a view, so a
1658+
visitor's navigation is not undone by a restart. Changing the authored
1659+
``collection_id`` is the author speaking again, and it wins the same
1660+
way authored initial state does.
1661+
"""
1662+
1663+
definition = self._definition_for_panel_spec_locked(panel)
1664+
if definition is None or definition.panel_type != "samples" or not workspace.dataset_name:
1665+
return
1666+
existing = workspace.ui.panels.get(panel.id)
1667+
if existing is None:
1668+
return
1669+
state = dict(existing.state)
1670+
state.update(
1671+
_samples_collection_state(self._opening_samples_collection_locked(workspace, panel))
1672+
)
1673+
if state == existing.state:
1674+
return
1675+
workspace.ui.panels[panel.id] = PanelStateEntry(
1676+
state=state,
1677+
state_revision=existing.state_revision + 1,
1678+
)
1679+
16291680
def _apply_initial_panel_states_locked(
16301681
self,
16311682
workspace: WorkspaceState,
@@ -2814,6 +2865,7 @@ def replace_custom_panels(
28142865
self._bump_version()
28152866
return workspace
28162867

2868+
previous_panels = {panel.id: panel for panel in workspace.ui.custom_panels}
28172869
workspace.ui.custom_panels = next_panels
28182870
workspace.ui.has_explicit_view = next_has_explicit_view
28192871
workspace.ui.active_panel_id = next_active_panel_id
@@ -2823,6 +2875,11 @@ def replace_custom_panels(
28232875
self._prune_panel_states_locked(workspace)
28242876
for panel in next_panels:
28252877
self._seed_default_panel_state_locked(workspace, panel)
2878+
previous = previous_panels.get(panel.id)
2879+
if previous is not None and _authored_collection_id(
2880+
previous
2881+
) != _authored_collection_id(panel):
2882+
self._reopen_samples_collection_locked(workspace, panel)
28262883
self._apply_initial_panel_states_locked(
28272884
workspace,
28282885
next_panels,

‎src/hyperview/storage/config.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,42 @@
55
from pathlib import Path
66

77

8+
def get_default_home_dir() -> Path:
9+
"""The directory HyperView keeps its state in.
10+
11+
``HYPERVIEW_HOME`` when set, otherwise ``~/.hyperview``. Datasets, media,
12+
and the workspace, provider, and job registries all live under it unless
13+
an individual ``HYPERVIEW_*_DIR`` variable moves one of them. Point two
14+
processes at different homes and they share nothing.
15+
"""
16+
env_dir = os.environ.get("HYPERVIEW_HOME")
17+
if env_dir:
18+
return Path(env_dir)
19+
return Path.home() / ".hyperview"
20+
21+
822
def get_default_datasets_dir() -> Path:
923
"""Get the default datasets directory.
1024
11-
Uses HYPERVIEW_DATASETS_DIR env var if set, otherwise ~/.hyperview/datasets/
25+
Uses HYPERVIEW_DATASETS_DIR env var if set, otherwise <home>/datasets/
1226
Each dataset gets its own subdirectory with isolated LanceDB tables.
1327
"""
1428
env_dir = os.environ.get("HYPERVIEW_DATASETS_DIR")
1529
if env_dir:
1630
return Path(env_dir)
17-
return Path.home() / ".hyperview" / "datasets"
31+
return get_default_home_dir() / "datasets"
1832

1933

2034
def get_default_media_dir() -> Path:
2135
"""Get the default media directory for downloaded images.
2236
23-
Uses HYPERVIEW_MEDIA_DIR env var if set, otherwise ~/.hyperview/media/
37+
Uses HYPERVIEW_MEDIA_DIR env var if set, otherwise <home>/media/
2438
Similar to FiftyOne's ~/fiftyone/huggingface/hub/ pattern.
2539
"""
2640
env_dir = os.environ.get("HYPERVIEW_MEDIA_DIR")
2741
if env_dir:
2842
return Path(env_dir)
29-
return Path.home() / ".hyperview" / "media"
43+
return get_default_home_dir() / "media"
3044

3145

3246
@dataclass

‎tests/conftest.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77
registry the demos and Static Space exports read from, where a test's workspace
88
id can collide with a real one.
99
10-
Redirecting both storage roots at a per-session temporary directory pins every
11-
registry path with them: `get_runtime_config_dir()` derives the config directory
12-
from `datasets_dir.parent`, so workspaces.json, providers.json and jobs.json all
13-
follow the datasets directory here.
10+
Redirecting `HYPERVIEW_HOME` and both storage roots at a per-session temporary
11+
directory pins every registry path with them: `get_runtime_config_dir()` is the
12+
home directory, so workspaces.json, providers.json and jobs.json all land there.
1413
"""
1514

1615
from __future__ import annotations
@@ -30,9 +29,11 @@ def isolate_hyperview_store(tmp_path_factory: pytest.TempPathFactory):
3029
media_dir.mkdir()
3130

3231
previous = {
32+
"HYPERVIEW_HOME": os.environ.get("HYPERVIEW_HOME"),
3333
"HYPERVIEW_DATASETS_DIR": os.environ.get("HYPERVIEW_DATASETS_DIR"),
3434
"HYPERVIEW_MEDIA_DIR": os.environ.get("HYPERVIEW_MEDIA_DIR"),
3535
}
36+
os.environ["HYPERVIEW_HOME"] = str(root)
3637
os.environ["HYPERVIEW_DATASETS_DIR"] = str(datasets_dir)
3738
os.environ["HYPERVIEW_MEDIA_DIR"] = str(media_dir)
3839
try:

‎tests/test_public_panel_api.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,46 @@ def test_a_samples_panel_opens_on_its_authored_collection_whatever_its_id() -> N
175175
assert panels["everything"].state["collection"]["kind"] == "all"
176176

177177

178+
def test_reauthoring_the_samples_collection_moves_a_persisted_panel() -> None:
179+
session, runtime, workspace_id = _make_session()
180+
first = session.create_collection(["sample-4", "sample-2"], name="A", workspace_id=workspace_id)
181+
second = session.create_collection(["sample-1"], name="B", workspace_id=workspace_id)
182+
183+
session.ui.apply_view(
184+
hv.ui.View(hv.ui.Samples(id="samples", mode="results", collection_id=first)),
185+
workspace_id=workspace_id,
186+
)
187+
# A visitor navigates elsewhere; re-applying the same view must not undo that.
188+
runtime.patch_panel_state(
189+
workspace_id,
190+
"samples",
191+
_samples_state_for(runtime, workspace_id, second),
192+
)
193+
session.ui.apply_view(
194+
hv.ui.View(hv.ui.Samples(id="samples", mode="results", collection_id=first)),
195+
workspace_id=workspace_id,
196+
)
197+
assert runtime.get_workspace(workspace_id).ui.panels["samples"].state["collection_id"] == second
198+
199+
# The author changing the collection is a new statement, and it wins.
200+
session.ui.apply_view(
201+
hv.ui.View(hv.ui.Samples(id="samples", mode="results", collection_id=second)),
202+
workspace_id=workspace_id,
203+
)
204+
session.ui.apply_view(
205+
hv.ui.View(hv.ui.Samples(id="samples", mode="results", collection_id=first)),
206+
workspace_id=workspace_id,
207+
)
208+
state = runtime.get_workspace(workspace_id).ui.panels["samples"].state
209+
assert state["collection_id"] == first
210+
assert state["collection"]["id"] == first
211+
212+
213+
def _samples_state_for(runtime, workspace_id: str, collection_id: str) -> dict:
214+
collection = runtime.get_workspace(workspace_id).collections[collection_id]
215+
return {"collection_id": collection.id, "collection": collection.to_dict()}
216+
217+
178218
def test_an_unknown_authored_collection_falls_back_to_every_sample() -> None:
179219
session, runtime, workspace_id = _make_session()
180220

‎tests/test_workspace_layout_and_registry.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,29 @@ def read_registry() -> None:
257257
reader.join(timeout=5)
258258

259259
assert not parse_errors
260+
261+
262+
def test_runtime_config_dir_follows_hyperview_home(monkeypatch, tmp_path) -> None:
263+
from hyperview.runtime import get_runtime_config_dir
264+
from hyperview.storage.config import get_default_datasets_dir, get_default_media_dir
265+
266+
home = tmp_path / "home"
267+
monkeypatch.setenv("HYPERVIEW_HOME", str(home))
268+
monkeypatch.delenv("HYPERVIEW_DATASETS_DIR", raising=False)
269+
monkeypatch.delenv("HYPERVIEW_MEDIA_DIR", raising=False)
270+
271+
assert get_default_datasets_dir() == home / "datasets"
272+
assert get_default_media_dir() == home / "media"
273+
assert get_runtime_config_dir() == home
274+
275+
# A datasets dir elsewhere moves the data, not the registries.
276+
monkeypatch.setenv("HYPERVIEW_DATASETS_DIR", str(tmp_path / "elsewhere" / "datasets"))
277+
assert get_runtime_config_dir() == home
278+
279+
280+
def test_runtime_config_dir_without_home_is_the_datasets_parent(monkeypatch, tmp_path) -> None:
281+
from hyperview.runtime import get_runtime_config_dir
282+
283+
monkeypatch.delenv("HYPERVIEW_HOME", raising=False)
284+
monkeypatch.setenv("HYPERVIEW_DATASETS_DIR", str(tmp_path / "run" / "datasets"))
285+
assert get_runtime_config_dir() == tmp_path / "run"

0 commit comments

Comments
 (0)