Skip to content

Release 0.22.0#51

Open
pskeshu wants to merge 314 commits into
mainfrom
development
Open

Release 0.22.0#51
pskeshu wants to merge 314 commits into
mainfrom
development

Conversation

@pskeshu

@pskeshu pskeshu commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Release 0.22.0 — promotes development to main.

Bumps the version (0.22.0.dev00.22.0) across pyproject.toml and the README, adds the v0.22.0 changelog entry, and gitignores the stray D:/ runtime dir.

Highlights since v0.9.2

  • File-based storage (Gently3) — retired SQLite; FileStore/FileContextStore replace GentlyStore/agent_mind.db. All state is human-browsable YAML/JSONL/TIFF.
  • Web UI redesign — docked sliding chat panel, Home landing tab, non-blocking view-only login, recent-images across sessions.
  • Hardware — ACUITYnano temperature controller (+ HiveMQ SIM), SPIM-head F-drive with hard limits and focus/align plans, room-light toggle, device-layer terminal UI.
  • Agent + perception — perception pull tool, prompt context, event bridge, wake-router; live acquisition control with permissioned autonomy; retired napari.
  • Tooling/env — ruff lint/format, incremental mypy (CI + pre-commit, pinned 2.1.0), uv environment with offline/UI-only launch, relicense + author update.

Merge method: merge commit (preserve history). Tag v0.22.0 + GitHub Release to follow on the merged main.

🤖 Generated with Claude Code

pskeshu and others added 30 commits May 30, 2026 15:37
The Quick Start was stale for the 0.22 web-first line: it told first-timers to
install Node and `npm run build` the Ink TUI (retired this epoch), used
`pip install -r requirements.txt`, and documented nothing about the web UI or
the admin bootstrap. Rewrite for reality:

- Prereqs: drop Node/TUI; Python 3.10+, ANTHROPIC_API_KEY, optional
  GENTLY_STORAGE_PATH.
