exlab_wizard.api.routers.browse#

/tree and /run/{path} browse endpoints. Backend Spec §4.6.1.

Two endpoints back the Frontend’s tree view (Frontend §3.6) and run detail panel (Frontend §3.6.2):

  • GET /tree – equipment / project / run hierarchy.

  • GET /run/{path} – single-run detail (template, operator, sync status, run kind, README content).

The router walks the local filesystem under each configured equipment root via os.scandir (the same iterator the validator uses for audit-mode walks; §4.5). creation.json is decoded via msgspec.json.decode per §4.4.5.

Functions

build_browse_router()

Construct the /tree + /run router.

build_hierarchy_dict(config)

Compose the nested hierarchy dict that ui.components.tree.build_tree expects.

build_received_equipment_nodes(config)

Walk the staging root and surface received-equipment nodes.

scan_folder_sync(folder_path, config)

Synchronous core of the GET /folder/{path} endpoint.

Classes

EquipmentNode(**data)

An owned-equipment node in the tree (Redesign §3.3).

FolderEntry(**data)

One row in the new GET /folder/{path} response.

FolderResponse(**data)

Immediate contents of one folder.

ProjectNode(**data)

RelayEquipmentNode(**data)

A received-equipment node auto-discovered from the staging area.

RunDetail(**data)

RunLogEntry(**data)

One row in the GET /run/{path}/log response.

RunLogResponse(**data)

GET /run/{path}/log response (Redesign §4.6 View-log surface).

RunNode(**data)

TreeResponse(**data)

class exlab_wizard.api.routers.browse.EquipmentNode(**data)[source]#

Bases: BaseModel

An owned-equipment node in the tree (Redesign §3.3).

sync_mode (“nas” | “stage”) drives the per-equipment badge. relay is False for owned equipment; the received-equipment node type has its own RelayEquipmentNode shape below.

Parameters:

data (Any)

id: str#
label: str#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

path: str#
projects: list[ProjectNode]#
relay: bool#
sync_mode: str#
class exlab_wizard.api.routers.browse.FolderEntry(**data)[source]#

Bases: BaseModel

One row in the new GET /folder/{path} response. Redesign §4.3 / §5.

Operator-free per-file NAS sync design (2026-05-21): sync_status is one of the five GUI display states – acquiring / syncing / synced / on_nas – sourced from the run’s sync_state.json. A tombstone entry (is_dir=False, size_bytes=None, modified_iso=None) represents a file present in sync_state.json but absent on disk – a cleared run still lists its files as “On NAS”. keep_local carries the file’s keep-local flag for the badge.

Parameters:

data (Any)

is_dir: bool#
keep_local: bool#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

modified_iso: str | None#
name: str#
path: str#
size_bytes: int | None#
sync_status: str | None#
tombstone: bool#
class exlab_wizard.api.routers.browse.FolderResponse(**data)[source]#

Bases: BaseModel

Immediate contents of one folder. Redesign §5 (live file feed).

Parameters:

data (Any)

entries: list[FolderEntry]#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

path: str#
class exlab_wizard.api.routers.browse.ProjectNode(**data)[source]#

Bases: BaseModel

Parameters:

data (Any)

has_creation_json: bool#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

name: str#
path: str#
runs: list[RunNode]#
test_runs: list[RunNode]#
class exlab_wizard.api.routers.browse.RelayEquipmentNode(**data)[source]#

Bases: BaseModel

A received-equipment node auto-discovered from the staging area.

Redesign §3.3: the orchestrator surfaces equipment it has received runs for, even though they are not in its local config registry.

Parameters:

data (Any)

id: str#
label: str#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

path: str#
projects: list[ProjectNode]#
relay: bool#
class exlab_wizard.api.routers.browse.RunDetail(**data)[source]#

Bases: BaseModel

Parameters:

data (Any)

label: str | None#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

operator: str | None#
path: str#
plugins_applied: list[dict[str, Any]]#
readme: str | None#
run_kind: str | None#
schema_version: str | None#
sync_status: str | None#
template: dict[str, Any] | None#
validation_overrides: list[dict[str, Any]]#
class exlab_wizard.api.routers.browse.RunLogEntry(**data)[source]#

Bases: BaseModel

One row in the GET /run/{path}/log response.

The orchestrator does not write per-run log files. After the operator-free per-file NAS sync redesign (2026-05-21) removed ingest.json, the per-run “log” is derived from the run’s sync-queue job: each entry carries the queue state and at timestamp, with queue extras (attempts, last_error …) forwarded as a free-form payload.

Parameters:

data (Any)

at: str | None#
host: str | None#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

payload: dict[str, Any]#
state: str#
class exlab_wizard.api.routers.browse.RunLogResponse(**data)[source]#

Bases: BaseModel

GET /run/{path}/log response (Redesign §4.6 View-log surface).

Parameters:

data (Any)

current_state: str | None#
history: list[RunLogEntry]#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

path: str#
class exlab_wizard.api.routers.browse.RunNode(**data)[source]#

Bases: BaseModel

Parameters:

data (Any)

has_creation_json: bool#
kind: str#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

name: str#
path: str#
sync_status: str | None#
class exlab_wizard.api.routers.browse.TreeResponse(**data)[source]#

Bases: BaseModel

Parameters:

data (Any)

equipment: list[EquipmentNode]#
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

received_equipment: list[RelayEquipmentNode]#
exlab_wizard.api.routers.browse.build_browse_router()[source]#

Construct the /tree + /run router.

Return type:

APIRouter

exlab_wizard.api.routers.browse.build_hierarchy_dict(config)[source]#

Compose the nested hierarchy dict that ui.components.tree.build_tree expects.

The GET /tree response is a flat shape (lists of equipment and received-equipment models); build_tree consumes a nested dict[EquipmentNode, dict[ProjectNode, list[RunNode]]] keyed by exlab_wizard.ui.components.tree.EquipmentNode / ProjectNode / RunNode. This helper bridges the two so the NiceGUI mount doesn’t have to re-implement the walk.

Returns an empty dict when config is None.

Parameters:

config (Any)

Return type:

dict[Any, dict[Any, list[Any]]]

exlab_wizard.api.routers.browse.build_received_equipment_nodes(config)[source]#

Walk the staging root and surface received-equipment nodes.

Redesign §3.3: auto-discovered from the runs the orchestrator has received; the equipment is NOT in this device’s config registry.

Parameters:

config (Any)

Return type:

list[RelayEquipmentNode]

exlab_wizard.api.routers.browse.scan_folder_sync(folder_path, config)[source]#

Synchronous core of the GET /folder/{path} endpoint.

Extracted so the NiceGUI mount can drive the same scan from a thread via asyncio.to_thread() (matching the convention in exlab_wizard.cache.equipment). Raises the same FastAPI HTTPException instances as the endpoint so the HTTP response code is preserved when called from a router; the NiceGUI mount catches them and renders the appropriate UI state.

Parameters:
  • folder_path (str)

  • config (Any)

Return type:

FolderResponse