- Setup: `pip install -e .`.
- Launch: device layer + launch_gently opens the browser; document
  --no-browser, --offline, and the banner URL (default http://localhost:8080).
- New "First sign-in (accounts)" section: viewing is open, login elevates to
  control; first run prints a one-time admin password to the console (never
  logged); how to add users (POST /api/auth/users), recover (delete
  users.yaml), and GENTLY_NO_AUTH=1 to disable auth.
- Fix architecture note: the core store is the file-based FileStore, not
  GentlyStore (SQLite).

Status line 0.11.0 -> 0.22.0.dev0.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
First-timers increasingly reach for uv, and the project is a standard PEP 621
pyproject so uv works against it with no extra config. Offer both paths in
Setup instead of assuming pip/venv: `python -m venv` + `pip install -e .`, or
`uv venv` + `uv pip install -e .` (lockfile-free — there's no uv.lock to
maintain yet). Add a Launch note so uv users know to either activate .venv or
prefix commands with `uv run`.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The 10-minute offline guide had the same stale steps as the README: it told
readers to `pip install -r requirements.txt` and `npm run build` the terminal
UI that this epoch retires. Bring it in line with the web-first reality and
mirror the README's dual environment paths (venv+pip or uv), drop the Node
prerequisite, and add the uv-run / Windows `set` variants to the launch step.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Fills the docs/EVAL.md TODO referenced by gently/eval/__init__.py. Documents
the capture/replay substrate that already shipped (EventCapture / EventReplay /
ShadowRunner / DecisionLog) and a grounded, incremental design for exercising
the *real* wake-router + agent reasoning offline by replaying recorded sessions
on a controllable clock.

Covers the central wiring gap (replay currently targets a fresh EventBus the
agent never subscribes to), four compared approaches (event-stream replay,
Perceiver stub, full timelapse re-feed, shadow scoring), honest fidelity limits
(recorded perception != new perception, LLM nondeterminism, wake-router
coalesce/throttle vs replay clock, wall-clock reads), and a step-by-step build
order. Referenced from the PR's "Where we'd especially value help" section.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ans viewer)

The Quick Start got users installed and signed in but stopped at "open the
URL" — it never led them into actually using the app. Add a walkthrough that
takes a first user all the way through the core loop:

- open the agent chat (header Agent toggle / Ctrl+J, or Home's Start button +
  the /wizard setup),
- enter plan mode with /plan (agent as scientific collaborator, no hardware),
- describe an experiment in plain language -> the agent drafts a campaign of
  typed plan items with concrete specs,
- inspect it in the Plans tab (plan viewer): campaign card -> plan document,
  item statuses/specs + inspector, doc/board/graph/timeline views, versions.

Notes that plan mode works fully offline (--offline), so reviewers can try the
talk -> plan -> inspect loop without a microscope — which is exactly the
hands-on interface feedback the PR asks for.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Relicense and update author list
Fix 500 error on every page under Starlette 1.x
- Adopt uv for env + deps; declare gently-perception (sibling repo) via [tool.uv.sources] and add python-dotenv. Refresh README setup/launch docs.
- Migrate requirements*.txt into pyproject: drop the redundant requirements.txt, move device accessories (bleak/pyserial/paho-mqtt) to a [device] extra, and document the optional CUDA-torch install (kept opt-in, not a forced default).
- Auto-load a project-root .env on startup; OS-aware ANTHROPIC_API_KEY message.
- Add --no-api UI-only mode and an immediate startup log line before imports.
Switch environment setup to uv and add offline/UI-only launch
Pin pymmcore to device-interface 70 and restructure the environment
- config.yml: add `temperature:` block (serial backend on COM8) so the
  device layer registers a `temperature` device.
- Web Devices header: live water-temperature readout + setpoint control
  - data.py: GET /api/devices/temperature/status, POST /api/devices/temperature/set
  - devices.js: temperature panel (poll + set); main.css pill; index.html markup
- Vendor integration SDKs: MQTT (test_temperature_controller.py) and USB
  serial (test_temp_usb.py).
- piezo.py: harden DiSPIMFDrive with module-level hard travel limits
  (F_DRIVE_MIN_UM=30, F_DRIVE_MAX_UM=25000), non-overridable from above;
  configurable per-move Status timeout (120s) for slow full-travel traverses.
- device_factory.py: register the SPIM head as `fdrive` (ZStage:V:37).
- plans/acquisition.py: spim_head_focus_descent_plan (3-phase descent:
  fast traverse -> coarse 1000um steps -> fine sweep-and-fit under LED),
  register_views_xy_plan (dual-view XY registration), and the composite
  spim_head_focus_and_align_plan. Also fix get_stage_position_plan to use
  bps.rd's actual return value (it was indexed as a {name:{value}} dict).
- device_layer.py: register the new plans and mark them heavy.
- test_dispim_device_safety.py: F-drive bounds tests for the 30/25000 limits.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Integrate ACUITYnano temperature controller (config, web control, SDKs)
Add SPIM-head F-drive device, hard limits, and focus/align plans
- pyproject.toml: configure ruff (line-length=100, target-version=py310,
  select=E,F,I,UP,B) and add ruff/pre-commit to the dev dependency group.
- .pre-commit-config.yaml: ruff --fix + ruff-format hooks, pinned to
  v0.15.17 (matches the installed ruff; the older v0.4.0 pin in the issue
  template produced inconsistent UP038/format results).
- .github/workflows/lint.yml: CI job running `ruff check .` and
  `ruff format --check .` on every PR.
- CONTRIBUTING.md: local setup instructions (uv sync, pre-commit install,
  pre-commit run --all-files); notes that mypy is tracked separately (#46).
- Fix all ~716 pre-existing ruff violations across the codebase: E501
  (wrapped long lines, line-length raised to 100), E402, F401, F841, B904,
  B905, B007, B023, B008, B027, UP035, E741 (ambiguous `l` -> `learning`),
  E722, F402, and F821 (including two real bugs: missing **kwargs in
  multi_embryo_calibration_session_plan, and an undefined-variable return
  in sam_detection._detect_with_sam).

mypy was scoped out of this issue due to its size (3002 errors across
225/299 files); tracked as a follow-up in #46.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Add linting, formatting, and type checking with ruff, mypy, and pre-commit
Adds a lenient [tool.mypy] config with an explicit ignore_errors
override list for the 113 modules that don't yet pass, runs mypy in
the lint CI job and as a pre-commit hook, and adds mypy to the dev
dependency group. New code is held to the standard; the override
list shrinks as modules are cleaned up.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Annotates two implicit-Optional defaults and fixes a str/Path
arg-type mismatch in create_timelapse_video. Removes the file from
the mypy override list before it's even added.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Changes def f(x: T = None) to def f(x: T | None = None) to match
PEP 484 / mypy's no_implicit_optional, matching the pattern already
applied to bridge.py in #42. Removes 14 modules from the mypy
ignore_errors override list (113 -> 99) that are now fully clean.
…errors

The four ContextStore mixins (_intentions, _plans, _understanding,
_ml_pipelines) call methods/attributes defined on the host class or
sibling mixins (_conn, _tx, _now, _gen_id, get_plan_items,
create_campaign, etc.), which mypy couldn't see on the mixins
themselves. Add gently/harness/memory/_protocols.py declaring a
StoreProtocol with these members and have each mixin inherit from it
for typing only.

Also fixes the remaining errors this didn't cover: two untyped dict
literals in _plans.py needed explicit dict[str, Any] annotations, and
three _ml_pipelines.py methods returning an Optional lookup right
after an insert now assert the row exists.

All four mixins are now mypy-clean; removes them from the
ignore_errors override list (99 -> 95).
…y overrides

Adds ctx_get(context, key) and retypes the require_* helpers in
harness/tools/helpers.py to accept context: dict | None and return
non-Optional success values, eliminating the dominant union-attr/arg-type
pattern across the tool modules. Applies the same fix to memory_tools'
local _get_memory and a handful of independent var-annotated/type issues.

Removes all 16 gently.app.tools.* modules from the mypy override list
(95 -> 79), cutting the underlying error count from 610 to 369.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…remaining type gaps

- volume_tools.view_image: guard against a disconnected microscope client
  via require_microscope() instead of crashing on client.capture_bottom_image()
- file_store/store register_volume: volume_data: np.ndarray = None ->
  np.ndarray | None = None (implicit-Optional missed in the earlier pass)
- StoreProtocol: mark @runtime_checkable for consistency with
  gently.harness.protocols
- TimelapseOrchestrator.start: embryo_ids: list[str] -> list[str] | None = None
  to match its actual None-handling and the docstring

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…onal

CI installed mypy unpinned (`pip install mypy`) while the pre-commit hook
pinned mirrors-mypy v2.1.0, so the two would diverge the moment a newer
mypy released — "passes my pre-commit" would stop implying "passes CI".
Pin all three sources to 2.1.0 (CI install, pyproject dev group, and the
pre-commit rev) and cross-reference them so they move together.

Also convert the one implicit-Optional missed in Phase 1: `view_image`'s
`image: np.ndarray = None` in dispim/client.py (the other three params in
that same signature were already converted).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Gradually introduce mypy type checking (Phases 1-3)
The header pill, the home landing line, and the agent dock each computed
connection state from their own signal at their own time. home.js read
state.connected exactly once at tab init — before the /ws handshake — and
never corrected, so the landing showed "Offline — start the agent to
connect" while the header pill showed "Online" and state.connected was true.

Add a single sticky ConnectionStatus store (status-store.js) holding three
distinct signals — gentlyConnected (/ws), microscopeConnected
(/api/device-status poll), agentConnected (/ws/agent) — which replays its
current snapshot to every new subscriber, so a late subscriber can never
miss the initial state (the root of the bug). All three surfaces now read
from / write to this store:
- websocket.js onopen/onclose -> setGently (via updateGentlyStatus)
- app.js fetchDeviceStatus -> setMicroscope; header renders via subscriber
- home.js updateStatus reads the store and re-renders on every change
- agent-chat.js setConn -> setAgent

Verified live: after reload the home line and header pill agree (no more
"Offline while Online"); no console errors.

Bug #3 (idle event-count inflation) needs no code change: the high-frequency
telemetry (DEVICE_STATE_UPDATE/BOTTOM_CAMERA_FRAME) is already excluded from
the events table + count at websocket.js, and idle measurement showed the
count is calm and dominated by LOG_RECORD.

Co-Authored-By: Claude Fable 5 <[email protected]>
pskeshu and others added 30 commits June 30, 2026 18:49
…irm, detect candidates

Device layer + client:
- Register bottom-cam focus Z (DiSPIMZstage, z_stage) in device_factory; poll
  it + the SPIM-head F-drive in the slow position stream.
- Fenced read+nudge handlers for bottom_z and fdrive (reject out-of-range; the
  device classes hard-enforce limits; F-drive floor is sacrosanct).
- Inject a focus score (analysis.core.calculate_focus_score on the full frame)
  into the bottom-cam + lightsheet stream payloads.
- Client: get_bottom_z/nudge_bottom_z/get_fdrive/nudge_fdrive.

Web routes (data.py):
- detect_embryos now RETURNS candidates (no auto-register) for the marking canvas.
- POST /api/devices/embryos/confirm — agent-free register of operator-confirmed
  markers into experiment.embryos (single source of truth), fires EMBRYOS_UPDATE.
- Proxies: stage/bottom_z[/nudge], spim/fdrive[/nudge].

RIG-NOTE: z_stage MMCore axis assumed ZStage:Z:32 + limits (50,250)µm — rig owner
to confirm. Nudges use direct device.set() (mirrors camera-exposure handler).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
New device-tab 'Operate' view (1st in the switcher, hotkey 'o'): a single guided
flow mirroring the physical workflow, replacing the scattered detect/mark/SPIM UX.

- Zone 1 Survey & mark: enlarged bottom-cam live + fenced bottom-Z focus nudge +
  live focus score; Detect (SAM candidates) or click-to-mark on a FROZEN frame;
  positions only (no forced roles); Confirm → register into the canonical list.
- Zone 2 Embryos: the single source of truth (EMBRYOS_UPDATE) with per-embryo
  state chips (marked→centered→focused→imaged) + select.
- Zone 3 Acquire: Center (stage move) → lower SPIM head (F-drive, fenced) →
  inline SPIM focus (lightsheet live + galvo/piezo/LED nudge + focus score) →
  Acquire volume.

Professional, theme-aware styling (operate.css). DevicesManager activates/
deactivates OperateManager on view switch (streams only run while visible).
Verified in-browser end-to-end (detect→mark→confirm→list→per-embryo cycle).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
On Confirm, persist the annotated bottom-cam frame + per-marker pixel & stage
coords (+ source, capture pose, transform) as a labelled snapshot via the
existing FileStore.register_snapshot sidecar — every confirmation becomes a
training/eval example for a future SAM replacement. Best-effort: needs a
client-supplied frame + active session; never blocks embryo registration.
Frontend sends image_b64 + frame meta + pixel coords with the confirm payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Device layer: _log_focus_trace appends (t, source, focus_score, bottom_z,
  fdrive, piezo) to {session}/focus_traces.jsonl on every broadcast frame —
  the operator's manual focusing is captured passively (no autonomous Z moves)
  as autofocus-validation data. Best-effort, off the broadcast hot path.
- gently/analysis/focus_validation.py: offline replay — load traces, segment
  sweeps, compare each focus metric's argmax to the human's resting Z, report
  error stats + interior-peak/contrast quality (a flat curve = no safe autofocus).
- 8 unit tests (synthetic Gaussian sweeps) — all green.

Earns autofocus safety offline before any Z move near the objective.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…e workflow

Replaces the three always-on ~340px cards (cluttered, no visible logic) with a
single height-filling four-region surface, from an expert-driven redesign
(diSPIM-ops + microscopy-UX + C. elegans-bio + critique → 3 candidates → synthesis):

- Thin HEADER: a real phase stepper (Survey: Focus→Mark │ Acquire: Center→Lower→
  Focus→Acquire) giving 'you are here', + an always-on safety status strip
  (HEAD/floor/LED/LASER/live-cam) that persists even when controls are hidden.
- LEFT SPINE: a read-only dish mini-map (state-colored pins + stage crosshair) +
  the embryo worklist board (per-row 4-node progress track), the loop's spine.
- CENTER: ONE live viewport whose camera source swaps per step, with decision
  instruments overlaid (markers / centre reticle+FOV box / floor gauge).
- RIGHT RAIL: renders ONLY the active step's controls (progressive disclosure);
  a single renderStep() drives header+spine+viewport+rail from one state so they
  can't disagree. Exactly one camera + one step live at a time.

Bug fixes from the critique: (1) 'focused' is earned at the SPIM-focus step, not
on a stray F-drive nudge (new 'lowering' state); (2) LED force-closed on
step-leave and view-leave (no more leak); (3) down-nudges auto-grey near the
floor and XY centering is blocked while the head is lowered.

Pure IA/reveal/gating over existing endpoints/streams/SSOT — no backend change.
Verified end-to-end in-browser (focus→mark→confirm→per-embryo center/lower/focus/
acquire→retract); stepper, status, board, and mini-map all track in lockstep.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ves only in Operate

Finishes the migration: the Map view keeps its read-only embryo dots; the
Detect / mark / per-embryo Center panel (an interim home) is removed now that the
Operate view owns that workflow. Drops the detect panel markup, its OperateManager-
duplicating JS in devices.js (renderEmbryoListPanel/runDetection/centerOnEmbryo/
removeEmbryo/setupDetectWiring), and the orphaned CSS; keeps the top-right rail +
XY readout.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Expert Opus workflow study + synthesis. Decisions: build all 3 phases; adaptive
default monitoring = idle; live run monitored in-Operate (rail flips to run-spine).
Tactics are the unifying object; new keystone = a deterministic Tactic Executor
(first caller of resolve_scope_embryos).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
After Confirm, Operate no longer dead-ends into the manual loop; the stepper
advances to a new ③ Run node and the rail shows a Run chooser. Tactics are the
unifying object — every run mode targets the marked set.

- Stepper: ① Focus → ② Mark → ③ Run; renderStep() drives a new c0 (chooser) and
  running (run-spine) state alongside a1/a2/b1-b5.
- Role chip strip (marked default → subject; flip to reference) → POST
  /api/embryos/roles (new thin route: sets EmbryoState.role + fires EMBRYOS_UPDATE).
  Load-bearing: expression_monitoring scopes to role=='test'.
- Mode A Manual → the existing per-embryo loop. Mode B Adaptive timelapse
  (interval/stop/monitor, default idle) → POST /api/devices/timelapse/start with
  embryo_ids=[subjects]. Mode C3 Hand-to-agent → AgentChat with the roster.
  Library/plan modes stubbed (wired in Phase 3).
- Live run-spine in the rail (reads GET /api/operation_plan, falls back to a
  summary card) + Pause/Stop/Resume (new /api/devices/timelapse/stop|pause|resume
  routes) + Open-in-Operations.

Verified in-browser: mark→confirm→chooser→roles→adaptive start→run-spine, and
Manual→b-loop, survey→a1. Backend timelapse start is rig/session-gated (503 w/o
orchestrator); UI flow + routes verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- /api/devices/timelapse/start now seeds the session Operation Plan with a
  standing_timelapse tactic (+ a reactive_monitor layered_on when a monitoring
  mode is active) scoped to the marked set, transitioned active — closing the
  'UI timelapses skip plan linking' TODO. Best-effort (needs a live session +
  context store); never blocks the start. So the Operate run-spine + Operations
  tab now show a real tactic for a UI-started timelapse.
- start_adaptive_timelapse gains tactic_id (transitions the tactic active on
  success) — lifecycle symmetry with stop/pause/enable_monitoring_mode/queue_burst.

Note: no tactic-schema change needed — _validate_tactics copies structure
verbatim, so cadence_s/stop_condition/monitoring_mode/interval are already
allowed. Seeding is rig/session-verified (no session in the hardware-free shim).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… modes

Keystone: gently/app/orchestration/tactic_executor.py — execute_tactic(agent,
tactic) resolves scope (the FIRST caller of resolve_scope_embryos) and dispatches
by kind to the orchestrator (standing_timelapse[+monitoring] / reactive_monitor /
exclusive_burst / oneshot), then marks the tactic active. One kind→action map the
agent and the UI both go through. 8 unit tests (mocked orchestrator/roster), all green.

- POST /api/operate/run-tactic — append a tactic (or instantiate a saved one via
  library_id) re-scoped to the marked set, then execute it via the executor.
- Operate Run modes wired: From-library → run-tactic(library_id); Manual → fires a
  cosmetic oneshot so the sweep shows on the spine; Continue-a-plan & Hand-to-agent
  → AgentChat hand-off (the agent owns plan resolution + composed tactics).
- Run-spine now renders the real Operation-Plan tactic cards (GET /api/operation_plan).

Verified in-browser: chooser→library select→run-tactic→running run-spine shows the
tactic; run-tactic route 200 end-to-end in shim. resolve_scope_embryos is no longer
orphaned. (No tactic-schema change needed — structure is free-form.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Adversarial review (Opus workflow, 8/8 confirmed) → all fixed:
- HIGH: Run chooser was a one-time gate (unreachable after a manual run or
  Survey/mark-more). The 'Run' stepper node is now a clickable re-entry to the
  chooser; finishing a manual sweep returns to the chooser, not Focus.
- MED: timelapse/start seeded scope embryos:[] when embryo_ids omitted (run images
  ALL) → now records {mode:'global'} so the plan matches the run.
- MED: B1 (Center) showed a frozen frame — the chooser stops all cameras and B1
  had no Start button. Bottom cam now auto-starts at B1 (live centering feedback).
- MED: adaptive 'after N timepoints' degraded to manual (never stops) — now sends
  the combined 'timepoints:N' / 'duration:Nh' form the parser understands.
- MED: stop/pause/resume never reconciled the seeded tactic + every Start appended
  a new active one → accumulating stale/duplicate active timelapses. Now: seeded
  tactics are tagged + linked to the run; stop→done, pause→paused, resume→active;
  prior active operate tactics are retired on a new Start; skip seeding when start
  was a no-op ('already running').
- LOW: run-tactic 500 on a malformed tactic → 400 + append_tactic_to_plan defaults
  kind='custom'.
- LOW: run-spine fallback hard-coded the adaptive shape ('undefineds' for a library
  run) → branches on mode, guards the interval interpolation.
- LOW: manual mode discarded chooser role toggles → applyRoles() now runs for every
  mode (moved to the top of startRun).

Verified in-browser (chooser re-entry, 400 path) + 16 unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…-all' into feature/temperature-operations-all
… visibility

Opus audit: settings panel is 100% localStorage display prefs; the thermalizer
connection (serial/MQTT/mock) is YAML-only + device-layer-restart. Design: a
server-backed Hardware/Thermalizer section (Test + live hot-swap w/ restart
fallback, sidecar persistence, secrets redacted), Mock dev-only, + a read-only
effective-config viewer. Decisions: full build; live-swap w/ restart fallback;
Mock dev-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ewer

The Settings panel was 100% localStorage display prefs. Adds a server-backed
'Hardware / Thermalizer' section to view + edit the controller connection, and a
read-only effective-config viewer.

Device layer (aiohttp): GET /api/temperature/config (redacted), POST
/api/temperature/config/test (transient probe, non-committing), POST
/api/temperature/config (live hot-swap: 409 if RunEngine running / ramp lock held,
build-new-before-swap, close old, sidecar-persist; restart-required fallback on
connect failure). config.local.yml sidecar merged over config.yml at boot (keeps
comments; 0600 perms for the MQTT password). Validator + password redaction.

Client: get/set/test_temperature_config. Viz proxies (require_control on
write/test) + GET /api/config/effective (settings.py + config.yml, secrets shown
as present/absent booleans; restart-required note).

UI: Hardware nav group; Thermalizer section (Serial/MQTT; Mock dev-only via
?dev=1) with Test + Apply (explicit, no auto-save), applied-live vs
restart-required banner; a separate ThermalizerSettings JS module isolated from
the localStorage SettingsManager; read-only Effective-config viewer. Relabeled the
Vitals 'Temperature model' → 'Developmental-timing reference' to kill the naming
trap.

Verified in-browser (render, serial↔MQTT toggle, Mock-hidden, Test graceful 502,
effective-config populated). Rig-only: real serial/MQTT connect + live-swap need
the vendor SDK + device layer (shim shows 'not available'/502 gracefully).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- MED: Test connection now preserves the stored MQTT password (shared
  _preserve_temp_password helper), so Test probes the same credentials Apply
  commits — a valid live config is no longer falsely reported broken.
- MED: thermalizer/effective edits no longer trigger SettingsManager's
  localStorage auto-save or its false 'Settings saved' toast (change listener
  now skips #section-thermalizer/#section-effective).
- LOW: the device-layer 409 (run/ramp active) is flattened to 200 by the proxy,
  so the UI detects it via a body 'blocked' flag instead of the dead
  res.status===409 branch — shows 'Blocked:' not a generic 'Failed:'.
- LOW: sidecar config.local.yml is created 0600 atomically (os.open O_CREAT
  0o600) so the plaintext MQTT password is never briefly world-readable.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…red editors

Completes the settings-panel Phase 2 alongside the effective-config viewer.

Dashboard-pref defaults (server-backed): GET/PUT /api/config/dashboard-defaults
(JSON in config/). settings.js now layers effective config as hardcoded <
rig-wide server defaults < per-browser localStorage, plus a defaults bar —
Save-as-rig-defaults, Reset-to-defaults, Export, Import — so prefs aren't trapped
per-browser.

Restart-required editors: an allowlisted set of settings.py knobs (timeouts,
mesh timing, ML, ux_v2, NCBI) editable via GET/PUT /api/config/settings-overrides
(require_control), persisted to config/settings.local.yml. settings.py now merges
that file into the environment at import (os.environ.setdefault, so a real env var
still wins) — every entry point picks it up on the next restart; the frozen
settings singleton is never live-mutated. New 'Advanced' panel section renders the
knobs with a 'Save (restart required)' banner.

Also: made the sticky settings footer opaque (var(--bg-dark) + top border) with
the defaults bar laid out inline, fixing the transparent-overlap on long
sections. Runtime override files gitignored.

Verified in-browser: server-defaults layering, Advanced render (13 knobs) +
save round-trip to settings.local.yml, fresh-import applies the file while a shell
env var still wins, footer no longer overlaps content. No console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…dvanced UI

Auditing the exposed knobs showed several with ZERO runtime readers — editing them
would be a silent no-op, and one was a vestige of the removed RPyC layer:
- Removed settings.timeouts.rpc_call entirely (RPyC-era; 0 readers repo-wide; the
  HTTP client uses its own aiohttp ClientTimeout). Dropped from the effective viewer.
- Pruned the Advanced editor to knobs with verified live readers: timeouts
  {volume_acquisition, api_call}, mesh {broadcast/stale/dead}, ui.ux_v2, api
  {ncbi_tool, ncbi_email}. Omitted timeouts.plan_execution + ml.{batch,epochs,lr}
  (0 readers today — no-ops).
- Grouped the Advanced section (Timeouts / Mesh network / Interface / NCBI) with
  subheadings instead of a flat list.

Verified in-browser: 8 grouped knobs, rpc_call absent, save round-trip intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
store_prediction computed the next prediction_id by _read_jsonl()-ing and
JSON-parsing the ENTIRE predictions.jsonl on every append — O(n) per prediction,
quadratic over a long timelapse, and a synchronous blocking read on the app event
loop (the perception-persist path). Replace with _last_jsonl_record(): read a
bounded 64 KB tail window and take the last record's prediction_id + 1. Ids stay
sequential (we only ever append in order); robust to a trailing partial line from
an interrupted write. Verified: identical ids, 20k-record file read in 0.26 ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Code-grounded map of how gently manages concurrent work: the two-process split
(app/viz :8080 <-> device-layer :60610 over HTTP + shared filesystem), one
asyncio loop per process, hardware serialized behind a single plan queue +
executor (and pymmcore g_core_lock), how polling coexists with long experiments
(to_thread offload, split pollers, reference-counted pause_state_updates,
telemetry bypassing the queue), filesystem image transfer, fire-and-forget
perception/VLM + EventBus, backpressure/failure isolation, and known limits
(incl. the now-fixed O(n) prediction write).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…fline

These read-only proxy GETs are polled by the manual/settings pages. When the
device layer isn't running, _resolve_client() returns a client that is not
connected, so the call raised ConnectionError and logged a full ERROR traceback
on every poll — noisy for an expected state (UI open without the device process).
Pre-check client.is_connected and return a quiet 503 instead; genuine failures
(connected but call errors) still log at ERROR.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Control routes 403 in account mode when no operator is logged in — previously the
only signal was a bare console 403 and a button that silently did nothing. Add a
single global fetch wrapper (control-auth.js) that detects a 403 on a mutating
/api/ request and shows a throttled 'Control required — Log in' toast (reusing
showGentlyToast) whose action navigates to /login. Reads only res.status, never
consumes the body, so callers are unaffected.

Verified end-to-end in account mode: real 403 -> toast with the message + Log in
button -> navigates to /login.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Vendor granted permission to redistribute their serial transport, so it's
now bundled under gently.hardware.vendor instead of requiring a per-machine
install. temperature.py loads it via _load_vendor(), which prefers a
system-installed copy of the same package name (letting an official vendor
update override) and falls back to the bundled copy.

The MQTT transport (acuitynano_precision_thermalizer_api) is deliberately
NOT bundled: it embeds broker credentials. Install it on the device-layer
machine for backend: mqtt.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Cherry-pick of 87a52c5 (gently-project/development): the device-layer
stack (pymmcore/bluesky/ophyd + bleak/pyserial/paho-mqtt) moves from the
base deps and the `device` *extra* into a `[dependency-groups] device`
group that is default-on via `[tool.uv] default-groups = ["dev", "device"]`.

Dependency groups are not pruned by `--extra` selection, so
`uv sync --extra sam --extra torch-gpu` no longer strips pyserial/paho-mqtt/
bleak (which broke the temperature serial/MQTT backends and the SwitchBot
room light). Drop the stack on a no-microscope machine with
`uv sync --no-group device`.

Lock regenerated locally rather than taking the upstream uv.lock (branch has
diverged); dependency set is unchanged, only group markers move.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Two regressions in the operate-tab detect/mark flow that made calibration
image empty field:

1. Marking stage origin. The bottom-cam stream carried no stage position, so
   the operate canvas converted clicked pixels to stage coords relative to
   [0,0] instead of the true stage XY — embryos landed hundreds of µm off and
   calibration saw blank buffer. The device layer now stamps the real XY onto
   every bottom-cam frame (_current_stage_xy, never [0,0]); operate.js prefers
   that, falls back to the position stream, and BLOCKS marking (rather than
   silently using [0,0]) when neither is known. confirmMarks() guards the same.

2. SAM room-light fallback. detect_embryos forced the camera LED off before
   trying the room light; a missing/failed SwitchBot then left the frame
   near-black and SAM found nothing. Now the LED is only disabled once the
   room light is actually on; otherwise it falls back to the camera LED.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The operate spine went B3 focus → B4 acquire with no calibration, so volumes
were acquired without a per-embryo piezo↔galvo fit. Add a "Calibrate" step
(node bc) between Focus and Acquire:

- Frontend: new stepper node + rail group, a 'calibrated' state ranked between
  focused and imaged, wired into the single renderStep() driver (step map,
  camera map, stepper order, board/minimap colors). Focus now advances to bc;
  Calibrate (or Skip) advances to Acquire. Acquire's guard relaxes from
  ==focused to >=focused so a calibrated embryo can be imaged.
- Backend: POST /api/devices/embryos/{id}/calibrate runs the agent's proven
  calibrate_embryo tool via the registry (Claude-vision edge detection +
  adaptive sweep, which sets the light-sheet laser config per snap) with an
  injected agent/client context — no LLM orchestration. Result persists on
  embryo.calibration; the rail shows slope + R².

Engine choice: reuses calibrate_embryo rather than the bare
calibrate_piezo_galvo_plan, which defers to focus_sweep_plan and does not
clearly set the laser config (risking the dark-frame failure just fixed).
Not yet hardware-verified end to end — the step + wiring are in place for a
rig test.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The Devices > Map viewBox was computed from only the firmware fence box and
the current stage marker — embryos were never included. SAM/marked embryos
often sit hundreds of µm outside that frame (e.g. a detection cluster near
stage X≈-800 while the fence box straddles origin), so they rendered at the
map edge or off-screen and looked mis-positioned, even though their stored
coords are correct absolute stage µm.

- computeViewBox() now expands the bounds to include every embryo's resolved
  XY (fine if aligned, else coarse).
- handleEmbryosUpdate() now always recomputes the viewBox (via computeViewBox's
  changed-return) and does a full renderMap() when the frame widened, instead
  of only reframing on the first-ever load. Newly-arrived embryos now pull the
  frame out to include them.

Not a coordinate bug: verified the SAM path records absolute stage coords
(stage_position + pixel offset), consistent with the map's stage→SVG transform.
This is purely framing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…WS disconnect spam

Two robustness fixes for long-running sessions:

- registry.execute() now coerces incoming string arguments to their
  annotated int/float/bool types before invoking the handler. Tool inputs
  arrive as JSON from the model (and sometimes as UI form strings), so a
  param annotated `float` could show up as "120" and crash on a numeric
  comparison — e.g. set_embryo_cadence raised
  '<' not supported between instances of 'str' and 'int'. Coercion is
  conservative: only string values whose annotation resolves to a scalar
  are touched; unparseable values pass through for the tool to validate.

- log_config now silences the `websockets` library's ERROR-level
  "data transfer failed" tracebacks (raised on every ungraceful client
  disconnect / WinError 121). These are routine and flooded the console,
  burying real errors. Set below CRITICAL so genuine faults still surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Resolve conflicts:
- gently/app/agent.py: union of both sides — keep development's typed
  monitor annotations + TYPE_CHECKING imports alongside this branch's
  temperature_sampler, operation_plan_updater, lightsheet_monitor
- uv.lock: regenerated with 'uv lock' from the merged pyproject.toml
The merge of development made mypy check function bodies the PR branch
had left untyped (development annotated the enclosing defs), surfacing
11 cross-branch errors; both parents were individually clean.

- agent.py: annotate temperature_sampler / operation_plan_updater /
  lightsheet_monitor attributes; guard seed_operation_plan_from_plan_item
  against a None session_id
- client.py: assert _session before lightsheet stream GET (house
  pattern); type the live-params body dict
- device_layer.py: annotate self.config and the sidecar 'existing' dict
- data.py: annotate new_tactics
- ruff format over 7 files the two branches formatted differently
…ions-all

UX v2 · Temperature + Operations/Tactics + Operate (bottom-cam→SPIM) suite
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants